mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-05-24 01:13:00 -04:00
Merge branch 'advplyr:master' into master
This commit is contained in:
commit
e64302f1d4
@ -374,19 +374,27 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
|
||||
if ('mediaSession' in navigator) {
|
||||
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
|
||||
const artwork = [
|
||||
{
|
||||
src: coverImageSrc
|
||||
}
|
||||
]
|
||||
const chapterInfo = []
|
||||
if (this.chapters.length) {
|
||||
this.chapters.forEach((chapter) => {
|
||||
chapterInfo.push({
|
||||
title: chapter.title,
|
||||
startTime: chapter.start
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: this.title,
|
||||
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
|
||||
album: this.mediaMetadata.seriesName || '',
|
||||
artwork
|
||||
artwork: [
|
||||
{
|
||||
src: this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
|
||||
}
|
||||
]
|
||||
})
|
||||
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<article class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||
<div class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||
<nuxt-link :to="`/author/${author?.id}`">
|
||||
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<article ref="card" :id="`book-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div ref="card" :id="`book-card-${index}`" tabindex="0" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
|
||||
<!-- When cover image does not fill -->
|
||||
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
@ -128,7 +128,7 @@
|
||||
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
|
||||
<div :style="{ fontSize: 0.9 + 'em' }">
|
||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<p cy-id="title" ref="displayTitle" aria-hidden="true" class="truncate">{{ displayTitle }}</p>
|
||||
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
||||
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@ -138,7 +138,7 @@
|
||||
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<article cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||
@ -21,14 +21,14 @@
|
||||
|
||||
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||
<p cy-id="standardBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
|
||||
<p cy-id="detailBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
<p cy-id="detailBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -19,12 +19,13 @@
|
||||
<ui-text-input v-model="currentShareUrl" show-copy readonly class="text-base h-10" />
|
||||
</div>
|
||||
<div class="w-full py-2 px-1">
|
||||
<p v-if="currentShare.expiresAt" class="text-base">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p>
|
||||
<p v-if="currentShare.isDownloadable" class="text-sm mb-2">{{ $strings.LabelDownloadable }}</p>
|
||||
<p v-if="currentShare.expiresAt">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p>
|
||||
<p v-else>{{ $strings.LabelPermanent }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-4">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-2">
|
||||
<div class="w-full sm:w-48">
|
||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label>
|
||||
<ui-text-input v-model="newShareSlug" class="text-base h-10" />
|
||||
@ -46,6 +47,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center w-full md:w-1/2 mb-4">
|
||||
<p class="text-sm text-gray-300 py-1 px-1">{{ $strings.LabelDownloadable }}</p>
|
||||
<ui-toggle-switch size="sm" v-model="isDownloadable" />
|
||||
<ui-tooltip :text="$strings.LabelShareDownloadableHelp">
|
||||
<p class="pl-4 text-sm">
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareURLWillBe', [demoShareUrl])" />
|
||||
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareExpirationWillBe', [expirationDateString])" />
|
||||
</template>
|
||||
@ -81,7 +91,8 @@ export default {
|
||||
text: this.$strings.LabelDays,
|
||||
value: 'days'
|
||||
}
|
||||
]
|
||||
],
|
||||
isDownloadable: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -172,7 +183,8 @@ export default {
|
||||
slug: this.newShareSlug,
|
||||
mediaItemType: 'book',
|
||||
mediaItemId: this.libraryItem.media.id,
|
||||
expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0
|
||||
expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0,
|
||||
isDownloadable: this.isDownloadable
|
||||
}
|
||||
this.processing = true
|
||||
this.$axios
|
||||
|
@ -138,7 +138,6 @@ export default {
|
||||
.$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Books removed from collection`, updatedCollection)
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@ -152,7 +151,6 @@ export default {
|
||||
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Book removed from collection`, updatedCollection)
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@ -167,12 +165,11 @@ export default {
|
||||
this.processing = true
|
||||
|
||||
if (this.showBatchCollectionModal) {
|
||||
// BATCH Remove books
|
||||
// BATCH Add books
|
||||
this.$axios
|
||||
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Books added to collection`, updatedCollection)
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@ -187,7 +184,6 @@ export default {
|
||||
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Book added to collection`, updatedCollection)
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@ -214,7 +210,6 @@ export default {
|
||||
.$post('/api/collections', newCollection)
|
||||
.then((data) => {
|
||||
console.log('New Collection Created', data)
|
||||
this.$toast.success(`Collection "${data.name}" created`)
|
||||
this.processing = false
|
||||
this.newCollectionName = ''
|
||||
})
|
||||
|
@ -130,7 +130,6 @@ export default {
|
||||
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
|
||||
.then((updatedPlaylist) => {
|
||||
console.log(`Items removed from playlist`, updatedPlaylist)
|
||||
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@ -148,7 +147,6 @@ export default {
|
||||
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
|
||||
.then((updatedPlaylist) => {
|
||||
console.log(`Items added to playlist`, updatedPlaylist)
|
||||
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@ -174,7 +172,6 @@ export default {
|
||||
.$post('/api/playlists', newPlaylist)
|
||||
.then((data) => {
|
||||
console.log('New playlist created', data)
|
||||
this.$toast.success(this.$strings.ToastPlaylistCreateSuccess + ': ' + data.name)
|
||||
this.processing = false
|
||||
this.newPlaylistName = ''
|
||||
})
|
||||
|
@ -170,6 +170,12 @@ export default {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
libraryItemUpdated(libraryItem) {
|
||||
const episode = libraryItem.media.episodes.find((e) => e.id === this.selectedEpisodeId)
|
||||
if (episode) {
|
||||
this.episodeItem = episode
|
||||
}
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
|
||||
this.goNextEpisode()
|
||||
@ -178,9 +184,15 @@ export default {
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
if (this.libraryItem) {
|
||||
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
}
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
},
|
||||
unregisterListeners() {
|
||||
if (this.libraryItem) {
|
||||
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
}
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
}
|
||||
},
|
||||
|
@ -163,13 +163,10 @@ export default {
|
||||
|
||||
this.isProcessing = false
|
||||
if (updateResult) {
|
||||
if (updateResult) {
|
||||
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div id="heatmap" class="w-full">
|
||||
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
|
||||
<p class="mb-2 px-1 text-sm text-gray-200">{{ $getString('MessageListeningSessionsInTheLastYear', [Object.values(daysListening).length]) }}</p>
|
||||
<p class="mb-2 px-1 text-sm text-gray-200">{{ $getString('MessageDaysListenedInTheLastYear', [daysListenedInTheLastYear]) }}</p>
|
||||
<div class="border border-white border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
|
||||
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||
@ -37,6 +37,7 @@ export default {
|
||||
innerHeight: 13 * 7,
|
||||
blockWidth: 13,
|
||||
data: [],
|
||||
daysListenedInTheLastYear: 0,
|
||||
monthLabels: [],
|
||||
tooltipEl: null,
|
||||
tooltipTextEl: null,
|
||||
@ -193,46 +194,47 @@ export default {
|
||||
buildData() {
|
||||
this.data = []
|
||||
|
||||
var maxValue = 0
|
||||
var minValue = 0
|
||||
Object.values(this.daysListening).forEach((val) => {
|
||||
if (val > maxValue) maxValue = val
|
||||
if (!minValue || val < minValue) minValue = val
|
||||
})
|
||||
const range = maxValue - minValue + 0.01
|
||||
let maxValue = 0
|
||||
let minValue = 0
|
||||
|
||||
const dates = []
|
||||
for (let i = 0; i < this.daysToShow + 1; i++) {
|
||||
const col = Math.floor(i / 7)
|
||||
const row = i % 7
|
||||
|
||||
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
|
||||
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
||||
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
|
||||
const monthString = this.$formatJsDate(date, 'MMM')
|
||||
const value = this.daysListening[dateString] || 0
|
||||
const x = col * 13
|
||||
const y = row * 13
|
||||
const dateObj = {
|
||||
col: Math.floor(i / 7),
|
||||
row: i % 7,
|
||||
date,
|
||||
dateString,
|
||||
datePretty: this.$formatJsDate(date, 'MMM d, yyyy'),
|
||||
monthString: this.$formatJsDate(date, 'MMM'),
|
||||
dayOfMonth: Number(dateString.split('-').pop()),
|
||||
yearString: dateString.split('-').shift(),
|
||||
value: this.daysListening[dateString] || 0
|
||||
}
|
||||
dates.push(dateObj)
|
||||
|
||||
var bgColor = this.bgColors[0]
|
||||
var outlineColor = this.outlineColors[0]
|
||||
if (value) {
|
||||
if (dateObj.value > 0) {
|
||||
this.daysListenedInTheLastYear++
|
||||
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]
|
||||
var percentOfAvg = (value - minValue) / range
|
||||
var bgIndex = Math.floor(percentOfAvg * 4) + 1
|
||||
const percentOfAvg = (dateObj.value - minValue) / range
|
||||
const bgIndex = Math.floor(percentOfAvg * 4) + 1
|
||||
bgColor = this.bgColors[bgIndex] || 'red'
|
||||
}
|
||||
|
||||
this.data.push({
|
||||
date,
|
||||
dateString,
|
||||
datePretty,
|
||||
monthString,
|
||||
dayOfMonth: Number(dateString.split('-').pop()),
|
||||
yearString: dateString.split('-').shift(),
|
||||
value,
|
||||
col,
|
||||
row,
|
||||
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||
...dateObj,
|
||||
style: `transform:translate(${dateObj.col * 13}px,${dateObj.row * 13}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||
})
|
||||
}
|
||||
|
||||
@ -260,6 +262,7 @@ export default {
|
||||
const heatmapEl = document.getElementById('heatmap')
|
||||
this.contentWidth = heatmapEl.clientWidth
|
||||
this.maxInnerWidth = this.contentWidth - 52
|
||||
this.daysListenedInTheLastYear = 0
|
||||
this.buildData()
|
||||
}
|
||||
},
|
||||
|
@ -218,7 +218,6 @@ export default {
|
||||
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
|
||||
} else {
|
||||
console.log(`Item removed from playlist`, updatedPlaylist)
|
||||
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.5",
|
||||
"version": "2.17.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.5",
|
||||
"version": "2.17.7",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.5",
|
||||
"version": "2.17.7",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
@ -12,6 +12,10 @@
|
||||
<div class="w-full pt-16">
|
||||
<player-ui ref="audioPlayer" :chapters="chapters" :current-chapter="currentChapter" :paused="isPaused" :loading="!hasLoaded" :is-podcast="false" hide-bookmarks hide-sleep-timer @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" />
|
||||
</div>
|
||||
|
||||
<ui-tooltip v-if="mediaItemShare.isDownloadable" direction="bottom" :text="$strings.LabelDownload" class="absolute top-0 left-0 m-4">
|
||||
<button aria-label="Download" class="text-gray-300 hover:text-white" @click="downloadShareItem"><span class="material-symbols text-2xl sm:text-3xl">download</span></button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -63,6 +67,9 @@ export default {
|
||||
if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
|
||||
return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover`
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/public/share/${this.mediaItemShare.slug}/download`
|
||||
},
|
||||
audioTracks() {
|
||||
return (this.playbackSession.audioTracks || []).map((track) => {
|
||||
track.relativeContentUrl = track.contentUrl
|
||||
@ -103,6 +110,84 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mediaSessionPlay() {
|
||||
console.log('Media session play')
|
||||
this.play()
|
||||
},
|
||||
mediaSessionPause() {
|
||||
console.log('Media session pause')
|
||||
this.pause()
|
||||
},
|
||||
mediaSessionStop() {
|
||||
console.log('Media session stop')
|
||||
this.pause()
|
||||
},
|
||||
mediaSessionSeekBackward() {
|
||||
console.log('Media session seek backward')
|
||||
this.jumpBackward()
|
||||
},
|
||||
mediaSessionSeekForward() {
|
||||
console.log('Media session seek forward')
|
||||
this.jumpForward()
|
||||
},
|
||||
mediaSessionSeekTo(e) {
|
||||
console.log('Media session seek to', e)
|
||||
if (e.seekTime !== null && !isNaN(e.seekTime)) {
|
||||
this.seek(e.seekTime)
|
||||
}
|
||||
},
|
||||
mediaSessionPreviousTrack() {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.prevChapter()
|
||||
}
|
||||
},
|
||||
mediaSessionNextTrack() {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.nextChapter()
|
||||
}
|
||||
},
|
||||
updateMediaSessionPlaybackState() {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
|
||||
}
|
||||
},
|
||||
setMediaSession() {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
|
||||
if ('mediaSession' in navigator) {
|
||||
const chapterInfo = []
|
||||
if (this.chapters.length > 0) {
|
||||
this.chapters.forEach((chapter) => {
|
||||
chapterInfo.push({
|
||||
title: chapter.title,
|
||||
startTime: chapter.start
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: this.mediaItemShare.playbackSession.displayTitle || 'No title',
|
||||
artist: this.mediaItemShare.playbackSession.displayAuthor || 'Unknown',
|
||||
artwork: [
|
||||
{
|
||||
src: this.coverUrl
|
||||
}
|
||||
],
|
||||
chapterInfo
|
||||
})
|
||||
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
|
||||
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
|
||||
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
|
||||
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
|
||||
} else {
|
||||
console.warn('Media session not available')
|
||||
}
|
||||
},
|
||||
async coverImageLoaded(e) {
|
||||
if (!this.playbackSession.coverPath) return
|
||||
const fac = new FastAverageColor()
|
||||
@ -119,8 +204,19 @@ export default {
|
||||
})
|
||||
},
|
||||
playPause() {
|
||||
if (this.isPlaying) {
|
||||
this.pause()
|
||||
} else {
|
||||
this.play()
|
||||
}
|
||||
},
|
||||
play() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
this.localAudioPlayer.playPause()
|
||||
this.localAudioPlayer.play()
|
||||
},
|
||||
pause() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
this.localAudioPlayer.pause()
|
||||
},
|
||||
jumpForward() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
@ -206,6 +302,7 @@ export default {
|
||||
} else {
|
||||
this.stopPlayInterval()
|
||||
}
|
||||
this.updateMediaSessionPlaybackState()
|
||||
},
|
||||
playerTimeUpdate(time) {
|
||||
this.setCurrentTime(time)
|
||||
@ -247,6 +344,9 @@ export default {
|
||||
},
|
||||
playerFinished() {
|
||||
console.log('Player finished')
|
||||
},
|
||||
downloadShareItem() {
|
||||
this.$downloadFile(this.downloadUrl)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@ -266,6 +366,8 @@ export default {
|
||||
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
|
||||
this.localAudioPlayer.on('error', this.playerError.bind(this))
|
||||
this.localAudioPlayer.on('finished', this.playerFinished.bind(this))
|
||||
|
||||
this.setMediaSession()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
|
@ -629,7 +629,6 @@
|
||||
"MessageItemsSelected": "{0} избрани",
|
||||
"MessageItemsUpdated": "{0} елемента обновени",
|
||||
"MessageJoinUsOn": "Присъединете се към нас",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} слушателски сесии през последната година",
|
||||
"MessageLoading": "Зареждане...",
|
||||
"MessageLoadingFolders": "Зареждане на Папки...",
|
||||
"MessageM4BFailed": "M4B Провалено!",
|
||||
@ -729,7 +728,6 @@
|
||||
"ToastBookmarkUpdateSuccess": "Отметката е обновена",
|
||||
"ToastChaptersHaveErrors": "Главите имат грешки",
|
||||
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
||||
"ToastCollectionItemsRemoveSuccess": "Елемент(и) премахнати от колекция",
|
||||
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
||||
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
|
||||
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
|
||||
|
@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "ট্র্যাকলিস্ট সংরক্ষণ করুন",
|
||||
"ButtonScan": "স্ক্যান",
|
||||
"ButtonScanLibrary": "স্ক্যান লাইব্রেরি",
|
||||
"ButtonScrollLeft": "বাম দিকে স্ক্রল করুন",
|
||||
"ButtonScrollRight": "ডানদিকে স্ক্রল করুন",
|
||||
"ButtonSearch": "অনুসন্ধান",
|
||||
"ButtonSelectFolderPath": "ফোল্ডারের পথ নির্বাচন করুন",
|
||||
"ButtonSeries": "সিরিজ",
|
||||
@ -190,6 +192,7 @@
|
||||
"HeaderSettingsExperimental": "পরীক্ষামূলক ফিচার",
|
||||
"HeaderSettingsGeneral": "সাধারণ",
|
||||
"HeaderSettingsScanner": "স্ক্যানার",
|
||||
"HeaderSettingsWebClient": "ওয়েব ক্লায়েন্ট",
|
||||
"HeaderSleepTimer": "স্লিপ টাইমার",
|
||||
"HeaderStatsLargestItems": "সবচেয়ে বড় আইটেম",
|
||||
"HeaderStatsLongestItems": "দীর্ঘতম আইটেম (ঘন্টা)",
|
||||
@ -297,6 +300,7 @@
|
||||
"LabelDiscover": "আবিষ্কার",
|
||||
"LabelDownload": "ডাউনলোড করুন",
|
||||
"LabelDownloadNEpisodes": "{0}টি পর্ব ডাউনলোড করুন",
|
||||
"LabelDownloadable": "ডাউনলোডযোগ্য",
|
||||
"LabelDuration": "সময়কাল",
|
||||
"LabelDurationComparisonExactMatch": "(সঠিক মিল)",
|
||||
"LabelDurationComparisonLonger": "({0} দীর্ঘ)",
|
||||
@ -542,6 +546,7 @@
|
||||
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
|
||||
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
|
||||
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
|
||||
"LabelSettingsAllowIframe": "আইফ্রেমে এম্বেড করার অনুমতি দিন",
|
||||
"LabelSettingsAudiobooksOnly": "শুধুমাত্র অডিও বই",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "এই সেটিংটি সক্ষম করা ই-বই ফাইলগুলিকে উপেক্ষা করবে যদি না সেগুলি একটি অডিওবই ফোল্ডারের মধ্যে থাকে যে ক্ষেত্রে সেগুলিকে সম্পূরক ই-বই হিসাবে সেট করা হবে",
|
||||
"LabelSettingsBookshelfViewHelp": "কাঠের তাক সহ স্কুমরফিক ডিজাইন",
|
||||
@ -584,6 +589,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "ডিফল্টরূপে মেটাডেটা ফাইলগুলি /মেটাডাটা/আইটেমগুলি -এ সংরক্ষণ করা হয়, এই সেটিংটি সক্ষম করলে মেটাডেটা ফাইলগুলি আপনার লাইব্রেরি আইটেম ফোল্ডারে সংরক্ষণ করা হবে",
|
||||
"LabelSettingsTimeFormat": "সময় বিন্যাস",
|
||||
"LabelShare": "শেয়ার করুন",
|
||||
"LabelShareDownloadableHelp": "শেয়ার লিঙ্ক সহ ব্যবহারকারীদের লাইব্রেরি আইটেমের একটি জিপ ফাইল ডাউনলোড করার অনুমতি দিন।",
|
||||
"LabelShareOpen": "শেয়ার খোলা",
|
||||
"LabelShareURL": "শেয়ার ইউআরএল",
|
||||
"LabelShowAll": "সব দেখান",
|
||||
@ -592,6 +598,8 @@
|
||||
"LabelSize": "আকার",
|
||||
"LabelSleepTimer": "স্লিপ টাইমার",
|
||||
"LabelSlug": "স্লাগ",
|
||||
"LabelSortAscending": "আরোহী",
|
||||
"LabelSortDescending": "অবরোহী",
|
||||
"LabelStart": "শুরু",
|
||||
"LabelStartTime": "শুরুর সময়",
|
||||
"LabelStarted": "শুরু হয়েছে",
|
||||
@ -679,6 +687,8 @@
|
||||
"LabelViewPlayerSettings": "প্লেয়ার সেটিংস দেখুন",
|
||||
"LabelViewQueue": "প্লেয়ার সারি দেখুন",
|
||||
"LabelVolume": "ভলিউম",
|
||||
"LabelWebRedirectURLsDescription": "লগইন করার পরে ওয়েব অ্যাপে পুনঃনির্দেশের অনুমতি দেওয়ার জন্য আপনার OAuth প্রদানকারীতে এই URLগুলোকে অনুমোদন করুন:",
|
||||
"LabelWebRedirectURLsSubfolder": "রিডাইরেক্ট URL এর জন্য সাবফোল্ডার",
|
||||
"LabelWeekdaysToRun": "চলতে হবে সপ্তাহের দিন",
|
||||
"LabelXBooks": "{0}টি বই",
|
||||
"LabelXItems": "{0}টি আইটেম",
|
||||
@ -763,7 +773,6 @@
|
||||
"MessageItemsSelected": "{0}টি আইটেম নির্বাচিত",
|
||||
"MessageItemsUpdated": "{0}টি আইটেম আপডেট করা হয়েছে",
|
||||
"MessageJoinUsOn": "আমাদের সাথে যোগ দিন",
|
||||
"MessageListeningSessionsInTheLastYear": "গত বছরে {0}টি শোনার সেশন",
|
||||
"MessageLoading": "লোড হচ্ছে.।",
|
||||
"MessageLoadingFolders": "ফোল্ডার লোড হচ্ছে...",
|
||||
"MessageLogsDescription": "লগগুলি JSON ফাইল হিসাবে <code>/metadata/logs</code>-এ সংরক্ষণ করা হয়। ক্র্যাশ লগগুলি <code>/metadata/logs/crash_logs.txt</code>-এ সংরক্ষণ করা হয়।",
|
||||
@ -951,8 +960,6 @@
|
||||
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
|
||||
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
|
||||
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
|
||||
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
|
||||
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
|
||||
"ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে",
|
||||
"ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে",
|
||||
"ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে",
|
||||
|
@ -904,8 +904,6 @@
|
||||
"ToastChaptersRemoved": "Capítols eliminats",
|
||||
"ToastChaptersUpdated": "Capítols actualitzats",
|
||||
"ToastCollectionItemsAddFailed": "Error en afegir elements a la col·lecció",
|
||||
"ToastCollectionItemsAddSuccess": "Elements afegits a la col·lecció",
|
||||
"ToastCollectionItemsRemoveSuccess": "Elements eliminats de la col·lecció",
|
||||
"ToastCollectionRemoveSuccess": "Col·lecció eliminada",
|
||||
"ToastCollectionUpdateSuccess": "Col·lecció actualitzada",
|
||||
"ToastCoverUpdateFailed": "Error en actualitzar la portada",
|
||||
|
@ -234,7 +234,7 @@
|
||||
"LabelAppend": "Připojit",
|
||||
"LabelAudioBitrate": "Bitový tok zvuku (např. 128k)",
|
||||
"LabelAudioChannels": "Zvukové kanály (1 nebo 2)",
|
||||
"LabelAudioCodec": "Kodek audia",
|
||||
"LabelAudioCodec": "Audio Kodek",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Autor (jméno a příjmení)",
|
||||
"LabelAuthorLastFirst": "Autor (příjmení a jméno)",
|
||||
@ -420,6 +420,7 @@
|
||||
"LabelMatchExistingUsersBy": "Přiřadit stávající uživatele podle",
|
||||
"LabelMatchExistingUsersByDescription": "Slouží k propojení stávajících uživatelů. Po propojení budou uživatelé přiřazeni k jedinečnému ID od poskytovatele SSO.",
|
||||
"LabelMaxEpisodesToDownload": "Maximální # epizod pro stažení. Použijte 0 pro bez omezení.",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Maximální počet nových epizod ke stažení při jedné kontrole",
|
||||
"LabelMaxEpisodesToKeep": "Maximální počet epizod k zachování",
|
||||
"LabelMaxEpisodesToKeepHelp": "Hodnotou 0 není nastaven žádný maximální limit. Po automatickém stažení nové epizody se odstraní nejstarší epizoda, pokud máte více než X epizod. Při každém novém stažení se odstraní pouze 1 epizoda.",
|
||||
"LabelMediaPlayer": "Přehrávač médií",
|
||||
@ -735,6 +736,7 @@
|
||||
"MessageConfirmPurgeCache": "Vyčistit mezipaměť odstraní celý adresář na adrese <code>/metadata/cache</code>. <br /><br />Určitě chcete odstranit adresář mezipaměti?",
|
||||
"MessageConfirmPurgeItemsCache": "Vyčištění mezipaměti položek odstraní celý adresář <code>/metadata/cache/items</code>.<br />Jste si jistí?",
|
||||
"MessageConfirmQuickEmbed": "Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů. <br><br>Chcete pokračovat?",
|
||||
"MessageConfirmQuickMatchEpisodes": "Pokud je nalezena shoda při rychlém párování epizod, dojde k přepsání podrobností. Aktualizovány budou pouze nespárované epizody. Jste si jisti?",
|
||||
"MessageConfirmReScanLibraryItems": "Opravdu chcete znovu prohledat {0} položky?",
|
||||
"MessageConfirmRemoveAllChapters": "Opravdu chcete odstranit všechny kapitoly?",
|
||||
"MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?",
|
||||
@ -742,6 +744,7 @@
|
||||
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
|
||||
"MessageConfirmRemoveListeningSessions": "Opravdu chcete odebrat {0} poslechových relací?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Jste si jisti, že chcete odstranit všechny metadata.{0} soubory ve složkách s položkami ve vaší knihovně?",
|
||||
"MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?",
|
||||
@ -757,6 +760,7 @@
|
||||
"MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop",
|
||||
"MessageEmbedFailed": "Vložení selhalo!",
|
||||
"MessageEmbedFinished": "Vložení dokončeno!",
|
||||
"MessageEmbedQueue": "Zařazeno do fronty pro vložení metadat ({0} ve frontě)",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení",
|
||||
"MessageEreaderDevices": "Aby bylo zajištěno doručení elektronických knih, může být nutné přidat výše uvedenou e-mailovou adresu jako platného odesílatele pro každé zařízení uvedené níže.",
|
||||
"MessageFeedURLWillBe": "URL zdroje bude {0}",
|
||||
@ -767,7 +771,6 @@
|
||||
"MessageItemsSelected": "{0} vybraných položek",
|
||||
"MessageItemsUpdated": "{0} položky byly aktualizovány",
|
||||
"MessageJoinUsOn": "Přidejte se k nám",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} poslechových relací za poslední rok",
|
||||
"MessageLoading": "Načítá se...",
|
||||
"MessageLoadingFolders": "Načítám složky...",
|
||||
"MessageLogsDescription": "Protokoly se ukládají do souborů JSON v <code>/metadata/logs</code>. Protokoly o pádech jsou uloženy v <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -801,6 +804,7 @@
|
||||
"MessageNoLogs": "Žádné protokoly",
|
||||
"MessageNoMediaProgress": "Žádný průběh médií",
|
||||
"MessageNoNotifications": "Žádná oznámení",
|
||||
"MessageNoPodcastFeed": "Neplatný podcast: Žádný kanál",
|
||||
"MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty",
|
||||
"MessageNoResults": "Žádné výsledky",
|
||||
"MessageNoSearchResultsFor": "Nebyly nalezeny žádné výsledky hledání pro \"{0}\"",
|
||||
@ -817,7 +821,10 @@
|
||||
"MessagePlaylistCreateFromCollection": "Vytvořit seznam skladeb z kolekce",
|
||||
"MessagePleaseWait": "Čekejte prosím...",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nemá žádnou adresu URL kanálu RSS, kterou by mohl použít pro porovnávání",
|
||||
"MessageQuickMatchDescription": "Vyplňte prázdné detaily položky a obálku prvním výsledkem shody z '{0}'. Nepřepisuje podrobnosti, pokud není povoleno nastavení serveru \"Preferovat párování metadata\".",
|
||||
"MessageQuickEmbedInProgress": "Probíhá rychlé vkládání",
|
||||
"MessageQuickEmbedQueue": "Zařazeno do fronty pro rychlé vložení ({0} ve frontě)",
|
||||
"MessageQuickMatchAllEpisodes": "Rychlá shoda všech epizod",
|
||||
"MessageQuickMatchDescription": "Vyplnit prázdné detaily položky a obálky prvním výsledkem shody z '{0}'. Nepřepisuje detaily, pokud není povoleno nastavení serveru 'Preferovat shodná metadata'.",
|
||||
"MessageRemoveChapter": "Odstranit kapitolu",
|
||||
"MessageRemoveEpisodes": "Odstranit {0} epizodu",
|
||||
"MessageRemoveFromPlayerQueue": "Odstranit z fronty přehrávače",
|
||||
@ -848,10 +855,13 @@
|
||||
"MessageTaskFailedToMergeAudioFiles": "Spojení audio souborů selhalo",
|
||||
"MessageTaskFailedToMoveM4bFile": "Přesunutí m4b souboru selhalo",
|
||||
"MessageTaskFailedToWriteMetadataFile": "Zápis souboru metadat selhal",
|
||||
"MessageTaskMatchingBooksInLibrary": "Párování knih v knihovně „{0}“",
|
||||
"MessageTaskNoFilesToScan": "Žádné soubory ke skenování",
|
||||
"MessageTaskOpmlImport": "Import OPML",
|
||||
"MessageTaskOpmlImportDescription": "Vytváření podcastů z {0} RSS feedů",
|
||||
"MessageTaskOpmlImportFeed": "Importní zdroj OPML",
|
||||
"MessageTaskOpmlImportFeedDescription": "Importování RSS feedu \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedFailed": "Nepodařilo se získat kanál podcastu",
|
||||
"MessageTaskOpmlImportFeedPodcastDescription": "Vytváření podcastu \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje",
|
||||
"MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo",
|
||||
@ -932,7 +942,6 @@
|
||||
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
|
||||
"ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy",
|
||||
"ToastChaptersRemoved": "Kapitoly odstraněny",
|
||||
"ToastCollectionItemsRemoveSuccess": "Položky odstraněny z kolekce",
|
||||
"ToastCollectionRemoveSuccess": "Kolekce odstraněna",
|
||||
"ToastCollectionUpdateSuccess": "Kolekce aktualizována",
|
||||
"ToastCoverUpdateFailed": "Aktualizace obálky selhala",
|
||||
|
@ -19,6 +19,7 @@
|
||||
"ButtonChooseFiles": "Vælg filer",
|
||||
"ButtonClearFilter": "Ryd filter",
|
||||
"ButtonCloseFeed": "Luk feed",
|
||||
"ButtonCloseSession": "Luk Åben Session",
|
||||
"ButtonCollections": "Samlinger",
|
||||
"ButtonConfigureScanner": "Konfigurer scanner",
|
||||
"ButtonCreate": "Opret",
|
||||
@ -29,7 +30,9 @@
|
||||
"ButtonEditChapters": "Rediger kapitler",
|
||||
"ButtonEditPodcast": "Rediger podcast",
|
||||
"ButtonEnable": "Aktiver",
|
||||
"ButtonForceReScan": "Tvungen genindlæsning",
|
||||
"ButtonFireAndFail": "Affyring Og Fejl",
|
||||
"ButtonFireOnTest": "Affyring vedTest begivenhed",
|
||||
"ButtonForceReScan": "Tving genindlæsning",
|
||||
"ButtonFullPath": "Fuld sti",
|
||||
"ButtonHide": "Skjul",
|
||||
"ButtonHome": "Hjem",
|
||||
@ -536,7 +539,6 @@
|
||||
"MessageItemsSelected": "{0} elementer valgt",
|
||||
"MessageItemsUpdated": "{0} elementer opdateret",
|
||||
"MessageJoinUsOn": "Deltag i os på",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} lyttesessioner i det sidste år",
|
||||
"MessageLoading": "Indlæser...",
|
||||
"MessageLoadingFolders": "Indlæser mapper...",
|
||||
"MessageM4BFailed": "M4B mislykkedes!",
|
||||
@ -637,7 +639,6 @@
|
||||
"ToastBookmarkUpdateSuccess": "Bogmærke opdateret",
|
||||
"ToastChaptersHaveErrors": "Kapitler har fejl",
|
||||
"ToastChaptersMustHaveTitles": "Kapitler skal have titler",
|
||||
"ToastCollectionItemsRemoveSuccess": "Element(er) fjernet fra samlingen",
|
||||
"ToastCollectionRemoveSuccess": "Samling fjernet",
|
||||
"ToastCollectionUpdateSuccess": "Samling opdateret",
|
||||
"ToastItemCoverUpdateSuccess": "Varens omslag opdateret",
|
||||
|
@ -771,7 +771,6 @@
|
||||
"MessageItemsSelected": "{0} ausgewählte Medien",
|
||||
"MessageItemsUpdated": "{0} Medien aktualisiert",
|
||||
"MessageJoinUsOn": "Besuche uns auf",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr",
|
||||
"MessageLoading": "Wird geladen …",
|
||||
"MessageLoadingFolders": "Lade Ordner...",
|
||||
"MessageLogsDescription": "Die Logs werdern in <code>/metadata/logs</code> als JSON Dateien gespeichert. Crash logs werden in <code>/metadata/logs/crash_logs.txt</code> gespeichert.",
|
||||
@ -959,8 +958,6 @@
|
||||
"ToastChaptersRemoved": "Kapitel entfernt",
|
||||
"ToastChaptersUpdated": "Kapitel aktualisiert",
|
||||
"ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen",
|
||||
"ToastCollectionItemsAddSuccess": "Element(e) erfolgreich zur Sammlung hinzugefügt",
|
||||
"ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
|
||||
"ToastCollectionRemoveSuccess": "Sammlung entfernt",
|
||||
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
|
||||
"ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen",
|
||||
|
@ -300,6 +300,7 @@
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDownloadable": "Downloadable",
|
||||
"LabelDuration": "Duration",
|
||||
"LabelDurationComparisonExactMatch": "(exact match)",
|
||||
"LabelDurationComparisonLonger": "({0} longer)",
|
||||
@ -588,6 +589,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShare": "Share",
|
||||
"LabelShareDownloadableHelp": "Allows users with the share link to download a zip file of the library item.",
|
||||
"LabelShareOpen": "Share Open",
|
||||
"LabelShareURL": "Share URL",
|
||||
"LabelShowAll": "Show All",
|
||||
@ -756,6 +758,7 @@
|
||||
"MessageConfirmResetProgress": "Are you sure you want to reset your progress?",
|
||||
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Are you sure you want to unlink this user from OpenID?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} days listened in the last year",
|
||||
"MessageDownloadingEpisode": "Downloading episode",
|
||||
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
|
||||
"MessageEmbedFailed": "Embed Failed!",
|
||||
@ -771,7 +774,6 @@
|
||||
"MessageItemsSelected": "{0} Items Selected",
|
||||
"MessageItemsUpdated": "{0} Items Updated",
|
||||
"MessageJoinUsOn": "Join us on",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
|
||||
"MessageLoading": "Loading...",
|
||||
"MessageLoadingFolders": "Loading folders...",
|
||||
"MessageLogsDescription": "Logs are stored in <code>/metadata/logs</code> as JSON files. Crash logs are stored in <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -959,8 +961,6 @@
|
||||
"ToastChaptersRemoved": "Chapters removed",
|
||||
"ToastChaptersUpdated": "Chapters updated",
|
||||
"ToastCollectionItemsAddFailed": "Item(s) added to collection failed",
|
||||
"ToastCollectionItemsAddSuccess": "Item(s) added to collection success",
|
||||
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
|
||||
"ToastCollectionRemoveSuccess": "Collection removed",
|
||||
"ToastCollectionUpdateSuccess": "Collection updated",
|
||||
"ToastCoverUpdateFailed": "Cover update failed",
|
||||
|
@ -771,7 +771,6 @@
|
||||
"MessageItemsSelected": "{0} Elementos Seleccionados",
|
||||
"MessageItemsUpdated": "{0} Elementos Actualizados",
|
||||
"MessageJoinUsOn": "Únetenos en",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} sesiones de escucha en el último año",
|
||||
"MessageLoading": "Cargando...",
|
||||
"MessageLoadingFolders": "Cargando archivos...",
|
||||
"MessageLogsDescription": "Logs son almacenados en <code>/metadata/logs</code> en archivos bajo formato JSON. Logs de fallos son almacenados en <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -959,8 +958,6 @@
|
||||
"ToastChaptersRemoved": "Capítulos eliminados",
|
||||
"ToastChaptersUpdated": "Capítulos actualizados",
|
||||
"ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)",
|
||||
"ToastCollectionItemsAddSuccess": "Artículo(s) añadido(s) a la colección correctamente",
|
||||
"ToastCollectionItemsRemoveSuccess": "Elementos(s) removidos de la colección",
|
||||
"ToastCollectionRemoveSuccess": "Colección removida",
|
||||
"ToastCollectionUpdateSuccess": "Colección actualizada",
|
||||
"ToastCoverUpdateFailed": "Error al actualizar la cubierta",
|
||||
|
@ -611,7 +611,6 @@
|
||||
"MessageItemsSelected": "{0} Valitud üksust",
|
||||
"MessageItemsUpdated": "{0} Üksust on uuendatud",
|
||||
"MessageJoinUsOn": "Liitu meiega",
|
||||
"MessageListeningSessionsInTheLastYear": "Kuulamissessioone viimase aasta jooksul: {0}",
|
||||
"MessageLoading": "Laadimine...",
|
||||
"MessageLoadingFolders": "Kaustade laadimine...",
|
||||
"MessageM4BFailed": "M4B ebaõnnestus!",
|
||||
@ -713,7 +712,6 @@
|
||||
"ToastBookmarkUpdateSuccess": "Järjehoidja värskendatud",
|
||||
"ToastChaptersHaveErrors": "Peatükkidel on vigu",
|
||||
"ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri",
|
||||
"ToastCollectionItemsRemoveSuccess": "Üksus(ed) eemaldatud kogumist",
|
||||
"ToastCollectionRemoveSuccess": "Kogum eemaldatud",
|
||||
"ToastCollectionUpdateSuccess": "Kogum värskendatud",
|
||||
"ToastItemCoverUpdateSuccess": "Üksuse kaas värskendatud",
|
||||
|
@ -765,7 +765,6 @@
|
||||
"MessageItemsSelected": "{0} éléments sélectionnés",
|
||||
"MessageItemsUpdated": "{0} éléments mis à jour",
|
||||
"MessageJoinUsOn": "Rejoignez-nous sur",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} sessions d’écoute l’an dernier",
|
||||
"MessageLoading": "Chargement…",
|
||||
"MessageLoadingFolders": "Chargement des dossiers…",
|
||||
"MessageLogsDescription": "Les journaux sont stockés dans <code>/metadata/logs</code> sous forme de fichiers JSON. Les journaux d’incidents sont stockés dans <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -953,8 +952,6 @@
|
||||
"ToastChaptersRemoved": "Chapitres supprimés",
|
||||
"ToastChaptersUpdated": "Chapitres mis à jour",
|
||||
"ToastCollectionItemsAddFailed": "Échec de l’ajout de(s) élément(s) à la collection",
|
||||
"ToastCollectionItemsAddSuccess": "Ajout de(s) élément(s) à la collection réussi",
|
||||
"ToastCollectionItemsRemoveSuccess": "Élément(s) supprimé(s) de la collection",
|
||||
"ToastCollectionRemoveSuccess": "Collection supprimée",
|
||||
"ToastCollectionUpdateSuccess": "Collection mise à jour",
|
||||
"ToastCoverUpdateFailed": "Échec de la mise à jour de la couverture",
|
||||
|
@ -642,7 +642,6 @@
|
||||
"MessageItemsSelected": "{0} פריטים נבחרו",
|
||||
"MessageItemsUpdated": "{0} פריטים עודכנו",
|
||||
"MessageJoinUsOn": "הצטרף אלינו ב-",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} מפגשי האזנה בשנה האחרונה",
|
||||
"MessageLoading": "טוען...",
|
||||
"MessageLoadingFolders": "טוען תיקיות...",
|
||||
"MessageM4BFailed": "M4B נכשל!",
|
||||
@ -744,7 +743,6 @@
|
||||
"ToastBookmarkUpdateSuccess": "הסימניה עודכנה בהצלחה",
|
||||
"ToastChaptersHaveErrors": "פרקים מכילים שגיאות",
|
||||
"ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות",
|
||||
"ToastCollectionItemsRemoveSuccess": "הפריט(ים) הוסרו מהאוסף בהצלחה",
|
||||
"ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה",
|
||||
"ToastCollectionUpdateSuccess": "האוסף עודכן בהצלחה",
|
||||
"ToastItemCoverUpdateSuccess": "כריכת הפריט עודכנה בהצלחה",
|
||||
|
@ -771,7 +771,6 @@
|
||||
"MessageItemsSelected": "{0} odabranih stavki",
|
||||
"MessageItemsUpdated": "{0} stavki ažurirano",
|
||||
"MessageJoinUsOn": "Pridruži nam se na",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} slušanja u prošloj godini",
|
||||
"MessageLoading": "Učitavam...",
|
||||
"MessageLoadingFolders": "Učitavam mape...",
|
||||
"MessageLogsDescription": "Zapisnici se čuvaju u <code>/metadata/logs</code> u obliku JSON datoteka. Zapisnici pada sustava čuvaju se u datoteci <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -959,8 +958,6 @@
|
||||
"ToastChaptersRemoved": "Poglavlja uklonjena",
|
||||
"ToastChaptersUpdated": "Poglavlja su ažurirana",
|
||||
"ToastCollectionItemsAddFailed": "Neuspješno dodavanje stavki u zbirku",
|
||||
"ToastCollectionItemsAddSuccess": "Uspješno dodavanje stavki u zbirku",
|
||||
"ToastCollectionItemsRemoveSuccess": "Stavke izbrisane iz zbirke",
|
||||
"ToastCollectionRemoveSuccess": "Zbirka izbrisana",
|
||||
"ToastCollectionUpdateSuccess": "Zbirka ažurirana",
|
||||
"ToastCoverUpdateFailed": "Ažuriranje naslovnice nije uspjelo",
|
||||
|
@ -206,7 +206,7 @@
|
||||
"HeaderUpdateDetails": "Részletek frissítése",
|
||||
"HeaderUpdateLibrary": "Könyvtár frissítése",
|
||||
"HeaderUsers": "Felhasználók",
|
||||
"HeaderYearReview": "{0} év áttekintése",
|
||||
"HeaderYearReview": "{0} év visszatekintése",
|
||||
"HeaderYourStats": "Saját statisztikák",
|
||||
"LabelAbridged": "Tömörített",
|
||||
"LabelAbridgedChecked": "Rövidített (ellenőrizve)",
|
||||
@ -478,7 +478,7 @@
|
||||
"LabelPermissionsDownload": "Letölthet",
|
||||
"LabelPermissionsUpdate": "Frissíthet",
|
||||
"LabelPermissionsUpload": "Feltölthet",
|
||||
"LabelPersonalYearReview": "Az éved áttekintése ({0})",
|
||||
"LabelPersonalYearReview": "Az évvisszatekintésed ({0})",
|
||||
"LabelPhotoPathURL": "Fénykép útvonal/URL",
|
||||
"LabelPlayMethod": "Lejátszási módszer",
|
||||
"LabelPlayerChapterNumberMarker": "{0} a {1} -ből",
|
||||
@ -539,7 +539,7 @@
|
||||
"LabelSeriesName": "Sorozat neve",
|
||||
"LabelSeriesProgress": "Sorozat haladása",
|
||||
"LabelServerLogLevel": "Kiszolgáló naplózási szint",
|
||||
"LabelServerYearReview": "Szerver évértékelő ({0})",
|
||||
"LabelServerYearReview": "Szerver évvisszatekintés ({0})",
|
||||
"LabelSetEbookAsPrimary": "Beállítás elsődlegesként",
|
||||
"LabelSetEbookAsSupplementary": "Beállítás kiegészítőként",
|
||||
"LabelSettingsAllowIframe": "A beágyazás engedélyezése egy iframe-be",
|
||||
@ -684,8 +684,8 @@
|
||||
"LabelWeekdaysToRun": "Futás napjai",
|
||||
"LabelXBooks": "{0} könyv",
|
||||
"LabelXItems": "{0} elem",
|
||||
"LabelYearReviewHide": "Évértékelő elrejtése",
|
||||
"LabelYearReviewShow": "Évértékelés megtekintése",
|
||||
"LabelYearReviewHide": "Az évvisszatekintés elrejtése",
|
||||
"LabelYearReviewShow": "Évvisszatekintés megtekintése",
|
||||
"LabelYourAudiobookDuration": "Hangoskönyv időtartama",
|
||||
"LabelYourBookmarks": "Könyvjelzőid",
|
||||
"LabelYourPlaylists": "Lejátszási listáid",
|
||||
@ -764,7 +764,6 @@
|
||||
"MessageItemsSelected": "{0} kiválasztott elem",
|
||||
"MessageItemsUpdated": "{0} frissített elem",
|
||||
"MessageJoinUsOn": "Csatlakozzon hozzánk a",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} hallgatási munkamenet az elmúlt évben",
|
||||
"MessageLoading": "Betöltés...",
|
||||
"MessageLoadingFolders": "Mappák betöltése...",
|
||||
"MessageLogsDescription": "A naplók a <code>/metadata/logs</code> mappában JSON-fájlokként tárolódnak. Az összeomlási naplók a <code>/metadata/logs/crash_logs.txt</code> fájlban tárolódnak.",
|
||||
@ -910,7 +909,7 @@
|
||||
"StatsTopNarrator": "TOP ELŐADÓ",
|
||||
"StatsTopNarrators": "TOP ELŐADÓ",
|
||||
"StatsTotalDuration": "A teljes időtartam…",
|
||||
"StatsYearInReview": "ÉVÉRTÉKELÉS",
|
||||
"StatsYearInReview": "ÉVVISSZATEKINTÉS",
|
||||
"ToastAccountUpdateSuccess": "Fiók frissítve",
|
||||
"ToastAppriseUrlRequired": "Meg kell adnia egy Apprise URL-címet",
|
||||
"ToastAsinRequired": "ASIN kötelező",
|
||||
@ -945,7 +944,6 @@
|
||||
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
|
||||
"ToastChaptersRemoved": "Fejezetek eltávolítva",
|
||||
"ToastChaptersUpdated": "Fejezetek frissítve",
|
||||
"ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből",
|
||||
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
|
||||
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
|
||||
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
|
||||
|
@ -762,7 +762,6 @@
|
||||
"MessageItemsSelected": "{0} oggetti Selezionati",
|
||||
"MessageItemsUpdated": "{0} Oggetti aggiornati",
|
||||
"MessageJoinUsOn": "Unisciti a noi su",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} sessioni di ascolto nell'ultimo anno",
|
||||
"MessageLoading": "Caricamento…",
|
||||
"MessageLoadingFolders": "Caricamento Cartelle...",
|
||||
"MessageLogsDescription": "I log vengono archiviati nel percorso <code>/metadata/logs</code> as JSON files. I registri degli arresti anomali vengono archiviati nel percorso <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -950,8 +949,6 @@
|
||||
"ToastChaptersRemoved": "Capitoli rimossi",
|
||||
"ToastChaptersUpdated": "Capitoli aggiornati",
|
||||
"ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito",
|
||||
"ToastCollectionItemsAddSuccess": "L'aggiunta dell'elemento(i) alla raccolta è riuscito",
|
||||
"ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta",
|
||||
"ToastCollectionRemoveSuccess": "Collezione rimossa",
|
||||
"ToastCollectionUpdateSuccess": "Raccolta aggiornata",
|
||||
"ToastCoverUpdateFailed": "Aggiornamento cover fallito",
|
||||
|
@ -104,7 +104,7 @@
|
||||
"ButtonViewAll": "Peržiūrėti visus",
|
||||
"ButtonYes": "Taip",
|
||||
"ErrorUploadFetchMetadataAPI": "Klaida gaunant metaduomenis",
|
||||
"ErrorUploadFetchMetadataNoResults": "Nepavyko gauti metaduomenų - pabandykite atnaujinti pavadinimą ir/ar autorių.",
|
||||
"ErrorUploadFetchMetadataNoResults": "Nepavyko gauti metaduomenų - pabandykite atnaujinti pavadinimą ir/ar autorių",
|
||||
"ErrorUploadLacksTitle": "Pavadinimas yra privalomas",
|
||||
"HeaderAccount": "Paskyra",
|
||||
"HeaderAdvanced": "Papildomi",
|
||||
@ -419,7 +419,7 @@
|
||||
"LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.",
|
||||
"LabelSettingsFindCovers": "Rasti viršelius",
|
||||
"LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanke, bandyti rasti viršelį.<br>Pastaba: Tai padidins skenavimo trukmę.",
|
||||
"LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanke, bandyti rasti viršelį.<br>Pastaba: Tai padidins skenavimo trukmę",
|
||||
"LabelSettingsHideSingleBookSeries": "Slėpti serijas, turinčias tik vieną knygą",
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Serijos, turinčios tik vieną knygą, bus paslėptos nuo serijų puslapio ir pagrindinio puslapio lentynų.",
|
||||
"LabelSettingsHomePageBookshelfView": "Naudoti pagrindinio puslapio knygų lentynų vaizdą",
|
||||
@ -563,7 +563,6 @@
|
||||
"MessageItemsSelected": "Pasirinkti {0} elementai (-ų)",
|
||||
"MessageItemsUpdated": "Atnaujinti {0} elementai (-ų)",
|
||||
"MessageJoinUsOn": "Prisijunkite prie mūsų",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} klausymo sesijų per paskutinius metus",
|
||||
"MessageLoading": "Kraunama...",
|
||||
"MessageLoadingFolders": "Kraunami aplankai...",
|
||||
"MessageM4BFailed": "M4B Nepavyko!",
|
||||
@ -666,8 +665,6 @@
|
||||
"ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus",
|
||||
"ToastChaptersRemoved": "Skyriai pašalinti",
|
||||
"ToastCollectionItemsAddFailed": "Nepavyko pridėti į kolekciją",
|
||||
"ToastCollectionItemsAddSuccess": "Pridėta į kolekciją",
|
||||
"ToastCollectionItemsRemoveSuccess": "Elementai pašalinti iš kolekcijos",
|
||||
"ToastCollectionRemoveSuccess": "Kolekcija pašalinta",
|
||||
"ToastCollectionUpdateSuccess": "Kolekcija atnaujinta",
|
||||
"ToastCoverUpdateFailed": "Viršelio atnaujinimas nepavyko",
|
||||
|
@ -758,7 +758,6 @@
|
||||
"MessageItemsSelected": "{0} onderdelen geselecteerd",
|
||||
"MessageItemsUpdated": "{0} onderdelen bijgewerkt",
|
||||
"MessageJoinUsOn": "Doe mee op",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} luistersessies in het laatste jaar",
|
||||
"MessageLoading": "Aan het laden...",
|
||||
"MessageLoadingFolders": "Mappen aan het laden...",
|
||||
"MessageLogsDescription": "Logs worden opgeslagen in <code>/metadata/logs</code> als JSON-bestanden. Crashlogs worden opgeslagen in <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -946,8 +945,6 @@
|
||||
"ToastChaptersRemoved": "Hoofdstukken verwijderd",
|
||||
"ToastChaptersUpdated": "Hoofdstukken bijgewerkt",
|
||||
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
|
||||
"ToastCollectionItemsAddSuccess": "Item(s) toegevoegd aan collectie gelukt",
|
||||
"ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie",
|
||||
"ToastCollectionRemoveSuccess": "Collectie verwijderd",
|
||||
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
|
||||
"ToastCoverUpdateFailed": "Cover update mislukt",
|
||||
|
@ -29,13 +29,16 @@
|
||||
"ButtonEdit": "Rediger",
|
||||
"ButtonEditChapters": "Rediger kapittel",
|
||||
"ButtonEditPodcast": "Rediger podcast",
|
||||
"ButtonEnable": "Aktiver",
|
||||
"ButtonFireAndFail": "Kjør ved feil",
|
||||
"ButtonFireOnTest": "Kjør onTest-kommando",
|
||||
"ButtonForceReScan": "Tving skann",
|
||||
"ButtonFullPath": "Full sti",
|
||||
"ButtonHide": "Gjøm",
|
||||
"ButtonHome": "Hjem",
|
||||
"ButtonIssues": "Problemer",
|
||||
"ButtonJumpBackward": "Hopp Bakover",
|
||||
"ButtonJumpForward": "Hopp Fremover",
|
||||
"ButtonJumpBackward": "Hopp bakover",
|
||||
"ButtonJumpForward": "Hopp frem",
|
||||
"ButtonLatest": "Siste",
|
||||
"ButtonLibrary": "Bibliotek",
|
||||
"ButtonLogout": "Logg ut",
|
||||
@ -45,24 +48,31 @@
|
||||
"ButtonMatchAllAuthors": "Søk opp alle forfattere",
|
||||
"ButtonMatchBooks": "Søk opp bøker",
|
||||
"ButtonNevermind": "Avbryt",
|
||||
"ButtonNext": "Neste",
|
||||
"ButtonNextChapter": "Neste Kapittel",
|
||||
"ButtonNextItemInQueue": "Neste element i køen",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonOpenFeed": "Åpne Feed",
|
||||
"ButtonOpenManager": "Åpne behandler",
|
||||
"ButtonPause": "Pause",
|
||||
"ButtonPlay": "Spill av",
|
||||
"ButtonPlayAll": "Spill av alle",
|
||||
"ButtonPlaying": "Spiller av",
|
||||
"ButtonPlaylists": "Spillelister",
|
||||
"ButtonPrevious": "Forrige",
|
||||
"ButtonPreviousChapter": "Forrige Kapittel",
|
||||
"ButtonProbeAudioFile": "Analyser lydfil",
|
||||
"ButtonPurgeAllCache": "Tøm alle mellomlager",
|
||||
"ButtonPurgeItemsCache": "Tøm mellomlager",
|
||||
"ButtonQueueAddItem": "Legg til kø",
|
||||
"ButtonQueueRemoveItem": "Fjern fra kø",
|
||||
"ButtonQuickEmbedMetadata": "Hurtig Innbygging Av Metadata",
|
||||
"ButtonQuickEmbed": "Hurtiginnbygging",
|
||||
"ButtonQuickEmbedMetadata": "Bygg inn metadata",
|
||||
"ButtonQuickMatch": "Kjapt søk",
|
||||
"ButtonReScan": "Skann på nytt",
|
||||
"ButtonRead": "Les",
|
||||
"ButtonReadLess": "Les Mindre",
|
||||
"ButtonReadMore": "Les Mer",
|
||||
"ButtonReadLess": "Vis mindre",
|
||||
"ButtonReadMore": "Vis mer",
|
||||
"ButtonRefresh": "Oppdater",
|
||||
"ButtonRemove": "Fjern",
|
||||
"ButtonRemoveAll": "Fjern alle",
|
||||
@ -71,12 +81,15 @@
|
||||
"ButtonRemoveFromContinueReading": "Fjern fra Fortsett å lese",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Fjern serie fra Fortsett serie",
|
||||
"ButtonReset": "Nullstill",
|
||||
"ButtonResetToDefault": "Tilbakestill til standard",
|
||||
"ButtonRestore": "Gjenopprett",
|
||||
"ButtonSave": "Lagre",
|
||||
"ButtonSaveAndClose": "Lagre og lukk",
|
||||
"ButtonSaveTracklist": "Lagre spilleliste",
|
||||
"ButtonScan": "Skann",
|
||||
"ButtonScanLibrary": "Skann bibliotek",
|
||||
"ButtonScrollLeft": "Rull til venstre",
|
||||
"ButtonScrollRight": "Rull til høyre",
|
||||
"ButtonSearch": "Søk",
|
||||
"ButtonSelectFolderPath": "Velg mappe",
|
||||
"ButtonSeries": "Serier",
|
||||
@ -88,20 +101,26 @@
|
||||
"ButtonStartMetadataEmbed": "Start Metadata innbaking",
|
||||
"ButtonStats": "Statistikk",
|
||||
"ButtonSubmit": "Send inn",
|
||||
"ButtonTest": "Test",
|
||||
"ButtonUnlinkOpenId": "Koble fra OpenID",
|
||||
"ButtonUpload": "Last opp",
|
||||
"ButtonUploadBackup": "Last opp sikkerhetskopi",
|
||||
"ButtonUploadCover": "Last opp cover",
|
||||
"ButtonUploadOPMLFile": "Last opp OPML fil",
|
||||
"ButtonUserDelete": "Slett bruker {0}",
|
||||
"ButtonUserEdit": "Rediger bruker {0}",
|
||||
"ButtonViewAll": "Vis alt",
|
||||
"ButtonViewAll": "Vis alle",
|
||||
"ButtonYes": "Ja",
|
||||
"ErrorUploadFetchMetadataAPI": "Feil ved innhenting av metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - forsøk å oppdatere tittel og/eller forfatter",
|
||||
"ErrorUploadLacksTitle": "Tittel kreves",
|
||||
"HeaderAccount": "Konto",
|
||||
"HeaderAddCustomMetadataProvider": "Legg til egendefinert metadata tilbyder",
|
||||
"HeaderAdvanced": "Avansert",
|
||||
"HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger",
|
||||
"HeaderAppriseNotificationSettings": "Apprise varslingsinstillinger",
|
||||
"HeaderAudioTracks": "Lydspor",
|
||||
"HeaderAudiobookTools": "Lydbok Filbehandlingsverktøy",
|
||||
"HeaderAuthentication": "Autentisering",
|
||||
"HeaderBackups": "Sikkerhetskopier",
|
||||
"HeaderChangePassword": "Bytt passord",
|
||||
"HeaderChapters": "Kapittel",
|
||||
@ -110,6 +129,8 @@
|
||||
"HeaderCollectionItems": "Samlingsgjenstander",
|
||||
"HeaderCover": "Omslag",
|
||||
"HeaderCurrentDownloads": "Aktive nedlastinger",
|
||||
"HeaderCustomMessageOnLogin": "Egendefinert melding ved pålogging",
|
||||
"HeaderCustomMetadataProviders": "Egendefinerte metadata tilbydere",
|
||||
"HeaderDetails": "Detaljer",
|
||||
"HeaderDownloadQueue": "Last ned kø",
|
||||
"HeaderEbookFiles": "Ebook filer",
|
||||
@ -140,12 +161,17 @@
|
||||
"HeaderMetadataToEmbed": "Metadata å bake inn",
|
||||
"HeaderNewAccount": "Ny konto",
|
||||
"HeaderNewLibrary": "Ny bibliotek",
|
||||
"HeaderNotifications": "Notifikasjoner",
|
||||
"HeaderNotificationCreate": "Opprett varsling",
|
||||
"HeaderNotificationUpdate": "Oppdater varsling",
|
||||
"HeaderNotifications": "Varslinger",
|
||||
"HeaderOpenIDConnectAuthentication": "Autentisering med OpenID Connect",
|
||||
"HeaderOpenListeningSessions": "Åpne lyttesesjoner",
|
||||
"HeaderOpenRSSFeed": "Åpne RSS Feed",
|
||||
"HeaderOtherFiles": "Andre filer",
|
||||
"HeaderPasswordAuthentication": "Logg inn med brukernavn og passord",
|
||||
"HeaderPermissions": "Rettigheter",
|
||||
"HeaderPlayerQueue": "Spiller kø",
|
||||
"HeaderPlayerSettings": "Avspillingsinnstillinger",
|
||||
"HeaderPlaylist": "Spilleliste",
|
||||
"HeaderPlaylistItems": "Spillelisteelement",
|
||||
"HeaderPodcastsToAdd": "Podcaster å legge til",
|
||||
@ -157,6 +183,7 @@
|
||||
"HeaderRemoveEpisodes": "Fjern {0} episoder",
|
||||
"HeaderSavedMediaProgress": "Lagret mediefremgang",
|
||||
"HeaderSchedule": "Timeplan",
|
||||
"HeaderScheduleEpisodeDownloads": "Planlegg automatisk nedlasting av episoder",
|
||||
"HeaderScheduleLibraryScans": "Planlegg automatisk bibliotek skann",
|
||||
"HeaderSession": "Sesjon",
|
||||
"HeaderSetBackupSchedule": "Sett timeplan for sikkerhetskopi",
|
||||
@ -165,6 +192,7 @@
|
||||
"HeaderSettingsExperimental": "Eksperimentelle funksjoner",
|
||||
"HeaderSettingsGeneral": "Generell",
|
||||
"HeaderSettingsScanner": "Skanner",
|
||||
"HeaderSettingsWebClient": "Webklient",
|
||||
"HeaderSleepTimer": "Sove timer",
|
||||
"HeaderStatsLargestItems": "Største enheter",
|
||||
"HeaderStatsLongestItems": "Lengste enheter (timer)",
|
||||
@ -179,9 +207,14 @@
|
||||
"HeaderUpdateDetails": "Oppdater detaljer",
|
||||
"HeaderUpdateLibrary": "Oppdater bibliotek",
|
||||
"HeaderUsers": "Brukere",
|
||||
"HeaderYearReview": "{0} oppsummert",
|
||||
"HeaderYourStats": "Din statistikk",
|
||||
"LabelAbridged": "Forkortet",
|
||||
"LabelAbridgedChecked": "Forkortet (valgt)",
|
||||
"LabelAbridgedUnchecked": "Forkortet (ikke valgt)",
|
||||
"LabelAccessibleBy": "Tilgjengelig via",
|
||||
"LabelAccountType": "Kontotype",
|
||||
"LabelAccountTypeAdmin": "Administrator",
|
||||
"LabelAccountTypeGuest": "Gjest",
|
||||
"LabelAccountTypeUser": "Bruker",
|
||||
"LabelActivity": "Aktivitet",
|
||||
@ -190,32 +223,55 @@
|
||||
"LabelAddToPlaylist": "Legg til i spilleliste",
|
||||
"LabelAddToPlaylistBatch": "Legg {0} enheter til i spilleliste",
|
||||
"LabelAddedAt": "Lagt Til",
|
||||
"LabelAddedDate": "La til {0}",
|
||||
"LabelAdminUsersOnly": "Kun administratorer",
|
||||
"LabelAll": "Alle",
|
||||
"LabelAllUsers": "Alle brukere",
|
||||
"LabelAllUsersExcludingGuests": "Alle brukere bortsett fra gjester",
|
||||
"LabelAllUsersIncludingGuests": "Alle brukere inkludert gjester",
|
||||
"LabelAlreadyInYourLibrary": "Allerede i biblioteket",
|
||||
"LabelApiToken": "API token",
|
||||
"LabelAppend": "Legge til",
|
||||
"LabelAudioBitrate": "Bitrate for lyd (f.eks. 128k)",
|
||||
"LabelAudioChannels": "Lydkanaler (1 eller 2)",
|
||||
"LabelAudioCodec": "Audio Codec",
|
||||
"LabelAuthor": "Forfatter",
|
||||
"LabelAuthorFirstLast": "Forfatter (Fornavn Etternavn)",
|
||||
"LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)",
|
||||
"LabelAuthors": "Forfattere",
|
||||
"LabelAutoDownloadEpisodes": "Last ned episoder automatisk",
|
||||
"LabelAutoFetchMetadata": "Automatisk henting av metadata",
|
||||
"LabelAutoFetchMetadataHelp": "Henter metadata for tittel, forfatter og serie for å optimalisere opplasting. Ekstra metadata må kanskje bekreftes etter opplasting.",
|
||||
"LabelAutoLaunch": "Autostart",
|
||||
"LabelAutoLaunchDescription": "Omdiriger til leverandør for innlogging automatisk når innloggingssiden åpnes. (Kan overstyres med <code>/login?autoLaunch=0</code>)",
|
||||
"LabelAutoRegister": "Automatisk registrering",
|
||||
"LabelAutoRegisterDescription": "Lag bruker automatisk ved første innlogging",
|
||||
"LabelBackToUser": "Tilbake til bruker",
|
||||
"LabelBackupAudioFiles": "Sikkerhetskopier lydfiler",
|
||||
"LabelBackupLocation": "Mappe for sikkerhetskopiering",
|
||||
"LabelBackupsEnableAutomaticBackups": "Aktiver automatisk sikkerhetskopi",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Sikkerhetskopier lagret under /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Maks sikkerhetskopi størrelse (i GB)",
|
||||
"LabelBackupsMaxBackupSize": "Maksimal størrelse for sikkerhetskopi (i GB) (0 for ubegrenset)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "For å forhindre feilkonfigurasjon, vil sikkerhetskopier mislykkes hvis de oveskride konfigurert størrelse.",
|
||||
"LabelBackupsNumberToKeep": "Antall sikkerhetskopier som skal beholdes",
|
||||
"LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhetskopi vil bli fjernet om gangen, hvis du allerede har flere sikkerhetskopier enn dette bør du fjerne de manuelt.",
|
||||
"LabelBitrate": "Bithastighet",
|
||||
"LabelBonus": "Bonus",
|
||||
"LabelBooks": "Bøker",
|
||||
"LabelButtonText": "Tekst på knappen",
|
||||
"LabelByAuthor": "av {0}",
|
||||
"LabelChangePassword": "Endre passord",
|
||||
"LabelChannels": "Kanaler",
|
||||
"LabelChapterCount": "{0} kapitler",
|
||||
"LabelChapterTitle": "Kapittel tittel",
|
||||
"LabelChapters": "Kapitler",
|
||||
"LabelChaptersFound": "kapitler funnet",
|
||||
"LabelClickForMoreInfo": "Klikk for mer informasjon",
|
||||
"LabelClickToUseCurrentValue": "Klikk for å bruke valgt verdi",
|
||||
"LabelClosePlayer": "Lukk spiller",
|
||||
"LabelCodec": "Kodek",
|
||||
"LabelCollapseSeries": "Minimer serier",
|
||||
"LabelCollapseSubSeries": "Skjul underserier",
|
||||
"LabelCollection": "Samling",
|
||||
"LabelCollections": "Samlings",
|
||||
"LabelComplete": "Fullfør",
|
||||
@ -232,58 +288,94 @@
|
||||
"LabelCustomCronExpression": "Tilpasset Cron utrykk:",
|
||||
"LabelDatetime": "Dato tid",
|
||||
"LabelDays": "Dager",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Slett fra filsystemet (fjern haken for kun å ta bort fra databasen)",
|
||||
"LabelDescription": "Beskrivelse",
|
||||
"LabelDeselectAll": "Fjern valg",
|
||||
"LabelDevice": "Enhet",
|
||||
"LabelDeviceInfo": "Enhetsinformasjon",
|
||||
"LabelDeviceIsAvailableTo": "Enheten er tilgjengelig for...",
|
||||
"LabelDirectory": "Mappe",
|
||||
"LabelDiscFromFilename": "Disk fra filnavn",
|
||||
"LabelDiscFromMetadata": "Disk fra metadata",
|
||||
"LabelDiscover": "Oppdagelse",
|
||||
"LabelDiscover": "Oppdag",
|
||||
"LabelDownload": "Last ned",
|
||||
"LabelDownloadNEpisodes": "Last ned {0} episoder",
|
||||
"LabelDuration": "Varighet",
|
||||
"LabelDurationComparisonExactMatch": "(nøyaktig treff)",
|
||||
"LabelDurationComparisonLonger": "({0} lenger)",
|
||||
"LabelDurationComparisonShorter": "({0} kortere)",
|
||||
"LabelDurationFound": "Varighet funnet:",
|
||||
"LabelEbook": "Ebok",
|
||||
"LabelEbooks": "E-bøker",
|
||||
"LabelEdit": "Rediger",
|
||||
"LabelEmail": "Epost",
|
||||
"LabelEmailSettingsFromAddress": "Fra Adresse",
|
||||
"LabelEmailSettingsRejectUnauthorized": "Avvis uautoriserte sertifikat",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Ved å deaktivere sjekk av SSL sertifikat eksponerer man tilkoblingen for sikkerhetsrisiko, som for eksempel mann-i-midten-angrep. Slå av kun om du forstår implikasjonene og stoler på e-post-serveren du kobler til!",
|
||||
"LabelEmailSettingsSecure": "Sikker",
|
||||
"LabelEmailSettingsSecureHelp": "Hvis aktivert, vil tilkoblingen bruke TLS under tilkobling til tjeneren. Ellers vil TLS bli brukt hvis tjeneren støtter STARTTLS utvidelsen. I de fleste tilfeller aktiver valget hvis du kobler til med port 465. Med port 587 eller 25 deaktiver valget. (fra nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsTestAddress": "Test Adresse",
|
||||
"LabelEmbeddedCover": "Bak inn omslag",
|
||||
"LabelEnable": "Aktiver",
|
||||
"LabelEncodingBackupLocation": "En sikkerhetskopi av de originale lyd-filene lagres i mappen:",
|
||||
"LabelEncodingChaptersNotEmbedded": "Kapitler er ikke bygget inn i flersporede lydbøker.",
|
||||
"LabelEncodingClearItemCache": "Husk å tømme mellomlageret med jevne mellomrom.",
|
||||
"LabelEncodingFinishedM4B": "Ferdig konvertert M4B-lydbøker legges i lydbok-mappen:",
|
||||
"LabelEncodingInfoEmbedded": "Metadata bygges inn i lydsporene i lydbokmappen.",
|
||||
"LabelEncodingStartedNavigation": "Så snart oppgaven er startet kan du navigere bort fra denne siden.",
|
||||
"LabelEncodingTimeWarning": "Konvertering kan ta opptil 30 minutter.",
|
||||
"LabelEncodingWarningAdvancedSettings": "Advarsel: Ikke oppdater disse innstillingene med mindre du er godt kjent med hvordan ffmpeg og konverteringsvalgene fungerer.",
|
||||
"LabelEncodingWatcherDisabled": "Hvis du har slått av overvåking så må du skanne dette biblioteket på nytt etterpå.",
|
||||
"LabelEnd": "Slutt",
|
||||
"LabelEndOfChapter": "Slutt på kapittel",
|
||||
"LabelEpisode": "Episode",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "Episode er ikke koblet til en RSS feed",
|
||||
"LabelEpisodeNumber": "Episode #{0}",
|
||||
"LabelEpisodeTitle": "Episode tittel",
|
||||
"LabelEpisodeType": "Episode type",
|
||||
"LabelEpisodeUrlFromRssFeed": "Episode URL fra RSS feed",
|
||||
"LabelEpisodes": "Episoder",
|
||||
"LabelEpisodic": "Episodisk",
|
||||
"LabelExample": "Eksempel",
|
||||
"LabelExpandSeries": "Vis serie",
|
||||
"LabelExpandSubSeries": "Vis underserie",
|
||||
"LabelExplicit": "Eksplisitt",
|
||||
"LabelExplicitChecked": "Eksplisitt (avhuket)",
|
||||
"LabelExplicitUnchecked": "Ikke eksplisitt (ikke avhuket)",
|
||||
"LabelExportOPML": "Eksporter OPML",
|
||||
"LabelFeedURL": "Feed Adresse",
|
||||
"LabelFetchingMetadata": "Henter metadata",
|
||||
"LabelFile": "Fil",
|
||||
"LabelFileBirthtime": "Fil Opprettelsesdato",
|
||||
"LabelFileBornDate": "Født {0}",
|
||||
"LabelFileModified": "Fil Endret",
|
||||
"LabelFileModifiedDate": "Redigert {0}",
|
||||
"LabelFilename": "Filnavn",
|
||||
"LabelFilterByUser": "Filtrer etter bruker",
|
||||
"LabelFindEpisodes": "Finn episoder",
|
||||
"LabelFinished": "Fullført",
|
||||
"LabelFolder": "Mappe",
|
||||
"LabelFolders": "Mapper",
|
||||
"LabelFontBold": "Fet",
|
||||
"LabelFontBoldness": "Skrifttykkelse",
|
||||
"LabelFontFamily": "Fontfamilie",
|
||||
"LabelFontItalic": "Kursiv",
|
||||
"LabelFontScale": "Font størrelse",
|
||||
"LabelFontStrikethrough": "Gjennomstreking",
|
||||
"LabelFormat": "Format",
|
||||
"LabelFull": "Full",
|
||||
"LabelGenre": "Sjanger",
|
||||
"LabelGenres": "Sjangers",
|
||||
"LabelHardDeleteFile": "Tving sletting av fil",
|
||||
"LabelHasEbook": "Har e-bok",
|
||||
"LabelHasSupplementaryEbook": "Har komplimentær e-bok",
|
||||
"LabelHideSubtitles": "Skjul undertekster",
|
||||
"LabelHighestPriority": "Høyeste prioritet",
|
||||
"LabelHost": "Tjener",
|
||||
"LabelHour": "Time",
|
||||
"LabelHours": "Timer",
|
||||
"LabelIcon": "Ikon",
|
||||
"LabelImageURLFromTheWeb": "Bilde-URL fra nett",
|
||||
"LabelInProgress": "I gang",
|
||||
"LabelIncludeInTracklist": "Inkluder i sporliste",
|
||||
"LabelIncomplete": "Ufullstendig",
|
||||
@ -298,8 +390,11 @@
|
||||
"LabelIntervalEveryHour": "Hver time",
|
||||
"LabelInvert": "Inverter",
|
||||
"LabelItem": "Enhet",
|
||||
"LabelJumpBackwardAmount": "Hopp bakover med",
|
||||
"LabelJumpForwardAmount": "Hopp forover med",
|
||||
"LabelLanguage": "Språk",
|
||||
"LabelLanguageDefaultServer": "Standard tjener språk",
|
||||
"LabelLanguages": "Språk",
|
||||
"LabelLastBookAdded": "Siste bok lagt til",
|
||||
"LabelLastBookUpdated": "Siste bok oppdatert",
|
||||
"LabelLastSeen": "Sist sett",
|
||||
@ -311,17 +406,36 @@
|
||||
"LabelLess": "Mindre",
|
||||
"LabelLibrariesAccessibleToUser": "Biblioteker tilgjengelig for bruker",
|
||||
"LabelLibrary": "Bibliotek",
|
||||
"LabelLibraryFilterSublistEmpty": "",
|
||||
"LabelLibraryItem": "Bibliotek enhet",
|
||||
"LabelLibraryName": "Bibliotek navn",
|
||||
"LabelLimit": "Begrensning",
|
||||
"LabelLineSpacing": "Linjemellomrom",
|
||||
"LabelListenAgain": "Lytt igjen",
|
||||
"LabelLogLevelDebug": "Debug",
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"LabelLookForNewEpisodesAfterDate": "Se etter nye episoder etter denne datoen",
|
||||
"LabelLowestPriority": "Laveste prioritet",
|
||||
"LabelMatchExistingUsersBy": "Knytt sammen eksisterende brukere basert på",
|
||||
"LabelMatchExistingUsersByDescription": "Brukes for å koble til eksisterende brukere. Når koblingen er i orden vil brukerne bli identifisert med en unik id fra SSO-tilbyderen.",
|
||||
"LabelMaxEpisodesToDownload": "Maksimalt antall episoder som skal lastes ned. Bruk 0 for ubegrenset.",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Maksimalt antall nye episoder som skal lastes ned per sjekk",
|
||||
"LabelMaxEpisodesToKeep": "Maksimalt antall episoder som skal beholdes",
|
||||
"LabelMaxEpisodesToKeepHelp": "Sett verdien til null (0) for ubegrenset. Etter at en episode lastes ned automatisk, så slettes den eldste episoden, om du har mer enn X episoder. Det slettes kun én episode per nye nedlasting.",
|
||||
"LabelMediaPlayer": "Mediespiller",
|
||||
"LabelMediaType": "Medie type",
|
||||
"LabelMetaTag": "Meta tag",
|
||||
"LabelMetaTags": "Meta tags",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "Høyere prioritert kilder for metadata overstyrer laverer prioriterte kilder for metadata.",
|
||||
"LabelMetadataProvider": "Metadata Leverandør",
|
||||
"LabelMinute": "Minutt",
|
||||
"LabelMinutes": "Minutter",
|
||||
"LabelMissing": "Mangler",
|
||||
"LabelMissingEbook": "Har ingen e-bok",
|
||||
"LabelMissingSupplementaryEbook": "Har ingen komplementær e-bok",
|
||||
"LabelMobileRedirectURIs": "Tillatte URL-er for vidersending",
|
||||
"LabelMobileRedirectURIsDescription": "Dette er en liste over godkjente videresendings-URL-er for mobil-apper. Standarden er <code>audiobookshelf://oauth</code>, som du kan fjerne eller supplere med ekstra URL-er for tredjeparts app-integrasjoner. For å tillate alle URL-er kan du bruke kun en (<code>*</code>) .",
|
||||
"LabelMore": "Mer",
|
||||
"LabelMoreInfo": "Mer info",
|
||||
"LabelName": "Navn",
|
||||
@ -333,6 +447,7 @@
|
||||
"LabelNewestEpisodes": "Nyeste episoder",
|
||||
"LabelNextBackupDate": "Neste sikkerhetskopi dato",
|
||||
"LabelNextScheduledRun": "Neste planlagte kjøring",
|
||||
"LabelNoCustomMetadataProviders": "Ingen egendefinerte tilbydere for metadata",
|
||||
"LabelNoEpisodesSelected": "Ingen episoder valgt",
|
||||
"LabelNotFinished": "Ikke fullført",
|
||||
"LabelNotStarted": "Ikke startet",
|
||||
@ -340,66 +455,95 @@
|
||||
"LabelNotificationAppriseURL": "Apprise URL(er)",
|
||||
"LabelNotificationAvailableVariables": "Tilgjengelige variabler",
|
||||
"LabelNotificationBodyTemplate": "Kroppsmal",
|
||||
"LabelNotificationEvent": "Notifikasjons hendelse",
|
||||
"LabelNotificationEvent": "Varsling",
|
||||
"LabelNotificationTitleTemplate": "Tittel mal",
|
||||
"LabelNotificationsMaxFailedAttempts": "Maks mislykkede forsøk",
|
||||
"LabelNotificationsMaxFailedAttemptsHelp": "Notifikasjoner er deaktivert når de mislykkes på sende dette flere ganger",
|
||||
"LabelNotificationsMaxQueueSize": "Maks kø lengde for Notifikasjonshendelser",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Hendelser er begrenset til avfyre 1 gang per sekund. Hendelser vil bli ignorert om køen er full. Dette forhindrer Notifikasjon spam.",
|
||||
"LabelNotificationsMaxFailedAttemptsHelp": "Varslinger deaktiveres når sending feiles dette antallet ganger",
|
||||
"LabelNotificationsMaxQueueSize": "Maksimalt antall varslinger i kø",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Hendelser er begrenset til avfyre én gang per sekund. Hendelser blir ignorert om køen er full. Dette forhindrer overflod av varslinger.",
|
||||
"LabelNumberOfBooks": "Antall bøker",
|
||||
"LabelNumberOfEpisodes": "Antall episoder",
|
||||
"LabelOpenIDClaims": "La følge valg være tomme for å slå av avanserte gruppe og tillatelser. Gruppen \"Bruker\" vil da også automatisk legges til.",
|
||||
"LabelOpenRSSFeed": "Åpne RSS Feed",
|
||||
"LabelOverwrite": "Overskriv",
|
||||
"LabelPaginationPageXOfY": "Side {0} av {1}",
|
||||
"LabelPassword": "Passord",
|
||||
"LabelPath": "Sti",
|
||||
"LabelPermanent": "Fast",
|
||||
"LabelPermissionsAccessAllLibraries": "Har tilgang til alle bibliotek",
|
||||
"LabelPermissionsAccessAllTags": "Har til gang til alle tags",
|
||||
"LabelPermissionsAccessExplicitContent": "Har tilgang til eksplisitt material",
|
||||
"LabelPermissionsCreateEreader": "Kan opprette e-leser",
|
||||
"LabelPermissionsDelete": "Kan slette",
|
||||
"LabelPermissionsDownload": "Kan laste ned",
|
||||
"LabelPermissionsUpdate": "Kan oppdatere",
|
||||
"LabelPermissionsUpload": "Kan laste opp",
|
||||
"LabelPersonalYearReview": "Oppsummering av året ditt ({0})",
|
||||
"LabelPhotoPathURL": "Bilde sti/URL",
|
||||
"LabelPlayMethod": "Avspillingsmetode",
|
||||
"LabelPlayerChapterNumberMarker": "{0} av {1}",
|
||||
"LabelPlaylists": "Spilleliste",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcastSearchRegion": "Podcast-søkeområde",
|
||||
"LabelPodcastType": "Podcast type",
|
||||
"LabelPodcasts": "Podcaster",
|
||||
"LabelPort": "Port",
|
||||
"LabelPrefixesToIgnore": "Prefiks som skal ignoreres (skiller ikke mellom store og små bokstaver)",
|
||||
"LabelPreventIndexing": "Forhindre at din feed fra å bli indeksert av iTunes og Google podcast kataloger",
|
||||
"LabelPrimaryEbook": "Primær ebok",
|
||||
"LabelProgress": "Framgang",
|
||||
"LabelProvider": "Tilbyder",
|
||||
"LabelProviderAuthorizationValue": "Autorisasjons header-verdi",
|
||||
"LabelPubDate": "Publiseringsdato",
|
||||
"LabelPublishYear": "Publikasjonsår",
|
||||
"LabelPublishedDate": "Publisert {0}",
|
||||
"LabelPublishedDecade": "Tiår for utgivelse",
|
||||
"LabelPublishedDecades": "Tiår for utgivelse",
|
||||
"LabelPublisher": "Forlegger",
|
||||
"LabelPublishers": "Utgivere",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Tilpasset eier e-post",
|
||||
"LabelRSSFeedCustomOwnerName": "Tilpasset eier Navn",
|
||||
"LabelRSSFeedOpen": "RSS Feed åpne",
|
||||
"LabelRSSFeedPreventIndexing": "Forhindre indeksering",
|
||||
"LabelRSSFeedSlug": "RSS-informasjonskanalunderadresse",
|
||||
"LabelRSSFeedSlug": "RSS-feed ID",
|
||||
"LabelRSSFeedURL": "RSS-feed URL",
|
||||
"LabelRandomly": "Tilfeldig",
|
||||
"LabelReAddSeriesToContinueListening": "Legg til igjen til \"Fortsett å lytte\"",
|
||||
"LabelRead": "Les",
|
||||
"LabelReadAgain": "Les igjen",
|
||||
"LabelReadEbookWithoutProgress": "Les ebok uten å beholde fremgang",
|
||||
"LabelRecentSeries": "Nylige serier",
|
||||
"LabelRecentlyAdded": "Nylig tillagt",
|
||||
"LabelRecommended": "Anbefalte",
|
||||
"LabelRedo": "Gjenta",
|
||||
"LabelRegion": "Region",
|
||||
"LabelReleaseDate": "Utgivelsesdato",
|
||||
"LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
|
||||
"LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
|
||||
"LabelRemoveCover": "Fjern omslag",
|
||||
"LabelRemoveMetadataFile": "Fjern metadata-filer fra biblioteks-mapper",
|
||||
"LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs i alle {0} mappene.",
|
||||
"LabelRowsPerPage": "Rader per side",
|
||||
"LabelSearchTerm": "Søkeord",
|
||||
"LabelSearchTitle": "Søk tittel",
|
||||
"LabelSearchTitleOrASIN": "Søk tittel eller ASIN",
|
||||
"LabelSeason": "Sesong",
|
||||
"LabelSeasonNumber": "Sesong #{0}",
|
||||
"LabelSelectAll": "Velg alt",
|
||||
"LabelSelectAllEpisodes": "Velg alle episoder",
|
||||
"LabelSelectEpisodesShowing": "Velg {0} episoder vist",
|
||||
"LabelSelectUsers": "Velg brukere",
|
||||
"LabelSendEbookToDevice": "Send Ebok til...",
|
||||
"LabelSequence": "Sekvens",
|
||||
"LabelSerial": "Serienr.",
|
||||
"LabelSeries": "Serier",
|
||||
"LabelSeriesName": "Serier Navn",
|
||||
"LabelSeriesProgress": "Serier fremgang",
|
||||
"LabelServerLogLevel": "Server logg-nivå",
|
||||
"LabelServerYearReview": "Server - Oppsummering av året ({0})",
|
||||
"LabelSetEbookAsPrimary": "Sett som primær",
|
||||
"LabelSetEbookAsSupplementary": "Sett som supplerende",
|
||||
"LabelSettingsAllowIframe": "Tillat å bygge inn i en iframe",
|
||||
"LabelSettingsAudiobooksOnly": "Kun lydbøker",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Aktivering av dette valget til ignorere ebok filer utenom de er i en lydbok mappe hvor de vil bli satt som supplerende ebøker",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeuomorf design med hyller av ved",
|
||||
@ -411,6 +555,8 @@
|
||||
"LabelSettingsEnableWatcher": "Aktiver overvåker",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Aktiver mappe overvåker for bibliotek",
|
||||
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk opprettelse/oppdatering av enheter når filendringer er oppdaget. *Krever restart av server*",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Tillat scripting i innholdet i ebub-bøker",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillat epub-filer å kjøre script. Det er anbefalt å slå av denne innstillingen med mindre du stoler på kilden til epub-filene.",
|
||||
"LabelSettingsExperimentalFeatures": "Eksperimentelle funksjoner",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Funksjoner under utvikling som kan trenge din tilbakemelding og hjelp med testing. Klikk for å åpne GitHub diskusjon.",
|
||||
"LabelSettingsFindCovers": "Finn omslag",
|
||||
@ -419,8 +565,13 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Serier som har kun en bok vil bli gjemt på serie- og hjemmeside hyllen.",
|
||||
"LabelSettingsHomePageBookshelfView": "Hjemmeside bruk bokhyllevisning",
|
||||
"LabelSettingsLibraryBookshelfView": "Bibliotek bruk bokhyllevisning",
|
||||
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Prosent ferdig er større enn",
|
||||
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Gjenværende tid er mindre enn (sekunder)",
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Marker som ferdig når",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hopp over tidligere bøker i \"Fortsett serien\"",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "\"Fortsett serie\"-siden viser første bok som ikke er påbegynt i serier der en bok er lest og ingen bøker leses nå. Ved å slå på denne innstillingen så vil man fortsette på serien etter siste leste bok, fremfor første bok som ikke er startet på i en serie.",
|
||||
"LabelSettingsParseSubtitles": "Analyser undertekster",
|
||||
"LabelSettingsParseSubtitlesHelp": "Trekk ut undertekster fra lydbok mappenavn.<br>undertekster må være separert med \" - \"<br>f.eks. \"Boktittel - Undertekst her\" har Undertekst \"Undertekst her\"",
|
||||
"LabelSettingsParseSubtitlesHelp": "Hent undertittel fra lydbokens mappenavn.<br>Undertittel må være separert med \" - \"<br>f.eks. \"Boktittel - En lengre tittel\" har undertittel \"En lengre tittel\".",
|
||||
"LabelSettingsPreferMatchedMetadata": "Foretrekk funnet metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Funnet data vil overskrive enhetens detaljene når man bruker Kjapt søk. Som standard vil Kjapt søk kun fylle inn manglende detaljer.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Hopp over bøker som allerede har ASIN",
|
||||
@ -435,10 +586,17 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden",
|
||||
"LabelSettingsTimeFormat": "Tid format",
|
||||
"LabelShare": "Dele",
|
||||
"LabelShareOpen": "Åpne deling",
|
||||
"LabelShareURL": "Dele URL",
|
||||
"LabelShowAll": "Vis alt",
|
||||
"LabelShowAll": "Vis alle",
|
||||
"LabelShowSeconds": "Vis sekunder",
|
||||
"LabelShowSubtitles": "Vis undertitler",
|
||||
"LabelSize": "Størrelse",
|
||||
"LabelSleepTimer": "Sove-timer",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelSortAscending": "Stigende",
|
||||
"LabelSortDescending": "Synkende",
|
||||
"LabelStart": "Start",
|
||||
"LabelStartTime": "Start Tid",
|
||||
"LabelStarted": "Startet",
|
||||
"LabelStartedAt": "Startet",
|
||||
@ -459,15 +617,24 @@
|
||||
"LabelStatsWeekListening": "Uker lyttet",
|
||||
"LabelSubtitle": "undertekster",
|
||||
"LabelSupportedFileTypes": "Støttede filtyper",
|
||||
"LabelTag": "Tag",
|
||||
"LabelTags": "Tagger",
|
||||
"LabelTagsAccessibleToUser": "Tagger tilgjengelig for bruker",
|
||||
"LabelTagsNotAccessibleToUser": "Tagger ikke tilgjengelig for bruker",
|
||||
"LabelTasks": "Oppgaver som kjører",
|
||||
"LabelTextEditorBulletedList": "Punkt-liste",
|
||||
"LabelTextEditorLink": "Link",
|
||||
"LabelTextEditorNumberedList": "Nummerert liste",
|
||||
"LabelTextEditorUnlink": "Fjern link",
|
||||
"LabelTheme": "Tema",
|
||||
"LabelThemeDark": "Mørk",
|
||||
"LabelThemeLight": "Lys",
|
||||
"LabelTimeBase": "Tidsbase",
|
||||
"LabelTimeDurationXHours": "{0} timer",
|
||||
"LabelTimeDurationXMinutes": "{0} minutter",
|
||||
"LabelTimeDurationXSeconds": "{0} sekunder",
|
||||
"LabelTimeInMinutes": "Timer i minutter",
|
||||
"LabelTimeLeft": "{0} gjenstår",
|
||||
"LabelTimeListened": "Tid lyttet",
|
||||
"LabelTimeListenedToday": "Tid lyttet idag",
|
||||
"LabelTimeRemaining": "{0} gjennstående",
|
||||
@ -475,6 +642,7 @@
|
||||
"LabelTitle": "Tittel",
|
||||
"LabelToolsEmbedMetadata": "Bak inn metadata",
|
||||
"LabelToolsEmbedMetadataDescription": "Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.",
|
||||
"LabelToolsM4bEncoder": "M4B enkoder",
|
||||
"LabelToolsMakeM4b": "Lag M4B Lydbokfil",
|
||||
"LabelToolsMakeM4bDescription": "Lager en.M4B lydbokfil med innbakte omslagsbilde og kapitler.",
|
||||
"LabelToolsSplitM4b": "Del M4B inn i MP3er",
|
||||
@ -487,39 +655,56 @@
|
||||
"LabelTracksMultiTrack": "Flerspor",
|
||||
"LabelTracksNone": "Ingen spor",
|
||||
"LabelTracksSingleTrack": "Enkelspor",
|
||||
"LabelTrailer": "Trailer",
|
||||
"LabelType": "Type",
|
||||
"LabelUnabridged": "Uavkortet",
|
||||
"LabelUndo": "Angre",
|
||||
"LabelUnknown": "Ukjent",
|
||||
"LabelUnknownPublishDate": "Ukjent publiseringsdato",
|
||||
"LabelUpdateCover": "Oppdater omslag",
|
||||
"LabelUpdateCoverHelp": "Tillat overskriving av eksisterende omslag for de valgte bøkene når en lik bok er funnet",
|
||||
"LabelUpdateDetails": "Oppdater detaljer",
|
||||
"LabelUpdateDetailsHelp": "Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet",
|
||||
"LabelUpdatedAt": "Oppdatert",
|
||||
"LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Dra & slipp filer",
|
||||
"LabelUploaderDropFiles": "Slipp filer",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Hent tittel, forfatter og serie automatisk",
|
||||
"LabelUseAdvancedOptions": "Bruk avanserte valg",
|
||||
"LabelUseChapterTrack": "Bruk kapittelspor",
|
||||
"LabelUseFullTrack": "Bruke hele sporet",
|
||||
"LabelUseZeroForUnlimited": "Bruk 0 for ubegrenset",
|
||||
"LabelUser": "Bruker",
|
||||
"LabelUsername": "Brukernavn",
|
||||
"LabelValue": "Verdi",
|
||||
"LabelVersion": "Versjon",
|
||||
"LabelViewBookmarks": "Vis bokmerker",
|
||||
"LabelViewChapters": "Vis kapitler",
|
||||
"LabelViewPlayerSettings": "Vis innstillinger for avspiller",
|
||||
"LabelViewQueue": "Vis spillerkø",
|
||||
"LabelVolume": "Volum",
|
||||
"LabelWebRedirectURLsDescription": "Godkjenn disse URL-ene hos OAuth-tilbyder for å tillate videresending til web-appen etter innlogging:",
|
||||
"LabelWebRedirectURLsSubfolder": "Undermapper for videresendings-URL-er",
|
||||
"LabelWeekdaysToRun": "Ukedager å kjøre",
|
||||
"LabelXBooks": "{0} bøker",
|
||||
"LabelXItems": "{0} elementer",
|
||||
"LabelYearReviewHide": "Skjul oppsummering av året",
|
||||
"LabelYearReviewShow": "Vis oppsummering av året",
|
||||
"LabelYourAudiobookDuration": "Din lydbok lengde",
|
||||
"LabelYourBookmarks": "Dine bokmerker",
|
||||
"LabelYourPlaylists": "Dine spillelister",
|
||||
"LabelYourProgress": "Din fremgang",
|
||||
"MessageAddToPlayerQueue": "Legg til i kø",
|
||||
"MessageAppriseDescription": "For å bruke denne funksjonen trenger du en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kjørende eller ett api som vil håndere disse forespørslene. <br />Apprise API Url skal være den fulle URL stien for å sende Notifikasjonen, f.eks., hvis din API instans er hos <code>http://192.168.1.1:8337</code> vil du bruke <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAppriseDescription": "For å bruke denne funksjonen trenger du en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kjørende eller et API som håndterer disse forespørslene. <br />Apprise API URL skal være hele URL-en til varslingen, f.eks., hvis din API-instans er på <code>http://192.168.1.1:8337</code> så skal du bruke <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Sikkerhetskopier inkluderer, brukerfremgang, detaljer om bibliotekgjenstander, tjener instillinger og bilder lagret under <code>/metadata/items</code> og <code>/metadata/authors</code>. Sikkerhetskopier <strong>vil ikke</strong> inkludere filer som er lagret i bibliotek mappene.",
|
||||
"MessageBackupsLocationEditNote": "Merk: Endring av sikkerhetskopieringssted hverken endrer eller flytter eksisterende sikkerhetskopier",
|
||||
"MessageBackupsLocationPathEmpty": "Sti til sikkerhetskopieringssted må angis",
|
||||
"MessageBackupsLocationEditNote": "Viktig: Endring av mappen for sikkerhetskopi hverken endrer eller flytter eksisterende sikkerhetskopier!",
|
||||
"MessageBackupsLocationNoEditNote": "NB: Mappen for sikkerhetskopi settes i en miljøvariabel og kan ikke endres her.",
|
||||
"MessageBackupsLocationPathEmpty": "Mappen for sikkerhetskopiering må angis",
|
||||
"MessageBatchQuickMatchDescription": "Kjapt søk vil forsøke å legge til manglende omslag og metadata for de valgte gjenstandene. Aktiver dette valget for å tillate Kjapt søk til å overskrive eksisterende omslag og/eller metadata.",
|
||||
"MessageBookshelfNoCollections": "Du har ikke laget noen samlinger ennå",
|
||||
"MessageBookshelfNoRSSFeeds": "Ingen RSS feed er åpen",
|
||||
"MessageBookshelfNoResultsForFilter": "Ingen resultat for filter \"{0}: {1}\"",
|
||||
"MessageBookshelfNoResultsForQuery": "Ingen resultater for søket",
|
||||
"MessageBookshelfNoSeries": "Du har ingen serier",
|
||||
"MessageChapterEndIsAfter": "Kapittel slutt er etter slutt av lydboken",
|
||||
"MessageChapterErrorFirstNotZero": "Første kapittel starter på 0",
|
||||
@ -529,18 +714,35 @@
|
||||
"MessageCheckingCron": "Sjekker cron...",
|
||||
"MessageConfirmCloseFeed": "Er du sikker på at du vil lukke denne feeden?",
|
||||
"MessageConfirmDeleteBackup": "Er du sikker på at du vil slette sikkerhetskopi for {0}?",
|
||||
"MessageConfirmDeleteDevice": "Er du sikker på at du vil slette e-leser enheten \"{0}\"?",
|
||||
"MessageConfirmDeleteFile": "Dette vil slette filen fra filsystemet. Er du sikker?",
|
||||
"MessageConfirmDeleteLibrary": "Er du sikker på at du vil slette biblioteket \"{0}\" for godt?",
|
||||
"MessageConfirmDeleteLibraryItem": "Nå slettes elementet fra databasen og fil-systemet. Er du sikker?",
|
||||
"MessageConfirmDeleteLibraryItems": "Nå slettes {0} elementer fra databasen og fil-systemet. Er du sikker?",
|
||||
"MessageConfirmDeleteMetadataProvider": "Er du sikker på at du vil slette den egendefinerte leverandøren av metadata: \"{0}\"?",
|
||||
"MessageConfirmDeleteNotification": "Er du sikker på at du vil slette dette varselet?",
|
||||
"MessageConfirmDeleteSession": "Er du sikker på at du vil slette denne sesjonen?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Er du sikker på at du vil legge til metadata i {0} lyd-filer?",
|
||||
"MessageConfirmForceReScan": "Er du sikker på at du vil tvinge en ny skann?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Er du sikker på at du vil markere alle episodene som fullført?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på at du vil markere alle episodene som ikke fullført?",
|
||||
"MessageConfirmMarkItemFinished": "Er du sikker på at du vil markere {0} som ferdig?",
|
||||
"MessageConfirmMarkItemNotFinished": "Er du sikker på at du vil markere {0} som ikke ferdig?",
|
||||
"MessageConfirmMarkSeriesFinished": "Er du sikker på at du vil markere alle bøkene i serien som fullført?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Er du sikker på at du vil markere alle bøkene i serien som ikke fullført?",
|
||||
"MessageConfirmNotificationTestTrigger": "Utløs dette varselet med test-data?",
|
||||
"MessageConfirmPurgeCache": "(Purge cache) Dette vil sletter hele mappen <code>/metadata/cache</code>. <br /><br />Er du sikker på at du du vil slette cache-mappen?",
|
||||
"MessageConfirmPurgeItemsCache": "(Purge items cache) Dette vil sletter hele mappen <code>/metadata/cache/items</code>.<br />Er du sikker?",
|
||||
"MessageConfirmQuickEmbed": "Advarsel! Rask innbygging av metadata tar ikke backup av lyd-filene først. Forsikre deg om at du har sikkerhetskopi av filene. <br><br> Fortsett?",
|
||||
"MessageConfirmQuickMatchEpisodes": "Hurtig gjenkjenning av episoder overskriver detaljene hvis en match blir funnet. Kun episoder som ikke allerede er matchet blir oppdatert. Er du sikker?",
|
||||
"MessageConfirmReScanLibraryItems": "Er du sikker på at du ønsker å skanne {0} elementer på nytt?",
|
||||
"MessageConfirmRemoveAllChapters": "Er du sikker på at du vil fjerne alle kapitler?",
|
||||
"MessageConfirmRemoveAuthor": "Er du sikker på at du vil fjerne forfatteren \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Er du sikker på at du vil fjerne episode \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Er du sikker på at du vil fjerne {0} episoder?",
|
||||
"MessageConfirmRemoveListeningSessions": "Er du sikker på at du vil fjerne {0} lytte-sesjoner?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Er du sikker på at du vil fjerne alle metadata.{0}-filer i mappene for biblioteks-elementer?",
|
||||
"MessageConfirmRemoveNarrator": "Er du sikker på at du vil fjerne forteller \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Er du sikker på at du vil fjerne spillelisten \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Er du sikker på at du vil endre sjanger \"{0}\" til \"{1}\" for alle gjenstandene?",
|
||||
@ -549,11 +751,16 @@
|
||||
"MessageConfirmRenameTag": "Er du sikker på at du vil endre tag \"{0}\" til \"{1}\" for alle gjenstandene?",
|
||||
"MessageConfirmRenameTagMergeNote": "Notis: Denne taggen finnes allerede så de vil bli slått sammen.",
|
||||
"MessageConfirmRenameTagWarning": "Advarsel! En lignende tag eksisterer allerede (med forsjellige store / små bokstaver) \"{0}\".",
|
||||
"MessageConfirmResetProgress": "Er du sikkert på at du vil tilbakestille fremgangen?",
|
||||
"MessageConfirmSendEbookToDevice": "Er du sikker på at du vil sende {0} ebok \"{1}\" til enhet \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Er du sikker på at du vil koble denne brukeren fra OpenID?",
|
||||
"MessageDownloadingEpisode": "Laster ned episode",
|
||||
"MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge",
|
||||
"MessageEmbedFailed": "Innbygging feilet!",
|
||||
"MessageEmbedFinished": "Bak inn Fullført!",
|
||||
"MessageEmbedQueue": "Lagt i køen for innbygging av metadata ({0} i kø)",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Episode(r) lagt til i kø for nedlasting",
|
||||
"MessageEreaderDevices": "For å sikre sendingen av e-bøker, så må du kanskje legge til e-postadressen over som en gyldig avsender for hver enhet i listen over.",
|
||||
"MessageFeedURLWillBe": "Feed URL vil bli {0}",
|
||||
"MessageFetching": "Henter...",
|
||||
"MessageForceReScanDescription": "vil skanne alle filene igjen som en ny skann. Lyd fil ID3 tagger, OPF filer og tekstfiler vil bli skannet som nye.",
|
||||
@ -562,7 +769,6 @@
|
||||
"MessageItemsSelected": "{0} Gjenstander valgt",
|
||||
"MessageItemsUpdated": "{0} Gjenstander oppdatert",
|
||||
"MessageJoinUsOn": "Følg oss nå",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} Lyttesesjoner iløpet av siste året",
|
||||
"MessageLoading": "Laster...",
|
||||
"MessageLoadingFolders": "Laster mapper...",
|
||||
"MessageM4BFailed": "M4B mislykkes!",
|
||||
@ -593,7 +799,7 @@
|
||||
"MessageNoListeningSessions": "Ingen Lyttesesjoner",
|
||||
"MessageNoLogs": "Ingen logger",
|
||||
"MessageNoMediaProgress": "Ingen mediefremgang",
|
||||
"MessageNoNotifications": "Ingen notifikasjoner",
|
||||
"MessageNoNotifications": "Ingen varslinger",
|
||||
"MessageNoPodcastsFound": "Ingen podcaster funnet",
|
||||
"MessageNoResults": "Ingen resultat",
|
||||
"MessageNoSearchResultsFor": "Ingen søkeresultat for \"{0}\"",
|
||||
@ -648,30 +854,64 @@
|
||||
"ToastAuthorUpdateMerged": "Forfatter slått sammen",
|
||||
"ToastAuthorUpdateSuccess": "Forfatter oppdatert",
|
||||
"ToastAuthorUpdateSuccessNoImageFound": "Forfatter oppdater (ingen bilde funnet)",
|
||||
"ToastBackupAppliedSuccess": "Sikkerhetskopi slått på",
|
||||
"ToastBackupCreateFailed": "Mislykkes å lage sikkerhetskopi",
|
||||
"ToastBackupCreateSuccess": "Sikkerhetskopi opprettet",
|
||||
"ToastBackupDeleteFailed": "Mislykkes å slette sikkerhetskopi",
|
||||
"ToastBackupDeleteSuccess": "Sikkerhetskopi slettet",
|
||||
"ToastBackupInvalidMaxKeep": "Ugyldig antall sikkerhetskopier ønskes beholdt",
|
||||
"ToastBackupInvalidMaxSize": "Ugyldig maksimal størrelse for sikkerhetskopi",
|
||||
"ToastBackupRestoreFailed": "Misslykkes å gjenopprette sikkerhetskopi",
|
||||
"ToastBackupUploadFailed": "Misslykkes å laste opp sikkerhetskopi",
|
||||
"ToastBackupUploadSuccess": "Sikkerhetskopi lastet opp",
|
||||
"ToastBatchDeleteFailed": "Sletting feilet på utvalget",
|
||||
"ToastBatchDeleteSuccess": "Sletting av samling utført",
|
||||
"ToastBatchQuickMatchFailed": "Feil ved rask integrering av metadata!",
|
||||
"ToastBatchQuickMatchStarted": "Rask integrering av metadata for {0} bøker startet!",
|
||||
"ToastBatchUpdateFailed": "Bulk oppdatering mislykket",
|
||||
"ToastBatchUpdateSuccess": "Bulk oppdatering fullført",
|
||||
"ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke",
|
||||
"ToastBookmarkCreateSuccess": "Bokmerke lagt til",
|
||||
"ToastBookmarkRemoveSuccess": "Bokmerke fjernet",
|
||||
"ToastBookmarkUpdateSuccess": "Bokmerke oppdatert",
|
||||
"ToastCachePurgeFailed": "Kunne ikke å slette mellomlager",
|
||||
"ToastCachePurgeSuccess": "Mellomlager slettet",
|
||||
"ToastChaptersHaveErrors": "Kapittel har feil",
|
||||
"ToastChaptersMustHaveTitles": "Kapittel må ha titler",
|
||||
"ToastCollectionItemsRemoveSuccess": "Gjenstand(er) fjernet fra samling",
|
||||
"ToastChaptersRemoved": "Kapitler fjernet",
|
||||
"ToastChaptersUpdated": "Kapitler oppdatert",
|
||||
"ToastCollectionItemsAddFailed": "Feil med å legge til element(er)",
|
||||
"ToastCollectionRemoveSuccess": "Samling fjernet",
|
||||
"ToastCollectionUpdateSuccess": "samlingupdated",
|
||||
"ToastCoverUpdateFailed": "Oppdatering av bilde feilet",
|
||||
"ToastDeleteFileFailed": "Kunne ikke slette fil",
|
||||
"ToastDeleteFileSuccess": "Fil slettet",
|
||||
"ToastDeviceAddFailed": "Kunne ikke legge til enhet",
|
||||
"ToastDeviceNameAlreadyExists": "E-leser med dette navnet eksisterer allerede",
|
||||
"ToastDeviceTestEmailFailed": "Kunne ikke sende test e-post",
|
||||
"ToastDeviceTestEmailSuccess": "E-post for testing er sendt",
|
||||
"ToastEmailSettingsUpdateSuccess": "Innstillinger for e-post oppdatert",
|
||||
"ToastEncodeCancelFailed": "Kunne ikke stoppe konverteringen",
|
||||
"ToastEncodeCancelSucces": "Konvertering kansellert",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "Kunne ikke tømme køen",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Nedlastingskø for eposider tømt",
|
||||
"ToastEpisodeUpdateSuccess": "{0} episoder oppdatert",
|
||||
"ToastFailedToLoadData": "Kunne ikke laste inn data",
|
||||
"ToastFailedToMatch": "Kunne ikke matche",
|
||||
"ToastFailedToShare": "Deling feilet",
|
||||
"ToastFailedToUpdate": "Oppdatering feilet",
|
||||
"ToastInvalidImageUrl": "Ugyldig URL for bilde",
|
||||
"ToastInvalidMaxEpisodesToDownload": "Ugyldig maksimalt antall for nedlasting av episoder",
|
||||
"ToastInvalidUrl": "Ugyldig URL",
|
||||
"ToastItemCoverUpdateSuccess": "Omslag oppdatert",
|
||||
"ToastItemDeletedFailed": "Kunne ikke slette element",
|
||||
"ToastItemDeletedSuccess": "Element slettet",
|
||||
"ToastItemDetailsUpdateSuccess": "Detaljer oppdatert",
|
||||
"ToastItemMarkedAsFinishedFailed": "Misslykkes å markere som Fullført",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Gjenstand marker som Fullført",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Misslykkes å markere som Ikke Fullført",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Markert som Ikke Fullført",
|
||||
"ToastItemUpdateSuccess": "Element oppdatert",
|
||||
"ToastLibraryCreateFailed": "Misslykkes å opprette bibliotek",
|
||||
"ToastLibraryCreateSuccess": "Bibliotek \"{0}\" opprettet",
|
||||
"ToastLibraryDeleteFailed": "Misslykkes å slette bibliotek",
|
||||
@ -679,25 +919,83 @@
|
||||
"ToastLibraryScanFailedToStart": "Misslykkes å starte skann",
|
||||
"ToastLibraryScanStarted": "Bibliotek skann startet",
|
||||
"ToastLibraryUpdateSuccess": "Bibliotek \"{0}\" oppdatert",
|
||||
"ToastMatchAllAuthorsFailed": "Kunne ikke finne match for alle forfattere",
|
||||
"ToastMetadataFilesRemovedError": "Feil ved fjerning av metadata.{0}-filer",
|
||||
"ToastMetadataFilesRemovedNoneFound": "Ingen metata.{0}-filer funnet i biblioteket",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "Ingen metata.{0}-filer fjernet",
|
||||
"ToastMetadataFilesRemovedSuccess": "{0} metata.{1}-filer fjernet",
|
||||
"ToastMustHaveAtLeastOnePath": "Påkrevd med minst én mappe",
|
||||
"ToastNameEmailRequired": "Navn og e-post påkrevd",
|
||||
"ToastNameRequired": "Navn er påkrevd",
|
||||
"ToastNewEpisodesFound": "{0} nye episoder funnet",
|
||||
"ToastNewUserCreatedFailed": "Kunne ikke opprette konto: \"{0}\"",
|
||||
"ToastNewUserCreatedSuccess": "Ny konto opprettet",
|
||||
"ToastNewUserLibraryError": "Velg minst ett bibliotek",
|
||||
"ToastNewUserPasswordError": "Passord kreves. Kun root-bruker kan ha blankt passord",
|
||||
"ToastNewUserTagError": "Velg minst en tag",
|
||||
"ToastNewUserUsernameError": "Skriv inn brukernavn",
|
||||
"ToastNoNewEpisodesFound": "Ingen nye episoder funnet",
|
||||
"ToastNoUpdatesNecessary": "Ingen oppdateringer nødvendig",
|
||||
"ToastNotificationCreateFailed": "Kunne ikke opprette varsling",
|
||||
"ToastNotificationDeleteFailed": "Kunne ikke slette varsling",
|
||||
"ToastNotificationFailedMaximum": "Maksimalt antall forsøk som feiler må være større eller lik null (0)",
|
||||
"ToastNotificationQueueMaximum": "Maksimal størrelse på varsel-kø må være større eller lik null (0)",
|
||||
"ToastNotificationSettingsUpdateSuccess": "Innstillinger for varsling oppdatert",
|
||||
"ToastNotificationTestTriggerFailed": "Kunne ikke utløse test-varsel",
|
||||
"ToastNotificationTestTriggerSuccess": "Test-varsel utløst",
|
||||
"ToastNotificationUpdateSuccess": "Varsel oppdatert",
|
||||
"ToastPlaylistCreateFailed": "Misslykkes å opprette spilleliste",
|
||||
"ToastPlaylistCreateSuccess": "Spilleliste opprettet",
|
||||
"ToastPlaylistRemoveSuccess": "Spilleliste fjernet",
|
||||
"ToastPlaylistUpdateSuccess": "Spilleliste oppdatert",
|
||||
"ToastPodcastCreateFailed": "Misslykkes å opprette podcast",
|
||||
"ToastPodcastCreateSuccess": "Podcast opprettet",
|
||||
"ToastPodcastGetFeedFailed": "Kunne ikke hente podcast-feed",
|
||||
"ToastPodcastNoEpisodesInFeed": "Ingen episoder funnet i RSS-feed",
|
||||
"ToastPodcastNoRssFeed": "Podcast har ingen RSS-feed",
|
||||
"ToastProgressIsNotBeingSynced": "Progresjon synkroniserer ikke, start avspilling på nytt",
|
||||
"ToastProviderCreatedFailed": "Kunne ikke legge til tilbyder",
|
||||
"ToastProviderCreatedSuccess": "Ny tilbyder lagt til",
|
||||
"ToastProviderNameAndUrlRequired": "Navn og URL er påkrevd",
|
||||
"ToastProviderRemoveSuccess": "Tilbyder fjernet",
|
||||
"ToastRSSFeedCloseFailed": "Misslykkes å lukke RSS feed",
|
||||
"ToastRSSFeedCloseSuccess": "RSS feed lukket",
|
||||
"ToastRemoveFailed": "Kunne ikke fjerne",
|
||||
"ToastRemoveItemFromCollectionFailed": "Misslykkes å fjerne gjenstsand fra samling",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Gjenstand fjernet fra samling",
|
||||
"ToastRemoveItemsWithIssuesFailed": "Kunne ikke fjerne bibliotek-elementer med feil",
|
||||
"ToastRemoveItemsWithIssuesSuccess": "Fjernet bibliotek-elementer med feil",
|
||||
"ToastRenameFailed": "Kunne ikke endre navn",
|
||||
"ToastRescanFailed": "Ny skanning feilet for {0}",
|
||||
"ToastRescanRemoved": "Ny skanning utført og element fjernet",
|
||||
"ToastRescanUpToDate": "Ny skanning utført og element var oppdatert",
|
||||
"ToastRescanUpdated": "Ny skanning utført og element oppdatert",
|
||||
"ToastScanFailed": "Kunne ikke skanne bibliotek-element",
|
||||
"ToastSelectAtLeastOneUser": "Velg minst én bruker",
|
||||
"ToastSendEbookToDeviceFailed": "Misslykkes å sende ebok",
|
||||
"ToastSendEbookToDeviceSuccess": "Ebok sendt til \"{0}\"",
|
||||
"ToastSeriesUpdateFailed": "Misslykkes å oppdatere serie",
|
||||
"ToastSeriesUpdateSuccess": "Serie oppdatert",
|
||||
"ToastServerSettingsUpdateSuccess": "Server-innstillinger oppdatert",
|
||||
"ToastSessionCloseFailed": "Kunne ikke avslutte sesjon",
|
||||
"ToastSessionDeleteFailed": "Misslykkes å slette sesjon",
|
||||
"ToastSessionDeleteSuccess": "Sesjon slettet",
|
||||
"ToastSleepTimerDone": "Søvn-timer ferdig... zZzzZz",
|
||||
"ToastSlugMustChange": "Slug inneholder ugyldige tegn",
|
||||
"ToastSlugRequired": "Slug påkrevd",
|
||||
"ToastSocketConnected": "Socket koblet til",
|
||||
"ToastSocketDisconnected": "Socket koblet fra",
|
||||
"ToastSocketFailedToConnect": "Misslykkes å koble til Socket",
|
||||
"ToastSortingPrefixesEmptyError": "Må ha minst én sorteringsprefiks",
|
||||
"ToastSortingPrefixesUpdateSuccess": "Sorteringsprefiks oppdatert ({0} element)",
|
||||
"ToastTitleRequired": "Tittel påkrevd",
|
||||
"ToastUnknownError": "Ukjent feil",
|
||||
"ToastUnlinkOpenIdFailed": "Kunne ikke koble bruker fra OpenID",
|
||||
"ToastUnlinkOpenIdSuccess": "Bruker koblet fra OpenID",
|
||||
"ToastUserDeleteFailed": "Misslykkes å slette bruker",
|
||||
"ToastUserDeleteSuccess": "Bruker slettet"
|
||||
"ToastUserDeleteSuccess": "Bruker slettet",
|
||||
"ToastUserPasswordChangeSuccess": "Passord ble endret",
|
||||
"ToastUserPasswordMismatch": "Passord må stemme overens",
|
||||
"ToastUserPasswordMustChange": "Nytt passord kan ikke være identisk med gammelt passord",
|
||||
"ToastUserRootRequireName": "Root-brukernavn er påkrevd"
|
||||
}
|
||||
|
@ -657,7 +657,6 @@
|
||||
"MessageInsertChapterBelow": "Wstaw rozdział poniżej",
|
||||
"MessageItemsSelected": "{0} zaznaczone elementy",
|
||||
"MessageJoinUsOn": "Dołącz do nas na",
|
||||
"MessageListeningSessionsInTheLastYear": "Sesje słuchania w ostatnim roku: {0}",
|
||||
"MessageLoading": "Ładowanie...",
|
||||
"MessageLoadingFolders": "Ładowanie folderów...",
|
||||
"MessageLogsDescription": "Logi zapisane są w <code>/metadata/logs</code> jako pliki JSON. Logi awaryjne są zapisane w <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -772,7 +771,6 @@
|
||||
"ToastBookmarkCreateSuccess": "Dodano zakładkę",
|
||||
"ToastBookmarkRemoveSuccess": "Zakładka została usunięta",
|
||||
"ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę",
|
||||
"ToastCollectionItemsRemoveSuccess": "Przedmiot(y) zostały usunięte z kolekcji",
|
||||
"ToastCollectionRemoveSuccess": "Kolekcja usunięta",
|
||||
"ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję",
|
||||
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
|
||||
|
@ -630,7 +630,6 @@
|
||||
"MessageItemsSelected": "{0} Itens Selecionados",
|
||||
"MessageItemsUpdated": "{0} Itens Atualizados",
|
||||
"MessageJoinUsOn": "Junte-se a nós",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} sessões de escuta no ano anterior",
|
||||
"MessageLoading": "Carregando...",
|
||||
"MessageLoadingFolders": "Carregando pastas...",
|
||||
"MessageLogsDescription": "Os logs estão armazenados em <code>/metadata/logs</code> como arquivos JSON. Logs de crash estão armazenados em <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -735,7 +734,6 @@
|
||||
"ToastCachePurgeSuccess": "Cache apagado com sucesso",
|
||||
"ToastChaptersHaveErrors": "Capítulos com erro",
|
||||
"ToastChaptersMustHaveTitles": "Capítulos precisam ter títulos",
|
||||
"ToastCollectionItemsRemoveSuccess": "Item(ns) removidos da coleção",
|
||||
"ToastCollectionRemoveSuccess": "Coleção removida",
|
||||
"ToastCollectionUpdateSuccess": "Coleção atualizada",
|
||||
"ToastDeleteFileFailed": "Falha ao apagar arquivo",
|
||||
|
@ -300,6 +300,7 @@
|
||||
"LabelDiscover": "Не начато",
|
||||
"LabelDownload": "Скачать",
|
||||
"LabelDownloadNEpisodes": "Скачать {0} эпизодов",
|
||||
"LabelDownloadable": "Загружаемый",
|
||||
"LabelDuration": "Длина",
|
||||
"LabelDurationComparisonExactMatch": "(точное совпадение)",
|
||||
"LabelDurationComparisonLonger": "({0} дольше)",
|
||||
@ -588,6 +589,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента",
|
||||
"LabelSettingsTimeFormat": "Формат времени",
|
||||
"LabelShare": "Поделиться",
|
||||
"LabelShareDownloadableHelp": "Позволяет пользователям с помощью ссылки загрузить zip-файл элемента библиотеки.",
|
||||
"LabelShareOpen": "Общедоступно",
|
||||
"LabelShareURL": "Общедоступный URL",
|
||||
"LabelShowAll": "Показать все",
|
||||
@ -771,7 +773,6 @@
|
||||
"MessageItemsSelected": "{0} Элементов выделено",
|
||||
"MessageItemsUpdated": "{0} Элементов обновлено",
|
||||
"MessageJoinUsOn": "Присоединяйтесь к нам в",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} сеансов прослушивания в прошлом году",
|
||||
"MessageLoading": "Загрузка...",
|
||||
"MessageLoadingFolders": "Загрузка каталогов...",
|
||||
"MessageLogsDescription": "Журналы хранятся в <code>/metadata/logs</code> в виде JSON-файлов. Журналы сбоев хранятся в <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -959,8 +960,6 @@
|
||||
"ToastChaptersRemoved": "Удалены главы",
|
||||
"ToastChaptersUpdated": "Обновленные главы",
|
||||
"ToastCollectionItemsAddFailed": "Не удалось добавить элемент(ы) в коллекцию",
|
||||
"ToastCollectionItemsAddSuccess": "Элемент(ы) добавлены в коллекцию",
|
||||
"ToastCollectionItemsRemoveSuccess": "Элемент(ы), удалены из коллекции",
|
||||
"ToastCollectionRemoveSuccess": "Коллекция удалена",
|
||||
"ToastCollectionUpdateSuccess": "Коллекция обновлена",
|
||||
"ToastCoverUpdateFailed": "Не удалось обновить обложку",
|
||||
|
@ -300,6 +300,7 @@
|
||||
"LabelDiscover": "Odkrij",
|
||||
"LabelDownload": "Prenos",
|
||||
"LabelDownloadNEpisodes": "Prenesi {0} epizod",
|
||||
"LabelDownloadable": "Možen prenos",
|
||||
"LabelDuration": "Trajanje",
|
||||
"LabelDurationComparisonExactMatch": "(natančno ujemanje)",
|
||||
"LabelDurationComparisonLonger": "({0} dlje)",
|
||||
@ -588,6 +589,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Datoteke z metapodatki so privzeto shranjene v /metadata/items, če omogočite to nastavitev, boste datoteke z metapodatki shranili v mape elementov vaše knjižnice",
|
||||
"LabelSettingsTimeFormat": "Oblika časa",
|
||||
"LabelShare": "Deli",
|
||||
"LabelShareDownloadableHelp": "Omogoča uporabnikom s povezavo skupne rabe, da prenesejo zip datoteko elementa knjižnice.",
|
||||
"LabelShareOpen": "Deli odprto",
|
||||
"LabelShareURL": "Deli URL",
|
||||
"LabelShowAll": "Prikaži vse",
|
||||
@ -771,7 +773,6 @@
|
||||
"MessageItemsSelected": "{0} izbranih elementov",
|
||||
"MessageItemsUpdated": "Št. posodobljenih elementov: {0}",
|
||||
"MessageJoinUsOn": "Pridružite se nam",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} sej poslušanja v zadnjem letu",
|
||||
"MessageLoading": "Nalagam...",
|
||||
"MessageLoadingFolders": "Nalagam mape...",
|
||||
"MessageLogsDescription": "Dnevniki so shranjeni v <code>/metadata/logs</code> kot datoteke JSON. Dnevniki zrušitev so shranjeni v <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -959,8 +960,6 @@
|
||||
"ToastChaptersRemoved": "Poglavja so odstranjena",
|
||||
"ToastChaptersUpdated": "Poglavja so posodobljena",
|
||||
"ToastCollectionItemsAddFailed": "Dodajanje elementov v zbirko ni uspelo",
|
||||
"ToastCollectionItemsAddSuccess": "Dodajanje elementov v zbirko je bilo uspešno",
|
||||
"ToastCollectionItemsRemoveSuccess": "Elementi so bili odstranjeni iz zbirke",
|
||||
"ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena",
|
||||
"ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena",
|
||||
"ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela",
|
||||
|
@ -13,7 +13,7 @@
|
||||
"ButtonBrowseForFolder": "Bläddra efter mapp",
|
||||
"ButtonCancel": "Avbryt",
|
||||
"ButtonCancelEncode": "Avbryt kodning",
|
||||
"ButtonChangeRootPassword": "Ändra rootlösenord",
|
||||
"ButtonChangeRootPassword": "Ändra lösenordet för root",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Kontrollera och ladda ner nya avsnitt",
|
||||
"ButtonChooseAFolder": "Välj en mapp",
|
||||
"ButtonChooseFiles": "Välj filer",
|
||||
@ -29,7 +29,7 @@
|
||||
"ButtonEditChapters": "Redigera kapitel",
|
||||
"ButtonEditPodcast": "Redigera podcast",
|
||||
"ButtonForceReScan": "Tvinga omstart",
|
||||
"ButtonFullPath": "Full sökväg",
|
||||
"ButtonFullPath": "Fullständig sökväg",
|
||||
"ButtonHide": "Dölj",
|
||||
"ButtonHome": "Hem",
|
||||
"ButtonIssues": "Problem",
|
||||
@ -42,13 +42,18 @@
|
||||
"ButtonMatchAllAuthors": "Matcha alla författare",
|
||||
"ButtonMatchBooks": "Matcha böcker",
|
||||
"ButtonNevermind": "Glöm det",
|
||||
"ButtonOk": "Okej",
|
||||
"ButtonNext": "Nästa",
|
||||
"ButtonNextChapter": "Nästa kapitel",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonOpenFeed": "Öppna flöde",
|
||||
"ButtonOpenManager": "Öppna Manager",
|
||||
"ButtonPause": "Pausa",
|
||||
"ButtonPlay": "Spela",
|
||||
"ButtonPlayAll": "Spela alla",
|
||||
"ButtonPlaying": "Spelar",
|
||||
"ButtonPlaylists": "Spellistor",
|
||||
"ButtonPrevious": "Föregående",
|
||||
"ButtonPreviousChapter": "Föregående kapitel",
|
||||
"ButtonPurgeAllCache": "Rensa all cache",
|
||||
"ButtonPurgeItemsCache": "Rensa föremåls-cache",
|
||||
"ButtonQueueAddItem": "Lägg till i kön",
|
||||
@ -56,6 +61,9 @@
|
||||
"ButtonQuickMatch": "Snabb matchning",
|
||||
"ButtonReScan": "Omstart",
|
||||
"ButtonRead": "Läs",
|
||||
"ButtonReadLess": "Visa mindre",
|
||||
"ButtonReadMore": "Visa mer",
|
||||
"ButtonRefresh": "Uppdatera",
|
||||
"ButtonRemove": "Ta bort",
|
||||
"ButtonRemoveAll": "Ta bort alla",
|
||||
"ButtonRemoveAllLibraryItems": "Ta bort alla biblioteksobjekt",
|
||||
@ -72,12 +80,13 @@
|
||||
"ButtonScanLibrary": "Skanna bibliotek",
|
||||
"ButtonSearch": "Sök",
|
||||
"ButtonSelectFolderPath": "Välj mappens sökväg",
|
||||
"ButtonSeries": "Serie",
|
||||
"ButtonSeries": "Serier",
|
||||
"ButtonSetChaptersFromTracks": "Ställ in kapitel från spår",
|
||||
"ButtonShiftTimes": "Förskjut tider",
|
||||
"ButtonShow": "Visa",
|
||||
"ButtonStartM4BEncode": "Starta M4B-kodning",
|
||||
"ButtonStartMetadataEmbed": "Starta inbäddning av metadata",
|
||||
"ButtonStats": "Statistik",
|
||||
"ButtonSubmit": "Skicka",
|
||||
"ButtonTest": "Testa",
|
||||
"ButtonUpload": "Ladda upp",
|
||||
@ -123,7 +132,7 @@
|
||||
"HeaderListeningStats": "Lyssningsstatistik",
|
||||
"HeaderLogin": "Logga in",
|
||||
"HeaderLogs": "Loggar",
|
||||
"HeaderManageGenres": "Hantera genrer",
|
||||
"HeaderManageGenres": "Hantera kategorier",
|
||||
"HeaderManageTags": "Hantera taggar",
|
||||
"HeaderMapDetails": "Karta detaljer",
|
||||
"HeaderMatch": "Matcha",
|
||||
@ -154,13 +163,14 @@
|
||||
"HeaderSettingsExperimental": "Experimentella funktioner",
|
||||
"HeaderSettingsGeneral": "Allmänt",
|
||||
"HeaderSettingsScanner": "Skanner",
|
||||
"HeaderSettingsWebClient": "Webklient",
|
||||
"HeaderSleepTimer": "Sovtidtagare",
|
||||
"HeaderStatsLargestItems": "Största föremål",
|
||||
"HeaderStatsLongestItems": "Längsta föremål (tim)",
|
||||
"HeaderStatsLargestItems": "Största objekt",
|
||||
"HeaderStatsLongestItems": "Längsta objekt (tim)",
|
||||
"HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagar)",
|
||||
"HeaderStatsRecentSessions": "Senaste sessioner",
|
||||
"HeaderStatsTop10Authors": "Topp 10 författare",
|
||||
"HeaderStatsTop5Genres": "Topp 5 genrer",
|
||||
"HeaderStatsTop10Authors": "10 populäraste författarna",
|
||||
"HeaderStatsTop5Genres": "5 populäraste kategorierna",
|
||||
"HeaderTableOfContents": "Innehållsförteckning",
|
||||
"HeaderTools": "Verktyg",
|
||||
"HeaderUpdateAccount": "Uppdatera konto",
|
||||
@ -168,7 +178,8 @@
|
||||
"HeaderUpdateDetails": "Uppdatera detaljer",
|
||||
"HeaderUpdateLibrary": "Uppdatera bibliotek",
|
||||
"HeaderUsers": "Användare",
|
||||
"HeaderYourStats": "Dina statistik",
|
||||
"HeaderYearReview": "Sammanställning för {0}",
|
||||
"HeaderYourStats": "Din statistik",
|
||||
"LabelAbridged": "Förkortad",
|
||||
"LabelAccountType": "Kontotyp",
|
||||
"LabelAccountTypeGuest": "Gäst",
|
||||
@ -191,18 +202,23 @@
|
||||
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
|
||||
"LabelAuthors": "Författare",
|
||||
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
|
||||
"LabelAutoFetchMetadata": "Automatisk nedladdning av metadata",
|
||||
"LabelAutoFetchMetadataHelp": "Hämtar metadata för titel, författare och serier. Kompletterande metadata får adderas efter uppladdningen.",
|
||||
"LabelBackToUser": "Tillbaka till användaren",
|
||||
"LabelBackupLocation": "Säkerhetskopia Plats",
|
||||
"LabelBackupLocation": "Plats för säkerhetskopia",
|
||||
"LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i /metadata/säkerhetskopior",
|
||||
"LabelBackupsMaxBackupSize": "Maximal säkerhetskopiostorlek (i GB)",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i \"/metadata/backups\"",
|
||||
"LabelBackupsMaxBackupSize": "Maximal storlek på säkerhetskopia (i GB) (0 = obegränsad)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot felkonfiguration kommer säkerhetskopior att misslyckas om de överskrider den konfigurerade storleken.",
|
||||
"LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla",
|
||||
"LabelBackupsNumberToKeepHelp": "Endast en säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än detta bör du ta bort dem manuellt.",
|
||||
"LabelBitrate": "Bitfrekvens",
|
||||
"LabelBooks": "Böcker",
|
||||
"LabelButtonText": "Knapptext",
|
||||
"LabelByAuthor": "av {0}",
|
||||
"LabelChangePassword": "Ändra lösenord",
|
||||
"LabelChannels": "Kanaler",
|
||||
"LabelChapterCount": "{0} kapitel",
|
||||
"LabelChapterTitle": "Kapitelrubrik",
|
||||
"LabelChapters": "Kapitel",
|
||||
"LabelChaptersFound": "hittade kapitel",
|
||||
@ -215,7 +231,7 @@
|
||||
"LabelConfirmPassword": "Bekräfta lösenord",
|
||||
"LabelContinueListening": "Fortsätt Lyssna",
|
||||
"LabelContinueReading": "Fortsätt Läsa",
|
||||
"LabelContinueSeries": "Forsätt Serie",
|
||||
"LabelContinueSeries": "Fortsätt Serie",
|
||||
"LabelCover": "Omslag",
|
||||
"LabelCoverImageURL": "URL till omslagsbild",
|
||||
"LabelCreatedAt": "Skapad vid",
|
||||
@ -267,8 +283,8 @@
|
||||
"LabelFontBoldness": "Fetstil",
|
||||
"LabelFontFamily": "Teckensnittsfamilj",
|
||||
"LabelFontScale": "Teckensnittsskala",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genrer",
|
||||
"LabelGenre": "Kategori",
|
||||
"LabelGenres": "Kategorier",
|
||||
"LabelHardDeleteFile": "Hård radering av fil",
|
||||
"LabelHasEbook": "Har E-bok",
|
||||
"LabelHasSupplementaryEbook": "Har komplimenterande E-bok",
|
||||
@ -316,19 +332,19 @@
|
||||
"LabelMediaType": "Mediatyp",
|
||||
"LabelMetaTag": "Metamärke",
|
||||
"LabelMetaTags": "Metamärken",
|
||||
"LabelMetadataProvider": "Metadataleverantör",
|
||||
"LabelMetadataProvider": "Källa för metadata",
|
||||
"LabelMinute": "Minut",
|
||||
"LabelMissing": "Saknad",
|
||||
"LabelMore": "Mer",
|
||||
"LabelMoreInfo": "Mer information",
|
||||
"LabelName": "Namn",
|
||||
"LabelNarrator": "Berättare",
|
||||
"LabelNarrators": "Berättare",
|
||||
"LabelNarrator": "Uppläsare",
|
||||
"LabelNarrators": "Uppläsare",
|
||||
"LabelNew": "Ny",
|
||||
"LabelNewPassword": "Nytt lösenord",
|
||||
"LabelNewestAuthors": "Senast tillagda författare",
|
||||
"LabelNewestEpisodes": "Senast tillagda avsnitt",
|
||||
"LabelNextBackupDate": "Nästa säkerhetskopia datum",
|
||||
"LabelNextBackupDate": "Nästa datum för säkerhetskopia",
|
||||
"LabelNextScheduledRun": "Nästa schemalagda körning",
|
||||
"LabelNoEpisodesSelected": "Inga avsnitt valda",
|
||||
"LabelNotFinished": "Ej avslutad",
|
||||
@ -367,7 +383,7 @@
|
||||
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer",
|
||||
"LabelPrimaryEbook": "Primär e-bok",
|
||||
"LabelProgress": "Framsteg",
|
||||
"LabelProvider": "Leverantör",
|
||||
"LabelProvider": "Källa",
|
||||
"LabelPubDate": "Publiceringsdatum",
|
||||
"LabelPublishYear": "Publiceringsår",
|
||||
"LabelPublisher": "Utgivare",
|
||||
@ -388,14 +404,14 @@
|
||||
"LabelRemoveCover": "Ta bort omslag",
|
||||
"LabelSearchTerm": "Sökterm",
|
||||
"LabelSearchTitle": "Sök titel",
|
||||
"LabelSearchTitleOrASIN": "Sök titel eller ASIN",
|
||||
"LabelSearchTitleOrASIN": "Sök titel eller ASIN-kod",
|
||||
"LabelSeason": "Säsong",
|
||||
"LabelSelectAllEpisodes": "Välj alla avsnitt",
|
||||
"LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas",
|
||||
"LabelSelectUsers": "Välj användare",
|
||||
"LabelSendEbookToDevice": "Skicka e-bok till...",
|
||||
"LabelSequence": "Sekvens",
|
||||
"LabelSeries": "Serie",
|
||||
"LabelSeries": "Serier",
|
||||
"LabelSeriesName": "Serienamn",
|
||||
"LabelSeriesProgress": "Serieframsteg",
|
||||
"LabelSetEbookAsPrimary": "Ange som primär",
|
||||
@ -403,7 +419,7 @@
|
||||
"LabelSettingsAudiobooksOnly": "Endast ljudböcker",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Aktivera detta alternativ kommer att ignorera e-boksfiler om de inte finns inom en ljudboksmapp, i vilket fall de kommer att anges som kompletterande e-böcker",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorfisk design med trähyllor",
|
||||
"LabelSettingsChromecastSupport": "Chromecast-stöd",
|
||||
"LabelSettingsChromecastSupport": "Stöd för Chromecast",
|
||||
"LabelSettingsDateFormat": "Datumformat",
|
||||
"LabelSettingsDisableWatcher": "Inaktivera Watcher",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Inaktivera mappbevakning för bibliotek",
|
||||
@ -415,24 +431,24 @@
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.",
|
||||
"LabelSettingsFindCovers": "Hitta omslag",
|
||||
"LabelSettingsFindCoversHelp": "Om din ljudbok inte har ett inbäddat omslag eller en omslagsbild i mappen kommer skannern att försöka hitta ett omslag.<br>Observera: Detta kommer att förlänga skannningstiden",
|
||||
"LabelSettingsHideSingleBookSeries": "Dölj enboksserier",
|
||||
"LabelSettingsHideSingleBookSeries": "Dölj serier med en bok",
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Serier som har en enda bok kommer att döljas från seriesidan och hyllsidan på startsidan.",
|
||||
"LabelSettingsHomePageBookshelfView": "Startsida använd bokhyllvy",
|
||||
"LabelSettingsLibraryBookshelfView": "Bibliotek använd bokhyllvy",
|
||||
"LabelSettingsParseSubtitles": "Analysera undertexter",
|
||||
"LabelSettingsParseSubtitlesHelp": "Extrahera undertexter från mappnamn för ljudböcker.<br>Undertext måste vara åtskilda av \" - \"<br>t.ex. \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"",
|
||||
"LabelSettingsParseSubtitlesHelp": "Extrahera undertitlar från namnet på mappar för ljudböcker.<br>Undertiteln måste vara åtskilda med ett bindestreck \" - \".<br>Mappen \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Föredra matchad metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN-kod",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker med ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering",
|
||||
"LabelSettingsSortingIgnorePrefixesHelp": "t.ex. för prefixet \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"",
|
||||
"LabelSettingsSortingIgnorePrefixesHelp": "För prefix som t.ex. \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"",
|
||||
"LabelSettingsSquareBookCovers": "Använd fyrkantiga bokomslag",
|
||||
"LabelSettingsSquareBookCoversHelp": "Föredrar att använda fyrkantiga omslag över standard 1.6:1 bokomslag",
|
||||
"LabelSettingsStoreCoversWithItem": "Lagra omslag med objekt",
|
||||
"LabelSettingsStoreCoversWithItemHelp": "Som standard lagras omslag i /metadata/items, att aktivera detta alternativ kommer att lagra omslag i din biblioteksmapp. Endast en fil med namnet \"cover\" kommer att behållas",
|
||||
"LabelSettingsStoreCoversWithItemHelp": "Som standard lagras bokomslag i mappen /metadata/items. Genom att aktivera detta alternativ kommer omslagen att lagra i din biblioteksmapp. Endast en fil med namnet \"cover\" kommer att behållas",
|
||||
"LabelSettingsStoreMetadataWithItem": "Lagra metadata med objekt",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i /metadata/items, att aktivera detta alternativ kommer att lagra metadatafiler i dina biblioteksmappar",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen /metadata/items. Genom att aktivera detta alternativ kommer metadatafilerna att lagras i dina biblioteksmappar",
|
||||
"LabelSettingsTimeFormat": "Tidsformat",
|
||||
"LabelShowAll": "Visa alla",
|
||||
"LabelSize": "Storlek",
|
||||
@ -457,7 +473,7 @@
|
||||
"LabelStatsOverallHours": "Totalt antal timmar",
|
||||
"LabelStatsWeekListening": "Veckans lyssnande",
|
||||
"LabelSubtitle": "Underrubrik",
|
||||
"LabelSupportedFileTypes": "Stödda filtyper",
|
||||
"LabelSupportedFileTypes": "Filtyper som accepteras",
|
||||
"LabelTag": "Tagg",
|
||||
"LabelTags": "Taggar",
|
||||
"LabelTagsAccessibleToUser": "Taggar tillgängliga för användaren",
|
||||
@ -467,17 +483,22 @@
|
||||
"LabelThemeDark": "Mörkt",
|
||||
"LabelThemeLight": "Ljust",
|
||||
"LabelTimeBase": "Tidsbas",
|
||||
"LabelTimeDurationXHours": "{0} timmar",
|
||||
"LabelTimeDurationXMinutes": "{0} minuter",
|
||||
"LabelTimeDurationXSeconds": "{0} sekunder",
|
||||
"LabelTimeInMinutes": "Tid i minuter",
|
||||
"LabelTimeLeft": "{0} återstår",
|
||||
"LabelTimeListened": "Tid lyssnad",
|
||||
"LabelTimeListenedToday": "Tid lyssnad idag",
|
||||
"LabelTimeRemaining": "{0} kvar",
|
||||
"LabelTimeRemaining": "{0} återstår",
|
||||
"LabelTimeToShift": "Tid att skifta i sekunder",
|
||||
"LabelTitle": "Titel",
|
||||
"LabelToolsEmbedMetadata": "Bädda in metadata",
|
||||
"LabelToolsEmbedMetadataDescription": "Bädda in metadata i ljudfiler, inklusive omslagsbild och kapitel.",
|
||||
"LabelToolsMakeM4b": "Skapa M4B ljudbok",
|
||||
"LabelToolsMakeM4bDescription": "Skapa en .M4B ljudboksfil med inbäddad metadata, omslagsbild och kapitel.",
|
||||
"LabelToolsSplitM4b": "Dela M4B till MP3-filer",
|
||||
"LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.",
|
||||
"LabelToolsSplitM4b": "Dela upp M4B-fil i MP3-filer",
|
||||
"LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B-fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.",
|
||||
"LabelTotalDuration": "Total varaktighet",
|
||||
"LabelTotalTimeListened": "Total tid lyssnad",
|
||||
"LabelTrackFromFilename": "Spår från filnamn",
|
||||
@ -486,6 +507,7 @@
|
||||
"LabelTracksMultiTrack": "Flerspårigt",
|
||||
"LabelTracksNone": "Inga spår",
|
||||
"LabelTracksSingleTrack": "Enspårigt",
|
||||
"LabelTrailer": "Trailer",
|
||||
"LabelType": "Typ",
|
||||
"LabelUnabridged": "Oavkortad",
|
||||
"LabelUnknown": "Okänd",
|
||||
@ -496,16 +518,20 @@
|
||||
"LabelUpdatedAt": "Uppdaterad vid",
|
||||
"LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar",
|
||||
"LabelUploaderDropFiles": "Släpp filer",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Hämtar automatiskt titel, författare och serier.",
|
||||
"LabelUseChapterTrack": "Använd kapitelspår",
|
||||
"LabelUseFullTrack": "Använd hela spåret",
|
||||
"LabelUser": "Användare",
|
||||
"LabelUsername": "Användarnamn",
|
||||
"LabelValue": "Värde",
|
||||
"LabelVersion": "Version",
|
||||
"LabelViewBookmarks": "Visa bokmärken",
|
||||
"LabelViewChapters": "Visa kapitel",
|
||||
"LabelViewQueue": "Visa spellista",
|
||||
"LabelVolume": "Volym",
|
||||
"LabelWeekdaysToRun": "Vardagar att köra",
|
||||
"LabelYearReviewHide": "Dölj sammanställning för året",
|
||||
"LabelYearReviewShow": "Visa sammanställning för året",
|
||||
"LabelYourAudiobookDuration": "Din ljudboks varaktighet",
|
||||
"LabelYourBookmarks": "Dina bokmärken",
|
||||
"LabelYourPlaylists": "Dina spellistor",
|
||||
@ -535,22 +561,22 @@
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Är du säker på att du vill markera alla avsnitt som inte avslutade?",
|
||||
"MessageConfirmMarkSeriesFinished": "Är du säker på att du vill markera alla böcker i denna serie som avslutade?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som inte avslutade?",
|
||||
"MessageConfirmQuickEmbed": "Varning! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som ej avslutade?",
|
||||
"MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?",
|
||||
"MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra omgenomsökning för {0} objekt?",
|
||||
"MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?",
|
||||
"MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?",
|
||||
"MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort berättaren \"{0}\"?",
|
||||
"MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort uppläsaren \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på genren \"{0}\" till \"{1}\" för alla objekt?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Observera: Den här genren finns redan, så de kommer att slås samman.",
|
||||
"MessageConfirmRenameGenreWarning": "Varning! En liknande genre med annat skrivsätt finns redan \"{0}\".",
|
||||
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategori \"{0}\" till \"{1}\" för alla objekt?",
|
||||
"MessageConfirmRenameGenreMergeNote": "OBS: Den här kategorin finns redan, så de kommer att slås samman.",
|
||||
"MessageConfirmRenameGenreWarning": "Varning! En liknande kategori med annat skrivsätt finns redan \"{0}\".",
|
||||
"MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?",
|
||||
"MessageConfirmRenameTagMergeNote": "Observera: Den här taggen finns redan, så de kommer att slås samman.",
|
||||
"MessageConfirmRenameTagWarning": "Varning! En liknande tagg med annat skrivsätt finns redan \"{0}\".",
|
||||
"MessageConfirmRenameTagMergeNote": "OBS: Den här taggen finns redan, så de kommer att slås samman.",
|
||||
"MessageConfirmRenameTagWarning": "VARNING! En liknande tagg med annat skrivsätt finns redan \"{0}\".",
|
||||
"MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?",
|
||||
"MessageDownloadingEpisode": "Laddar ner avsnitt",
|
||||
"MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning",
|
||||
@ -564,7 +590,6 @@
|
||||
"MessageItemsSelected": "{0} Objekt markerade",
|
||||
"MessageItemsUpdated": "{0} Objekt uppdaterade",
|
||||
"MessageJoinUsOn": "Anslut dig till oss på",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} lyssningssessioner det senaste året",
|
||||
"MessageLoading": "Laddar...",
|
||||
"MessageLoadingFolders": "Laddar mappar...",
|
||||
"MessageM4BFailed": "M4B misslyckades!",
|
||||
@ -574,7 +599,7 @@
|
||||
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som inte avslutade",
|
||||
"MessageMarkAsFinished": "Markera som avslutad",
|
||||
"MessageMarkAsNotFinished": "Markera som inte avslutad",
|
||||
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda sökleverantören och fylla i tomma detaljer och omslagskonst. Överskriver inte detaljer.",
|
||||
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda källan och fylla i uppgifter som saknas och bokomslag. Inga befintliga uppgifter kommer att ersättas.",
|
||||
"MessageNoAudioTracks": "Inga ljudspår",
|
||||
"MessageNoAuthors": "Inga författare",
|
||||
"MessageNoBackups": "Inga säkerhetskopior",
|
||||
@ -588,7 +613,7 @@
|
||||
"MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades",
|
||||
"MessageNoEpisodes": "Inga avsnitt",
|
||||
"MessageNoFoldersAvailable": "Inga mappar tillgängliga",
|
||||
"MessageNoGenres": "Inga genrer",
|
||||
"MessageNoGenres": "Inga kategorier",
|
||||
"MessageNoIssues": "Inga problem",
|
||||
"MessageNoItems": "Inga objekt",
|
||||
"MessageNoItemsFound": "Inga objekt hittades",
|
||||
@ -637,7 +662,7 @@
|
||||
"NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Mappar med mediefiler hanteras som separata biblioteksobjekt.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Mappar med flera mediefiler hanteras som separata objekt i biblioteket.",
|
||||
"NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.",
|
||||
"NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.",
|
||||
"PlaceholderNewCollection": "Nytt samlingsnamn",
|
||||
@ -645,29 +670,42 @@
|
||||
"PlaceholderNewPlaylist": "Nytt spellistanamn",
|
||||
"PlaceholderSearch": "Sök...",
|
||||
"PlaceholderSearchEpisode": "Sök avsnitt...",
|
||||
"StatsTopAuthor": "POPULÄRAST FÖRFATTAREN",
|
||||
"StatsTopAuthors": "POPULÄRASTE FÖRFATTARNA",
|
||||
"StatsTopGenre": "Populäraste kategorin",
|
||||
"StatsTopGenres": "Populäraste kategorierna",
|
||||
"StatsTopMonth": "Bästa månaden",
|
||||
"StatsTopNarrator": "Populärast uppläsarna",
|
||||
"StatsTopNarrators": "Populäraste uppläsaren",
|
||||
"StatsYearInReview": "SAMMANSTÄLLNING AV ÅRET",
|
||||
"ToastAccountUpdateSuccess": "Kontot uppdaterat",
|
||||
"ToastAsinRequired": "En ASIN-kod krävs",
|
||||
"ToastAuthorImageRemoveSuccess": "Författarens bild borttagen",
|
||||
"ToastAuthorNotFound": "Författaren \"{0}\" kunde inte identifieras",
|
||||
"ToastAuthorRemoveSuccess": "Författaren har raderats",
|
||||
"ToastAuthorSearchNotFound": "Författaren kunde inte identifieras",
|
||||
"ToastAuthorUpdateMerged": "Författaren sammanslagen",
|
||||
"ToastAuthorUpdateSuccess": "Författaren uppdaterad",
|
||||
"ToastAuthorUpdateSuccessNoImageFound": "Författaren uppdaterad (ingen bild hittad)",
|
||||
"ToastBackupCreateFailed": "Det gick inte att skapa en säkerhetskopia",
|
||||
"ToastBackupCreateSuccess": "Säkerhetskopia skapad",
|
||||
"ToastBackupDeleteFailed": "Det gick inte att ta bort säkerhetskopian",
|
||||
"ToastBackupDeleteSuccess": "Säkerhetskopan borttagen",
|
||||
"ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopan",
|
||||
"ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopan",
|
||||
"ToastBackupUploadSuccess": "Säkerhetskopan uppladdad",
|
||||
"ToastBackupCreateSuccess": "Säkerhetskopian har skapats",
|
||||
"ToastBackupDeleteFailed": "Det gick inte att radera säkerhetskopian",
|
||||
"ToastBackupDeleteSuccess": "Säkerhetskopian har raderats",
|
||||
"ToastBackupInvalidMaxKeep": "Felaktigt antal kopior av backup har angivits",
|
||||
"ToastBackupInvalidMaxSize": "Felaktig storlek på backup har angivits",
|
||||
"ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopian",
|
||||
"ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopian",
|
||||
"ToastBackupUploadSuccess": "Säkerhetskopian uppladdad",
|
||||
"ToastBatchUpdateFailed": "Batchuppdateringen misslyckades",
|
||||
"ToastBatchUpdateSuccess": "Batchuppdateringen lyckades",
|
||||
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
|
||||
"ToastBookmarkCreateSuccess": "Bokmärket tillagt",
|
||||
"ToastBookmarkRemoveSuccess": "Bokmärket borttaget",
|
||||
"ToastBookmarkUpdateSuccess": "Bokmärket uppdaterat",
|
||||
"ToastBookmarkCreateSuccess": "Bokmärket har adderats",
|
||||
"ToastBookmarkRemoveSuccess": "Bokmärket har raderats",
|
||||
"ToastBookmarkUpdateSuccess": "Bokmärket har uppdaterats",
|
||||
"ToastChaptersHaveErrors": "Kapitlen har fel",
|
||||
"ToastChaptersMustHaveTitles": "Kapitel måste ha titlar",
|
||||
"ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen",
|
||||
"ToastCollectionRemoveSuccess": "Samlingen borttagen",
|
||||
"ToastCollectionUpdateSuccess": "Samlingen uppdaterad",
|
||||
"ToastCollectionRemoveSuccess": "Samlingen har raderats",
|
||||
"ToastCollectionUpdateSuccess": "Samlingen har uppdaterats",
|
||||
"ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat",
|
||||
"ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade",
|
||||
"ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig",
|
||||
@ -693,8 +731,8 @@
|
||||
"ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen",
|
||||
"ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten",
|
||||
"ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"",
|
||||
"ToastSeriesUpdateFailed": "Serieuppdateringen misslyckades",
|
||||
"ToastSeriesUpdateSuccess": "Serieuppdateringen lyckades",
|
||||
"ToastSeriesUpdateFailed": "Uppdateringen av serier misslyckades",
|
||||
"ToastSeriesUpdateSuccess": "Uppdateringen av serierna lyckades",
|
||||
"ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen",
|
||||
"ToastSessionDeleteSuccess": "Sessionen borttagen",
|
||||
"ToastSocketConnected": "Socket ansluten",
|
||||
|
@ -300,6 +300,7 @@
|
||||
"LabelDiscover": "Огляд",
|
||||
"LabelDownload": "Завантажити",
|
||||
"LabelDownloadNEpisodes": "Завантажити епізодів: {0}",
|
||||
"LabelDownloadable": "Можна завантажити",
|
||||
"LabelDuration": "Тривалість",
|
||||
"LabelDurationComparisonExactMatch": "(повний збіг)",
|
||||
"LabelDurationComparisonLonger": "(на {0} довше)",
|
||||
@ -588,6 +589,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "За замовчуванням файли метаданих зберігаються у /metadata/items. Цей параметр увімкне збереження метаданих у теці елемента бібліотеки",
|
||||
"LabelSettingsTimeFormat": "Формат часу",
|
||||
"LabelShare": "Поділитися",
|
||||
"LabelShareDownloadableHelp": "Дозволяє користувачам із посиланням для спільного доступу завантажувати zip-файл елемента бібліотеки.",
|
||||
"LabelShareOpen": "Поділитися відкрито",
|
||||
"LabelShareURL": "Поділитися URL",
|
||||
"LabelShowAll": "Показати все",
|
||||
@ -771,7 +773,6 @@
|
||||
"MessageItemsSelected": "Обрано елементів: {0}",
|
||||
"MessageItemsUpdated": "Оновлено елементів: {0}",
|
||||
"MessageJoinUsOn": "Приєднуйтесь до",
|
||||
"MessageListeningSessionsInTheLastYear": "Сесій прослуховування минулого року: {0}",
|
||||
"MessageLoading": "Завантаження...",
|
||||
"MessageLoadingFolders": "Завантаження тек...",
|
||||
"MessageLogsDescription": "Журнали зберігаються у <code>/metadata/logs</code> як JSON-файли. Журнали збоїв зберігаються у <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -959,8 +960,6 @@
|
||||
"ToastChaptersRemoved": "Розділи видалені",
|
||||
"ToastChaptersUpdated": "Розділи оновлені",
|
||||
"ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції",
|
||||
"ToastCollectionItemsAddSuccess": "Елемент(и) успішно додано до колекції",
|
||||
"ToastCollectionItemsRemoveSuccess": "Елемент(и) видалено з добірки",
|
||||
"ToastCollectionRemoveSuccess": "Добірку видалено",
|
||||
"ToastCollectionUpdateSuccess": "Добірку оновлено",
|
||||
"ToastCoverUpdateFailed": "Не вдалося оновити обкладинку",
|
||||
|
@ -581,7 +581,6 @@
|
||||
"MessageItemsSelected": "{0} Mục Đã Chọn",
|
||||
"MessageItemsUpdated": "{0} Mục Đã Cập Nhật",
|
||||
"MessageJoinUsOn": "Tham gia cùng chúng tôi trên",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} phiên nghe trong năm qua",
|
||||
"MessageLoading": "Đang tải...",
|
||||
"MessageLoadingFolders": "Đang tải các thư mục...",
|
||||
"MessageM4BFailed": "M4B thất bại!",
|
||||
@ -683,7 +682,6 @@
|
||||
"ToastBookmarkUpdateSuccess": "Đánh dấu đã được cập nhật",
|
||||
"ToastChaptersHaveErrors": "Các chương có lỗi",
|
||||
"ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề",
|
||||
"ToastCollectionItemsRemoveSuccess": "Mục đã được xóa khỏi bộ sưu tập",
|
||||
"ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa",
|
||||
"ToastCollectionUpdateSuccess": "Bộ sưu tập đã được cập nhật",
|
||||
"ToastItemCoverUpdateSuccess": "Ảnh bìa mục đã được cập nhật",
|
||||
|
@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "保存音轨列表",
|
||||
"ButtonScan": "扫描",
|
||||
"ButtonScanLibrary": "扫描库",
|
||||
"ButtonScrollLeft": "向左滚动",
|
||||
"ButtonScrollRight": "向右滚动",
|
||||
"ButtonSearch": "查找",
|
||||
"ButtonSelectFolderPath": "选择文件夹路径",
|
||||
"ButtonSeries": "系列",
|
||||
@ -190,6 +192,7 @@
|
||||
"HeaderSettingsExperimental": "实验功能",
|
||||
"HeaderSettingsGeneral": "通用",
|
||||
"HeaderSettingsScanner": "扫描",
|
||||
"HeaderSettingsWebClient": "网页客户端",
|
||||
"HeaderSleepTimer": "睡眠计时",
|
||||
"HeaderStatsLargestItems": "最大的项目",
|
||||
"HeaderStatsLongestItems": "项目时长(小时)",
|
||||
@ -542,6 +545,7 @@
|
||||
"LabelServerYearReview": "服务器年度回顾 ({0})",
|
||||
"LabelSetEbookAsPrimary": "设置为主",
|
||||
"LabelSetEbookAsSupplementary": "设置为补充",
|
||||
"LabelSettingsAllowIframe": "允许嵌入到 iframe 中",
|
||||
"LabelSettingsAudiobooksOnly": "只有有声读物",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "启用此设置将忽略电子书文件, 除非它们位于有声读物文件夹中, 在这种情况下, 它们将被设置为补充电子书",
|
||||
"LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计",
|
||||
@ -592,6 +596,8 @@
|
||||
"LabelSize": "文件大小",
|
||||
"LabelSleepTimer": "睡眠定时",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelSortAscending": "升序",
|
||||
"LabelSortDescending": "降序",
|
||||
"LabelStart": "开始",
|
||||
"LabelStartTime": "开始时间",
|
||||
"LabelStarted": "开始于",
|
||||
@ -765,7 +771,6 @@
|
||||
"MessageItemsSelected": "已选定 {0} 个项目",
|
||||
"MessageItemsUpdated": "已更新 {0} 个项目",
|
||||
"MessageJoinUsOn": "加入我们",
|
||||
"MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话",
|
||||
"MessageLoading": "正在加载...",
|
||||
"MessageLoadingFolders": "加载文件夹...",
|
||||
"MessageLogsDescription": "日志以 JSON 文件形式存储在 <code>/metadata/logs</code> 目录中. 崩溃日志存储在 <code>/metadata/logs/crash_logs.txt</code> 目录中.",
|
||||
@ -953,8 +958,6 @@
|
||||
"ToastChaptersRemoved": "已删除章节",
|
||||
"ToastChaptersUpdated": "章节已更新",
|
||||
"ToastCollectionItemsAddFailed": "项目添加到收藏夹失败",
|
||||
"ToastCollectionItemsAddSuccess": "项目添加到收藏夹成功",
|
||||
"ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除",
|
||||
"ToastCollectionRemoveSuccess": "收藏夹已删除",
|
||||
"ToastCollectionUpdateSuccess": "收藏夹已更新",
|
||||
"ToastCoverUpdateFailed": "封面更新失败",
|
||||
|
@ -625,7 +625,6 @@
|
||||
"MessageItemsSelected": "已選定 {0} 個項目",
|
||||
"MessageItemsUpdated": "已更新 {0} 個項目",
|
||||
"MessageJoinUsOn": "加入我們",
|
||||
"MessageListeningSessionsInTheLastYear": "去年收聽 {0} 個會話",
|
||||
"MessageLoading": "讀取...",
|
||||
"MessageLoadingFolders": "讀取資料夾...",
|
||||
"MessageM4BFailed": "M4B 失敗!",
|
||||
@ -727,7 +726,6 @@
|
||||
"ToastBookmarkUpdateSuccess": "書籤已更新",
|
||||
"ToastChaptersHaveErrors": "章節有錯誤",
|
||||
"ToastChaptersMustHaveTitles": "章節必須有標題",
|
||||
"ToastCollectionItemsRemoveSuccess": "項目從收藏夾移除",
|
||||
"ToastCollectionRemoveSuccess": "收藏夾已刪除",
|
||||
"ToastCollectionUpdateSuccess": "收藏夾已更新",
|
||||
"ToastItemCoverUpdateSuccess": "項目封面已更新",
|
||||
|
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.17.5",
|
||||
"version": "2.17.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.17.5",
|
||||
"version": "2.17.7",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.17.5",
|
||||
"version": "2.17.7",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
|
@ -401,32 +401,6 @@ class Database {
|
||||
return this.models.setting.updateSettingObj(settings.toJSON())
|
||||
}
|
||||
|
||||
updateBulkBooks(oldBooks) {
|
||||
if (!this.sequelize) return false
|
||||
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
|
||||
}
|
||||
|
||||
createBulkCollectionBooks(collectionBooks) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.collectionBook.bulkCreate(collectionBooks)
|
||||
}
|
||||
|
||||
createPlaylistMediaItem(playlistMediaItem) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.playlistMediaItem.create(playlistMediaItem)
|
||||
}
|
||||
|
||||
createBulkPlaylistMediaItems(playlistMediaItems) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
|
||||
}
|
||||
|
||||
async createLibraryItem(oldLibraryItem) {
|
||||
if (!this.sequelize) return false
|
||||
await oldLibraryItem.saveMetadata()
|
||||
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save metadata file and update library item
|
||||
*
|
||||
@ -444,17 +418,6 @@ class Database {
|
||||
return updated
|
||||
}
|
||||
|
||||
async createBulkBookAuthors(bookAuthors) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
||||
}
|
||||
|
||||
async removeBulkBookAuthors(authorId = null, bookId = null) {
|
||||
if (!this.sequelize) return false
|
||||
if (!authorId && !bookId) return
|
||||
await this.models.bookAuthor.removeByIds(authorId, bookId)
|
||||
}
|
||||
|
||||
getPlaybackSessions(where = null) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.playbackSession.getOldPlaybackSessions(where)
|
||||
@ -680,7 +643,7 @@ class Database {
|
||||
/**
|
||||
* Clean invalid records in database
|
||||
* Series should have atleast one Book
|
||||
* Book and Podcast must have an associated LibraryItem
|
||||
* Book and Podcast must have an associated LibraryItem (and vice versa)
|
||||
* Remove playback sessions that are 3 seconds or less
|
||||
*/
|
||||
async cleanDatabase() {
|
||||
@ -710,6 +673,49 @@ class Database {
|
||||
await book.destroy()
|
||||
}
|
||||
|
||||
// Remove invalid LibraryItem records
|
||||
const libraryItemsWithNoMedia = await this.libraryItemModel.findAll({
|
||||
include: [
|
||||
{
|
||||
model: this.bookModel,
|
||||
attributes: ['id']
|
||||
},
|
||||
{
|
||||
model: this.podcastModel,
|
||||
attributes: ['id']
|
||||
}
|
||||
],
|
||||
where: {
|
||||
'$book.id$': null,
|
||||
'$podcast.id$': null
|
||||
}
|
||||
})
|
||||
for (const libraryItem of libraryItemsWithNoMedia) {
|
||||
Logger.warn(`Found libraryItem "${libraryItem.id}" with no media - removing it`)
|
||||
await libraryItem.destroy()
|
||||
}
|
||||
|
||||
const playlistMediaItemsWithNoMediaItem = await this.playlistMediaItemModel.findAll({
|
||||
include: [
|
||||
{
|
||||
model: this.bookModel,
|
||||
attributes: ['id']
|
||||
},
|
||||
{
|
||||
model: this.podcastEpisodeModel,
|
||||
attributes: ['id']
|
||||
}
|
||||
],
|
||||
where: {
|
||||
'$book.id$': null,
|
||||
'$podcastEpisode.id$': null
|
||||
}
|
||||
})
|
||||
for (const playlistMediaItem of playlistMediaItemsWithNoMediaItem) {
|
||||
Logger.warn(`Found playlistMediaItem with no book or podcastEpisode - removing it`)
|
||||
await playlistMediaItem.destroy()
|
||||
}
|
||||
|
||||
// Remove empty series
|
||||
const emptySeries = await this.seriesModel.findAll({
|
||||
include: {
|
||||
|
@ -6,6 +6,7 @@ const util = require('util')
|
||||
const fs = require('./libs/fsExtra')
|
||||
const fileUpload = require('./libs/expressFileupload')
|
||||
const cookieParser = require('cookie-parser')
|
||||
const axios = require('axios')
|
||||
|
||||
const { version } = require('../package.json')
|
||||
|
||||
@ -54,7 +55,26 @@ class Server {
|
||||
global.XAccel = process.env.USE_X_ACCEL
|
||||
global.AllowCors = process.env.ALLOW_CORS === '1'
|
||||
|
||||
if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') {
|
||||
if (process.env.EXP_PROXY_SUPPORT === '1') {
|
||||
// https://github.com/advplyr/audiobookshelf/pull/3754
|
||||
Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`)
|
||||
global.DisableSsrfRequestFilter = () => true
|
||||
|
||||
axios.defaults.maxRedirects = 0
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if ([301, 302].includes(error.response?.status)) {
|
||||
return axios({
|
||||
...error.config,
|
||||
url: error.response.headers.location
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
} else if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') {
|
||||
Logger.info(`[Server] SSRF Request Filter Disabled`)
|
||||
global.DisableSsrfRequestFilter = () => true
|
||||
} else if (process.env.SSRF_REQUEST_FILTER_WHITELIST?.length) {
|
||||
|
@ -44,16 +44,21 @@ class AuthorController {
|
||||
|
||||
// Used on author landing page to include library items and items grouped in series
|
||||
if (include.includes('items')) {
|
||||
authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
|
||||
const libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
|
||||
|
||||
if (include.includes('series')) {
|
||||
const seriesMap = {}
|
||||
// Group items into series
|
||||
authorJson.libraryItems.forEach((li) => {
|
||||
if (li.media.metadata.series) {
|
||||
li.media.metadata.series.forEach((series) => {
|
||||
const itemWithSeries = li.toJSONMinified()
|
||||
itemWithSeries.media.metadata.series = series
|
||||
libraryItems.forEach((li) => {
|
||||
if (li.media.series?.length) {
|
||||
li.media.series.forEach((series) => {
|
||||
const itemWithSeries = li.toOldJSONMinified()
|
||||
itemWithSeries.media.metadata.series = {
|
||||
id: series.id,
|
||||
name: series.name,
|
||||
nameIgnorePrefix: series.nameIgnorePrefix,
|
||||
sequence: series.bookSeries.sequence
|
||||
}
|
||||
|
||||
if (seriesMap[series.id]) {
|
||||
seriesMap[series.id].items.push(itemWithSeries)
|
||||
@ -76,7 +81,7 @@ class AuthorController {
|
||||
}
|
||||
|
||||
// Minify library items
|
||||
authorJson.libraryItems = authorJson.libraryItems.map((li) => li.toJSONMinified())
|
||||
authorJson.libraryItems = libraryItems.map((li) => li.toOldJSONMinified())
|
||||
}
|
||||
|
||||
return res.json(authorJson)
|
||||
@ -125,7 +130,7 @@ class AuthorController {
|
||||
const bookAuthorsToCreate = []
|
||||
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
|
||||
|
||||
const oldLibraryItems = []
|
||||
const libraryItems = []
|
||||
allItemsWithAuthor.forEach((libraryItem) => {
|
||||
// Replace old author with merging author for each book
|
||||
libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)
|
||||
@ -134,23 +139,22 @@ class AuthorController {
|
||||
name: existingAuthor.name
|
||||
})
|
||||
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
oldLibraryItems.push(oldLibraryItem)
|
||||
libraryItems.push(libraryItem)
|
||||
|
||||
bookAuthorsToCreate.push({
|
||||
bookId: libraryItem.media.id,
|
||||
authorId: existingAuthor.id
|
||||
})
|
||||
})
|
||||
if (oldLibraryItems.length) {
|
||||
await Database.removeBulkBookAuthors(req.author.id) // Remove all old BookAuthor
|
||||
await Database.createBulkBookAuthors(bookAuthorsToCreate) // Create all new BookAuthor
|
||||
for (const libraryItem of allItemsWithAuthor) {
|
||||
if (libraryItems.length) {
|
||||
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
|
||||
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor
|
||||
for (const libraryItem of libraryItems) {
|
||||
await libraryItem.saveMetadataFile()
|
||||
}
|
||||
SocketAuthority.emitter(
|
||||
'items_updated',
|
||||
oldLibraryItems.map((li) => li.toJSONExpanded())
|
||||
libraryItems.map((li) => li.toOldJSONExpanded())
|
||||
)
|
||||
}
|
||||
|
||||
@ -190,7 +194,7 @@ class AuthorController {
|
||||
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
|
||||
|
||||
numBooksForAuthor = allItemsWithAuthor.length
|
||||
const oldLibraryItems = []
|
||||
const libraryItems = []
|
||||
// Update author name on all books
|
||||
for (const libraryItem of allItemsWithAuthor) {
|
||||
libraryItem.media.authors = libraryItem.media.authors.map((au) => {
|
||||
@ -199,16 +203,16 @@ class AuthorController {
|
||||
}
|
||||
return au
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
oldLibraryItems.push(oldLibraryItem)
|
||||
|
||||
libraryItems.push(libraryItem)
|
||||
|
||||
await libraryItem.saveMetadataFile()
|
||||
}
|
||||
|
||||
if (oldLibraryItems.length) {
|
||||
if (libraryItems.length) {
|
||||
SocketAuthority.emitter(
|
||||
'items_updated',
|
||||
oldLibraryItems.map((li) => li.toJSONExpanded())
|
||||
libraryItems.map((li) => li.toOldJSONExpanded())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
@ -5,13 +5,17 @@ const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const Collection = require('../objects/Collection')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/Collection')} collection
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} CollectionControllerRequest
|
||||
*/
|
||||
|
||||
class CollectionController {
|
||||
@ -25,36 +29,71 @@ class CollectionController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
const newCollection = new Collection()
|
||||
req.body.userId = req.user.id
|
||||
if (!newCollection.setData(req.body)) {
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// Validation
|
||||
if (!reqBody.name || !reqBody.libraryId) {
|
||||
return res.status(400).send('Invalid collection data')
|
||||
}
|
||||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid collection description')
|
||||
}
|
||||
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid collection data. No books')
|
||||
}
|
||||
|
||||
// Create collection record
|
||||
await Database.collectionModel.createFromOld(newCollection)
|
||||
|
||||
// Get library items in collection
|
||||
const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection)
|
||||
|
||||
// Create collectionBook records
|
||||
let order = 1
|
||||
const collectionBooksToAdd = []
|
||||
for (const libraryItemId of newCollection.books) {
|
||||
const libraryItem = libraryItemsInCollection.find((li) => li.id === libraryItemId)
|
||||
if (libraryItem) {
|
||||
collectionBooksToAdd.push({
|
||||
collectionId: newCollection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
order: order++
|
||||
})
|
||||
// Load library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: libraryItemIds,
|
||||
libraryId: reqBody.libraryId,
|
||||
mediaType: 'book'
|
||||
}
|
||||
}
|
||||
if (collectionBooksToAdd.length) {
|
||||
await Database.createBulkCollectionBooks(collectionBooksToAdd)
|
||||
})
|
||||
if (libraryItems.length !== libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid collection data. Invalid books')
|
||||
}
|
||||
|
||||
const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection)
|
||||
/** @type {import('../models/Collection')} */
|
||||
let newCollection = null
|
||||
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
// Create collection
|
||||
newCollection = await Database.collectionModel.create(
|
||||
{
|
||||
libraryId: reqBody.libraryId,
|
||||
name: reqBody.name,
|
||||
description: reqBody.description || null
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
// Create collectionBooks
|
||||
const collectionBookPayloads = libraryItemIds.map((llid, index) => {
|
||||
const libraryItem = libraryItems.find((li) => li.id === llid)
|
||||
return {
|
||||
collectionId: newCollection.id,
|
||||
bookId: libraryItem.mediaId,
|
||||
order: index + 1
|
||||
}
|
||||
})
|
||||
await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction })
|
||||
|
||||
await transaction.commit()
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
Logger.error('[CollectionController] create:', error)
|
||||
return res.status(500).send('Failed to create collection')
|
||||
}
|
||||
|
||||
// Load books expanded
|
||||
newCollection.books = await newCollection.getBooksExpandedWithLibraryItem()
|
||||
|
||||
// Note: The old collection model stores expanded libraryItems in the books property
|
||||
const jsonExpanded = newCollection.toOldJSONExpanded()
|
||||
SocketAuthority.emitter('collection_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
@ -75,7 +114,7 @@ class CollectionController {
|
||||
/**
|
||||
* GET: /api/collections/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
@ -94,7 +133,7 @@ class CollectionController {
|
||||
* PATCH: /api/collections/:id
|
||||
* Update collection
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
@ -158,7 +197,7 @@ class CollectionController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
@ -178,11 +217,13 @@ class CollectionController {
|
||||
* Add a single book to a collection
|
||||
* Req.body { id: <library item id> }
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async addBook(req, res) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
|
||||
const libraryItem = await Database.libraryItemModel.findByPk(req.body.id, {
|
||||
attributes: ['libraryId', 'mediaId']
|
||||
})
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Book not found')
|
||||
}
|
||||
@ -192,14 +233,14 @@ class CollectionController {
|
||||
|
||||
// Check if book is already in collection
|
||||
const collectionBooks = await req.collection.getCollectionBooks()
|
||||
if (collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) {
|
||||
if (collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
|
||||
return res.status(400).send('Book already in collection')
|
||||
}
|
||||
|
||||
// Create collectionBook record
|
||||
await Database.collectionBookModel.create({
|
||||
collectionId: req.collection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
bookId: libraryItem.mediaId,
|
||||
order: collectionBooks.length + 1
|
||||
})
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
@ -212,11 +253,13 @@ class CollectionController {
|
||||
* Remove a single book from a collection. Re-order books
|
||||
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeBook(req, res) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
|
||||
const libraryItem = await Database.libraryItemModel.findByPk(req.params.bookId, {
|
||||
attributes: ['mediaId']
|
||||
})
|
||||
if (!libraryItem) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
@ -227,7 +270,7 @@ class CollectionController {
|
||||
})
|
||||
|
||||
let jsonExpanded = null
|
||||
const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.media.id)
|
||||
const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.mediaId)
|
||||
if (collectionBookToRemove) {
|
||||
// Remove collection book record
|
||||
await collectionBookToRemove.destroy()
|
||||
@ -235,7 +278,7 @@ class CollectionController {
|
||||
// Update order on collection books
|
||||
let order = 1
|
||||
for (const collectionBook of collectionBooks) {
|
||||
if (collectionBook.bookId === libraryItem.media.id) continue
|
||||
if (collectionBook.bookId === libraryItem.mediaId) continue
|
||||
if (collectionBook.order !== order) {
|
||||
await collectionBook.update({
|
||||
order
|
||||
@ -257,29 +300,31 @@ class CollectionController {
|
||||
* Add multiple books to collection
|
||||
* Req.body { books: <Array of library item ids> }
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async addBatch(req, res) {
|
||||
// filter out invalid libraryItemIds
|
||||
const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string')
|
||||
if (!bookIdsToAdd.length) {
|
||||
return res.status(500).send('Invalid request body')
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Get library items associated with ids
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.in]: bookIdsToAdd
|
||||
}
|
||||
},
|
||||
include: {
|
||||
model: Database.bookModel
|
||||
id: bookIdsToAdd,
|
||||
libraryId: req.collection.libraryId,
|
||||
mediaType: 'book'
|
||||
}
|
||||
})
|
||||
if (!libraryItems.length) {
|
||||
return res.status(400).send('Invalid request body. No valid books')
|
||||
}
|
||||
|
||||
// Get collection books already in collection
|
||||
/** @type {import('../models/CollectionBook')[]} */
|
||||
const collectionBooks = await req.collection.getCollectionBooks()
|
||||
|
||||
let order = collectionBooks.length + 1
|
||||
@ -288,10 +333,10 @@ class CollectionController {
|
||||
|
||||
// Check and set new collection books to add
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (!collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) {
|
||||
if (!collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
|
||||
collectionBooksToAdd.push({
|
||||
collectionId: req.collection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
bookId: libraryItem.mediaId,
|
||||
order: order++
|
||||
})
|
||||
hasUpdated = true
|
||||
@ -302,7 +347,8 @@ class CollectionController {
|
||||
|
||||
let jsonExpanded = null
|
||||
if (hasUpdated) {
|
||||
await Database.createBulkCollectionBooks(collectionBooksToAdd)
|
||||
await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)
|
||||
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
} else {
|
||||
@ -316,7 +362,7 @@ class CollectionController {
|
||||
* Remove multiple books from collection
|
||||
* Req.body { books: <Array of library item ids> }
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeBatch(req, res) {
|
||||
@ -329,9 +375,7 @@ class CollectionController {
|
||||
// Get library items associated with ids
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.in]: bookIdsToRemove
|
||||
}
|
||||
id: bookIdsToRemove
|
||||
},
|
||||
include: {
|
||||
model: Database.bookModel
|
||||
@ -339,6 +383,7 @@ class CollectionController {
|
||||
})
|
||||
|
||||
// Get collection books already in collection
|
||||
/** @type {import('../models/CollectionBook')[]} */
|
||||
const collectionBooks = await req.collection.getCollectionBooks({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
|
@ -106,7 +106,7 @@ class EmailController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(req.body.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
}
|
||||
|
@ -1145,14 +1145,14 @@ class LibraryController {
|
||||
await libraryItem.media.update({
|
||||
narrators: libraryItem.media.narrators
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
itemsUpdated.push(oldLibraryItem)
|
||||
|
||||
itemsUpdated.push(libraryItem)
|
||||
}
|
||||
|
||||
if (itemsUpdated.length) {
|
||||
SocketAuthority.emitter(
|
||||
'items_updated',
|
||||
itemsUpdated.map((li) => li.toJSONExpanded())
|
||||
itemsUpdated.map((li) => li.toOldJSONExpanded())
|
||||
)
|
||||
}
|
||||
|
||||
@ -1189,14 +1189,14 @@ class LibraryController {
|
||||
await libraryItem.media.update({
|
||||
narrators: libraryItem.media.narrators
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
itemsUpdated.push(oldLibraryItem)
|
||||
|
||||
itemsUpdated.push(libraryItem)
|
||||
}
|
||||
|
||||
if (itemsUpdated.length) {
|
||||
SocketAuthority.emitter(
|
||||
'items_updated',
|
||||
itemsUpdated.map((li) => li.toJSONExpanded())
|
||||
itemsUpdated.map((li) => li.toOldJSONExpanded())
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -24,6 +24,16 @@ const ShareManager = require('../managers/ShareManager')
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/LibraryItem')} libraryItem
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} LibraryItemControllerRequest
|
||||
*
|
||||
* @typedef RequestLibraryFileObject
|
||||
* @property {import('../objects/files/LibraryFile')} libraryFile
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
|
||||
*/
|
||||
|
||||
class LibraryItemController {
|
||||
@ -35,17 +45,17 @@ class LibraryItemController {
|
||||
* ?include=progress,rssfeed,downloads,share
|
||||
* ?expanded=1
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
const includeEntities = (req.query.include || '').split(',')
|
||||
if (req.query.expanded == 1) {
|
||||
var item = req.libraryItem.toJSONExpanded()
|
||||
const item = req.libraryItem.toOldJSONExpanded()
|
||||
|
||||
// Include users media progress
|
||||
if (includeEntities.includes('progress')) {
|
||||
var episodeId = req.query.episode || null
|
||||
const episodeId = req.query.episode || null
|
||||
item.userMediaProgress = req.user.getOldMediaProgress(item.id, episodeId)
|
||||
}
|
||||
|
||||
@ -68,28 +78,32 @@ class LibraryItemController {
|
||||
|
||||
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
|
||||
*/
|
||||
async update(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
// Item has cover and update is removing cover so purge it from cache
|
||||
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
if (req.libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
|
||||
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) {
|
||||
Logger.debug(`[LibraryItemController] Updated now saving`)
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
await Database.updateLibraryItem(oldLibraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
}
|
||||
res.json(libraryItem.toJSON())
|
||||
res.json(oldLibraryItem.toJSON())
|
||||
}
|
||||
|
||||
/**
|
||||
@ -100,7 +114,7 @@ class LibraryItemController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
@ -111,14 +125,14 @@ class LibraryItemController {
|
||||
const authorIds = []
|
||||
const seriesIds = []
|
||||
if (req.libraryItem.isPodcast) {
|
||||
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
|
||||
mediaItemIds.push(...req.libraryItem.media.podcastEpisodes.map((ep) => ep.id))
|
||||
} else {
|
||||
mediaItemIds.push(req.libraryItem.media.id)
|
||||
if (req.libraryItem.media.metadata.authors?.length) {
|
||||
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id))
|
||||
if (req.libraryItem.media.authors?.length) {
|
||||
authorIds.push(...req.libraryItem.media.authors.map((au) => au.id))
|
||||
}
|
||||
if (req.libraryItem.media.metadata.series?.length) {
|
||||
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
|
||||
if (req.libraryItem.media.series?.length) {
|
||||
seriesIds.push(...req.libraryItem.media.series.map((se) => se.id))
|
||||
}
|
||||
}
|
||||
|
||||
@ -155,7 +169,7 @@ class LibraryItemController {
|
||||
* GET: /api/items/:id/download
|
||||
* Download library item. Zip file if multiple files.
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async download(req, res) {
|
||||
@ -164,7 +178,7 @@ class LibraryItemController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const libraryItemPath = req.libraryItem.path
|
||||
const itemTitle = req.libraryItem.media.metadata.title
|
||||
const itemTitle = req.libraryItem.media.title
|
||||
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
|
||||
|
||||
@ -194,11 +208,10 @@ class LibraryItemController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateMedia(req, res) {
|
||||
const libraryItem = req.libraryItem
|
||||
const mediaPayload = req.body
|
||||
|
||||
if (mediaPayload.url) {
|
||||
@ -207,44 +220,44 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
// Book specific
|
||||
if (libraryItem.isBook) {
|
||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||
if (req.libraryItem.isBook) {
|
||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, req.libraryItem.libraryId)
|
||||
}
|
||||
|
||||
// Podcast specific
|
||||
let isPodcastAutoDownloadUpdated = false
|
||||
if (libraryItem.isPodcast) {
|
||||
if (mediaPayload.autoDownloadEpisodes !== undefined && libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
|
||||
if (req.libraryItem.isPodcast) {
|
||||
if (mediaPayload.autoDownloadEpisodes !== undefined && req.libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
|
||||
isPodcastAutoDownloadUpdated = true
|
||||
} else if (mediaPayload.autoDownloadSchedule !== undefined && libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
|
||||
} else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
|
||||
isPodcastAutoDownloadUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
// Book specific - Get all series being removed from this item
|
||||
let seriesRemoved = []
|
||||
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
||||
if (req.libraryItem.isBook && mediaPayload.metadata?.series) {
|
||||
const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || []
|
||||
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
seriesRemoved = req.libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
}
|
||||
|
||||
let authorsRemoved = []
|
||||
if (libraryItem.isBook && mediaPayload.metadata?.authors) {
|
||||
if (req.libraryItem.isBook && mediaPayload.metadata?.authors) {
|
||||
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
|
||||
authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
|
||||
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) {
|
||||
libraryItem.updatedAt = Date.now()
|
||||
req.libraryItem.changed('updatedAt', true)
|
||||
await req.libraryItem.save()
|
||||
|
||||
if (isPodcastAutoDownloadUpdated) {
|
||||
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
||||
this.cronManager.checkUpdatePodcastCron(req.libraryItem)
|
||||
}
|
||||
|
||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
|
||||
if (authorsRemoved.length) {
|
||||
// Check remove empty authors
|
||||
@ -259,14 +272,14 @@ class LibraryItemController {
|
||||
}
|
||||
res.json({
|
||||
updated: hasUpdates,
|
||||
libraryItem
|
||||
libraryItem: req.libraryItem.toOldJSON()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/items/:id/cover
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
* @param {boolean} [updateAndReturnJson=true]
|
||||
*/
|
||||
@ -276,15 +289,13 @@ class LibraryItemController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
let libraryItem = req.libraryItem
|
||||
|
||||
let result = null
|
||||
if (req.body?.url) {
|
||||
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
|
||||
result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url)
|
||||
result = await CoverManager.downloadCoverFromUrlNew(req.body.url, req.libraryItem.id, req.libraryItem.isFile ? null : req.libraryItem.path)
|
||||
} else if (req.files?.cover) {
|
||||
Logger.debug(`[LibraryItemController] Handling uploaded cover`)
|
||||
result = await CoverManager.uploadCover(libraryItem, req.files.cover)
|
||||
result = await CoverManager.uploadCover(req.libraryItem, req.files.cover)
|
||||
} else {
|
||||
return res.status(400).send('Invalid request no file or url')
|
||||
}
|
||||
@ -296,8 +307,15 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
if (updateAndReturnJson) {
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
req.libraryItem.media.coverPath = result.cover
|
||||
req.libraryItem.media.changed('coverPath', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
// client uses updatedAt timestamp in URL to force refresh cover
|
||||
req.libraryItem.changed('updatedAt', true)
|
||||
await req.libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
res.json({
|
||||
success: true,
|
||||
cover: result.cover
|
||||
@ -308,22 +326,28 @@ class LibraryItemController {
|
||||
/**
|
||||
* PATCH: /api/items/:id/cover
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateCover(req, res) {
|
||||
const libraryItem = req.libraryItem
|
||||
if (!req.body.cover) {
|
||||
return res.status(400).send('Invalid request no cover path')
|
||||
}
|
||||
|
||||
const validationResult = await CoverManager.validateCoverPath(req.body.cover, libraryItem)
|
||||
const validationResult = await CoverManager.validateCoverPath(req.body.cover, req.libraryItem)
|
||||
if (validationResult.error) {
|
||||
return res.status(500).send(validationResult.error)
|
||||
}
|
||||
if (validationResult.updated) {
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
req.libraryItem.media.coverPath = validationResult.cover
|
||||
req.libraryItem.media.changed('coverPath', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
// client uses updatedAt timestamp in URL to force refresh cover
|
||||
req.libraryItem.changed('updatedAt', true)
|
||||
await req.libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
@ -334,17 +358,22 @@ class LibraryItemController {
|
||||
/**
|
||||
* DELETE: /api/items/:id/cover
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeCover(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
if (req.libraryItem.media.coverPath) {
|
||||
req.libraryItem.media.coverPath = null
|
||||
req.libraryItem.media.changed('coverPath', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
if (libraryItem.media.coverPath) {
|
||||
libraryItem.updateMediaCover('')
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
// client uses updatedAt timestamp in URL to force refresh cover
|
||||
req.libraryItem.changed('updatedAt', true)
|
||||
await req.libraryItem.save()
|
||||
|
||||
await CacheManager.purgeCoverCache(req.libraryItem.id)
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
@ -353,7 +382,7 @@ class LibraryItemController {
|
||||
/**
|
||||
* GET: /api/items/:id/cover
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getCover(req, res) {
|
||||
@ -395,11 +424,11 @@ class LibraryItemController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
startPlaybackSession(req, res) {
|
||||
if (!req.libraryItem.media.numTracks) {
|
||||
if (!req.libraryItem.hasAudioTracks) {
|
||||
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
@ -412,18 +441,18 @@ class LibraryItemController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
startEpisodePlaybackSession(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
if (!libraryItem.media.numTracks) {
|
||||
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
if (!req.libraryItem.isPodcast) {
|
||||
Logger.error(`[LibraryItemController] startEpisodePlaybackSession invalid media type ${req.libraryItem.id}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
var episodeId = req.params.episodeId
|
||||
if (!libraryItem.media.episodes.find((ep) => ep.id === episodeId)) {
|
||||
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||
|
||||
const episodeId = req.params.episodeId
|
||||
if (!req.libraryItem.media.podcastEpisodes.some((ep) => ep.id === episodeId)) {
|
||||
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${req.libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
@ -433,30 +462,55 @@ class LibraryItemController {
|
||||
/**
|
||||
* PATCH: /api/items/:id/tracks
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateTracks(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
var orderedFileData = req.body.orderedFileData
|
||||
if (!libraryItem.media.updateAudioTracks) {
|
||||
Logger.error(`[LibraryItemController] updateTracks invalid media type ${libraryItem.id}`)
|
||||
return res.sendStatus(500)
|
||||
const orderedFileData = req.body?.orderedFileData
|
||||
|
||||
if (!req.libraryItem.isBook) {
|
||||
Logger.error(`[LibraryItemController] updateTracks invalid media type ${req.libraryItem.id}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
libraryItem.media.updateAudioTracks(orderedFileData)
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
res.json(libraryItem.toJSON())
|
||||
if (!Array.isArray(orderedFileData) || !orderedFileData.length) {
|
||||
Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
// Ensure that each orderedFileData has a valid ino and is in the book audioFiles
|
||||
if (orderedFileData.some((fileData) => !fileData?.ino || !req.libraryItem.media.audioFiles.some((af) => af.ino === fileData.ino))) {
|
||||
Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
let index = 1
|
||||
const updatedAudioFiles = orderedFileData.map((fileData) => {
|
||||
const audioFile = req.libraryItem.media.audioFiles.find((af) => af.ino === fileData.ino)
|
||||
audioFile.manuallyVerified = true
|
||||
audioFile.exclude = !!fileData.exclude
|
||||
if (audioFile.exclude) {
|
||||
audioFile.index = -1
|
||||
} else {
|
||||
audioFile.index = index++
|
||||
}
|
||||
return audioFile
|
||||
})
|
||||
updatedAudioFiles.sort((a, b) => a.index - b.index)
|
||||
|
||||
req.libraryItem.media.audioFiles = updatedAudioFiles
|
||||
req.libraryItem.media.changed('audioFiles', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
res.json(req.libraryItem.toOldJSON())
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/items/:id/match
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async match(req, res) {
|
||||
const libraryItem = req.libraryItem
|
||||
const reqBody = req.body || {}
|
||||
|
||||
const options = {}
|
||||
@ -473,7 +527,8 @@ class LibraryItemController {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -496,11 +551,11 @@ class LibraryItemController {
|
||||
const hardDelete = req.query.hard == 1 // Delete files from filesystem
|
||||
|
||||
const { libraryItemIds } = req.body
|
||||
if (!libraryItemIds?.length) {
|
||||
if (!libraryItemIds?.length || !Array.isArray(libraryItemIds)) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
const itemsToDelete = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: libraryItemIds
|
||||
})
|
||||
|
||||
@ -511,19 +566,19 @@ class LibraryItemController {
|
||||
const libraryId = itemsToDelete[0].libraryId
|
||||
for (const libraryItem of itemsToDelete) {
|
||||
const libraryItemPath = libraryItem.path
|
||||
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
|
||||
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
|
||||
const mediaItemIds = []
|
||||
const seriesIds = []
|
||||
const authorIds = []
|
||||
if (libraryItem.isPodcast) {
|
||||
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
|
||||
mediaItemIds.push(...libraryItem.media.podcastEpisodes.map((ep) => ep.id))
|
||||
} else {
|
||||
mediaItemIds.push(libraryItem.media.id)
|
||||
if (libraryItem.media.metadata.series?.length) {
|
||||
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id))
|
||||
if (libraryItem.media.series?.length) {
|
||||
seriesIds.push(...libraryItem.media.series.map((se) => se.id))
|
||||
}
|
||||
if (libraryItem.media.metadata.authors?.length) {
|
||||
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
|
||||
if (libraryItem.media.authors?.length) {
|
||||
authorIds.push(...libraryItem.media.authors.map((au) => au.id))
|
||||
}
|
||||
}
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||
@ -568,7 +623,7 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
// Get all library items to update
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: libraryItemIds
|
||||
})
|
||||
if (updatePayloads.length !== libraryItems.length) {
|
||||
@ -590,21 +645,23 @@ class LibraryItemController {
|
||||
if (libraryItem.isBook) {
|
||||
if (Array.isArray(mediaPayload.metadata?.series)) {
|
||||
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
|
||||
const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
const seriesRemoved = libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
|
||||
}
|
||||
if (Array.isArray(mediaPayload.metadata?.authors)) {
|
||||
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
|
||||
const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
|
||||
const authorsRemoved = libraryItem.media.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
|
||||
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItem.media.update(mediaPayload)) {
|
||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||
const hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
|
||||
if (hasUpdates) {
|
||||
libraryItem.changed('updatedAt', true)
|
||||
await libraryItem.save()
|
||||
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
Logger.debug(`[LibraryItemController] Updated library item media "${libraryItem.media.title}"`)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
itemsUpdated++
|
||||
}
|
||||
}
|
||||
@ -633,11 +690,11 @@ class LibraryItemController {
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(403).send('Invalid payload')
|
||||
}
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: libraryItemIds
|
||||
})
|
||||
res.json({
|
||||
libraryItems: libraryItems.map((li) => li.toJSONExpanded())
|
||||
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
|
||||
})
|
||||
}
|
||||
|
||||
@ -660,7 +717,7 @@ class LibraryItemController {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: req.body.libraryItemIds
|
||||
})
|
||||
if (!libraryItems?.length) {
|
||||
@ -682,7 +739,8 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
for (const libraryItem of libraryItems) {
|
||||
const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
const matchResult = await Scanner.quickMatchLibraryItem(this, oldLibraryItem, options)
|
||||
if (matchResult.updated) {
|
||||
itemsUpdated++
|
||||
} else if (matchResult.warning) {
|
||||
@ -741,7 +799,7 @@ class LibraryItemController {
|
||||
/**
|
||||
* POST: /api/items/:id/scan
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async scan(req, res) {
|
||||
@ -765,7 +823,7 @@ class LibraryItemController {
|
||||
/**
|
||||
* GET: /api/items/:id/metadata-object
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
getMetadataObject(req, res) {
|
||||
@ -774,7 +832,7 @@ class LibraryItemController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) {
|
||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
@ -785,7 +843,7 @@ class LibraryItemController {
|
||||
/**
|
||||
* POST: /api/items/:id/chapters
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateMediaChapters(req, res) {
|
||||
@ -794,26 +852,51 @@ class LibraryItemController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.hasAudioTracks) {
|
||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
if (!req.body.chapters) {
|
||||
if (!Array.isArray(req.body.chapters) || req.body.chapters.some((c) => !c.title || typeof c.title !== 'string' || c.start === undefined || typeof c.start !== 'number' || c.end === undefined || typeof c.end !== 'number')) {
|
||||
Logger.error(`[LibraryItemController] Invalid payload`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const chapters = req.body.chapters || []
|
||||
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
|
||||
if (wasUpdated) {
|
||||
await Database.updateLibraryItem(req.libraryItem)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
|
||||
let hasUpdates = false
|
||||
if (chapters.length !== req.libraryItem.media.chapters.length) {
|
||||
req.libraryItem.media.chapters = chapters.map((c, index) => {
|
||||
return {
|
||||
id: index,
|
||||
title: c.title,
|
||||
start: c.start,
|
||||
end: c.end
|
||||
}
|
||||
})
|
||||
hasUpdates = true
|
||||
} else {
|
||||
for (const [index, chapter] of chapters.entries()) {
|
||||
const currentChapter = req.libraryItem.media.chapters[index]
|
||||
if (currentChapter.title !== chapter.title || currentChapter.start !== chapter.start || currentChapter.end !== chapter.end) {
|
||||
currentChapter.title = chapter.title
|
||||
currentChapter.start = chapter.start
|
||||
currentChapter.end = chapter.end
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
req.libraryItem.media.changed('chapters', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
updated: wasUpdated
|
||||
updated: hasUpdates
|
||||
})
|
||||
}
|
||||
|
||||
@ -821,7 +904,7 @@ class LibraryItemController {
|
||||
* GET: /api/items/:id/ffprobe/:fileid
|
||||
* FFProbe JSON result from audio file
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getFFprobeData(req, res) {
|
||||
@ -829,25 +912,21 @@ class LibraryItemController {
|
||||
Logger.error(`[LibraryItemController] Non-admin user "${req.user.username}" attempted to get ffprobe data`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (req.libraryFile.fileType !== 'audio') {
|
||||
Logger.error(`[LibraryItemController] Invalid filetype "${req.libraryFile.fileType}" for fileid "${req.params.fileid}". Expected audio file`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const audioFile = req.libraryItem.media.findFileWithInode(req.params.fileid)
|
||||
const audioFile = req.libraryItem.getAudioFileWithIno(req.params.fileid)
|
||||
if (!audioFile) {
|
||||
Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile)
|
||||
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile.metadata.path)
|
||||
res.json(ffprobeData)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/items/:id/file/:fileid
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequestWithFile} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getLibraryFile(req, res) {
|
||||
@ -870,7 +949,7 @@ class LibraryItemController {
|
||||
/**
|
||||
* DELETE api/items/:id/file/:fileid
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequestWithFile} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async deleteLibraryFile(req, res) {
|
||||
@ -881,17 +960,49 @@ class LibraryItemController {
|
||||
await fs.remove(libraryFile.metadata.path).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error)
|
||||
})
|
||||
req.libraryItem.removeLibraryFile(req.params.fileid)
|
||||
|
||||
if (req.libraryItem.media.removeFileWithInode(req.params.fileid)) {
|
||||
// If book has no more media files then mark it as missing
|
||||
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
|
||||
req.libraryItem.setMissing()
|
||||
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((lf) => lf.ino !== req.params.fileid)
|
||||
req.libraryItem.changed('libraryFiles', true)
|
||||
|
||||
if (req.libraryItem.isBook) {
|
||||
if (req.libraryItem.media.audioFiles.some((af) => af.ino === req.params.fileid)) {
|
||||
req.libraryItem.media.audioFiles = req.libraryItem.media.audioFiles.filter((af) => af.ino !== req.params.fileid)
|
||||
req.libraryItem.media.changed('audioFiles', true)
|
||||
} else if (req.libraryItem.media.ebookFile?.ino === req.params.fileid) {
|
||||
req.libraryItem.media.ebookFile = null
|
||||
req.libraryItem.media.changed('ebookFile', true)
|
||||
}
|
||||
if (!req.libraryItem.media.hasMediaFiles) {
|
||||
req.libraryItem.isMissing = true
|
||||
}
|
||||
} else if (req.libraryItem.media.podcastEpisodes.some((ep) => ep.audioFile.ino === req.params.fileid)) {
|
||||
const episodeToRemove = req.libraryItem.media.podcastEpisodes.find((ep) => ep.audioFile.ino === req.params.fileid)
|
||||
// Remove episode from all playlists
|
||||
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id])
|
||||
|
||||
// Remove episode media progress
|
||||
const numProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||
where: {
|
||||
mediaItemId: episodeToRemove.id
|
||||
}
|
||||
})
|
||||
if (numProgressRemoved > 0) {
|
||||
Logger.info(`[LibraryItemController] Removed media progress for episode ${episodeToRemove.id}`)
|
||||
}
|
||||
|
||||
// Remove episode
|
||||
await episodeToRemove.destroy()
|
||||
|
||||
req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.audioFile.ino !== req.params.fileid)
|
||||
}
|
||||
req.libraryItem.updatedAt = Date.now()
|
||||
await Database.updateLibraryItem(req.libraryItem)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
|
||||
if (req.libraryItem.media.changed()) {
|
||||
await req.libraryItem.media.save()
|
||||
}
|
||||
|
||||
await req.libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@ -899,7 +1010,7 @@ class LibraryItemController {
|
||||
* GET api/items/:id/file/:fileid/download
|
||||
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequestWithFile} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async downloadLibraryFile(req, res) {
|
||||
@ -911,7 +1022,7 @@ class LibraryItemController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" file at "${libraryFile.metadata.path}"`)
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.title}" file at "${libraryFile.metadata.path}"`)
|
||||
|
||||
if (global.XAccel) {
|
||||
const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path)
|
||||
@ -947,13 +1058,13 @@ class LibraryItemController {
|
||||
* fileid is only required when reading a supplementary ebook
|
||||
* when no fileid is passed in the primary ebook will be returned
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getEBookFile(req, res) {
|
||||
let ebookFile = null
|
||||
if (req.params.fileid) {
|
||||
ebookFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
|
||||
ebookFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)
|
||||
if (!ebookFile?.isEBookFile) {
|
||||
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
|
||||
return res.status(400).send('Invalid ebook file id')
|
||||
@ -963,12 +1074,12 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
if (!ebookFile) {
|
||||
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.metadata.title}"`)
|
||||
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.title}"`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
const ebookFilePath = ebookFile.metadata.path
|
||||
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" ebook at "${ebookFilePath}"`)
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.title}" ebook at "${ebookFilePath}"`)
|
||||
|
||||
if (global.XAccel) {
|
||||
const encodedURI = encodeUriPath(global.XAccel + ebookFilePath)
|
||||
@ -991,28 +1102,55 @@ class LibraryItemController {
|
||||
* if an ebook file is the primary ebook, then it will be changed to supplementary
|
||||
* if an ebook file is supplementary, then it will be changed to primary
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequestWithFile} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateEbookFileStatus(req, res) {
|
||||
const ebookLibraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
|
||||
if (!ebookLibraryFile?.isEBookFile) {
|
||||
if (!req.libraryItem.isBook) {
|
||||
Logger.error(`[LibraryItemController] Invalid media type for ebook file status update`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (!req.libraryFile?.isEBookFile) {
|
||||
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
|
||||
return res.status(400).send('Invalid ebook file id')
|
||||
}
|
||||
|
||||
const ebookLibraryFile = req.libraryFile
|
||||
let primaryEbookFile = null
|
||||
|
||||
const ebookLibraryFileInos = req.libraryItem
|
||||
.getLibraryFiles()
|
||||
.filter((lf) => lf.isEBookFile)
|
||||
.map((lf) => lf.ino)
|
||||
|
||||
if (ebookLibraryFile.isSupplementary) {
|
||||
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`)
|
||||
req.libraryItem.setPrimaryEbook(ebookLibraryFile)
|
||||
|
||||
primaryEbookFile = ebookLibraryFile.toJSON()
|
||||
delete primaryEbookFile.isSupplementary
|
||||
delete primaryEbookFile.fileType
|
||||
primaryEbookFile.ebookFormat = ebookLibraryFile.metadata.format
|
||||
} else {
|
||||
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`)
|
||||
ebookLibraryFile.isSupplementary = true
|
||||
req.libraryItem.setPrimaryEbook(null)
|
||||
}
|
||||
|
||||
req.libraryItem.updatedAt = Date.now()
|
||||
await Database.updateLibraryItem(req.libraryItem)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
req.libraryItem.media.ebookFile = primaryEbookFile
|
||||
req.libraryItem.media.changed('ebookFile', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.map((lf) => {
|
||||
if (ebookLibraryFileInos.includes(lf.ino)) {
|
||||
lf.isSupplementary = lf.ino !== primaryEbookFile?.ino
|
||||
}
|
||||
return lf
|
||||
})
|
||||
req.libraryItem.changed('libraryFiles', true)
|
||||
|
||||
req.libraryItem.isMissing = !req.libraryItem.media.hasMediaFiles
|
||||
|
||||
await req.libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@ -1023,7 +1161,7 @@ class LibraryItemController {
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async middleware(req, res, next) {
|
||||
req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
|
||||
if (!req.libraryItem?.media) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
@ -1033,7 +1171,7 @@ class LibraryItemController {
|
||||
|
||||
// For library file routes, get the library file
|
||||
if (req.params.fileid) {
|
||||
req.libraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
|
||||
req.libraryFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)
|
||||
if (!req.libraryFile) {
|
||||
Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`)
|
||||
return res.sendStatus(404)
|
||||
|
@ -66,7 +66,7 @@ class MeController {
|
||||
const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId)
|
||||
const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId)
|
||||
|
||||
if (!libraryItem || (libraryItem.mediaType === 'podcast' && !episode)) {
|
||||
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
|
||||
Logger.error(`[MeController] Media item not found for library item id "${req.params.libraryItemId}"`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
@ -296,7 +296,7 @@ class MeController {
|
||||
const mediaProgressesInProgress = req.user.mediaProgresses.filter((mp) => !mp.isFinished && (mp.currentTime > 0 || mp.ebookProgress > 0))
|
||||
|
||||
const libraryItemsIds = [...new Set(mediaProgressesInProgress.map((mp) => mp.extraData?.libraryItemId).filter((id) => id))]
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: libraryItemsIds })
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: libraryItemsIds })
|
||||
|
||||
let itemsInProgress = []
|
||||
|
||||
@ -304,19 +304,19 @@ class MeController {
|
||||
const oldMediaProgress = mediaProgress.getOldMediaProgress()
|
||||
const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId)
|
||||
if (libraryItem) {
|
||||
if (oldMediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
|
||||
const episode = libraryItem.media.episodes.find((ep) => ep.id === oldMediaProgress.episodeId)
|
||||
if (oldMediaProgress.episodeId && libraryItem.isPodcast) {
|
||||
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === oldMediaProgress.episodeId)
|
||||
if (episode) {
|
||||
const libraryItemWithEpisode = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
recentEpisode: episode.toJSON(),
|
||||
...libraryItem.toOldJSONMinified(),
|
||||
recentEpisode: episode.toOldJSON(libraryItem.id),
|
||||
progressLastUpdate: oldMediaProgress.lastUpdate
|
||||
}
|
||||
itemsInProgress.push(libraryItemWithEpisode)
|
||||
}
|
||||
} else if (!oldMediaProgress.episodeId) {
|
||||
itemsInProgress.push({
|
||||
...libraryItem.toJSONMinified(),
|
||||
...libraryItem.toOldJSONMinified(),
|
||||
progressLastUpdate: oldMediaProgress.lastUpdate
|
||||
})
|
||||
}
|
||||
|
@ -342,8 +342,8 @@ class MiscController {
|
||||
tags: libraryItem.media.tags
|
||||
})
|
||||
await libraryItem.saveMetadataFile()
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
}
|
||||
@ -385,8 +385,8 @@ class MiscController {
|
||||
tags: libraryItem.media.tags
|
||||
})
|
||||
await libraryItem.saveMetadataFile()
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
|
||||
@ -480,8 +480,8 @@ class MiscController {
|
||||
genres: libraryItem.media.genres
|
||||
})
|
||||
await libraryItem.saveMetadataFile()
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
}
|
||||
@ -523,8 +523,8 @@ class MiscController {
|
||||
genres: libraryItem.media.genres
|
||||
})
|
||||
await libraryItem.saveMetadataFile()
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
|
||||
|
@ -3,13 +3,16 @@ const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const Playlist = require('../objects/Playlist')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/Playlist')} playlist
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} PlaylistControllerRequest
|
||||
*/
|
||||
|
||||
class PlaylistController {
|
||||
@ -23,48 +26,103 @@ class PlaylistController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
const oldPlaylist = new Playlist()
|
||||
req.body.userId = req.user.id
|
||||
const success = oldPlaylist.setData(req.body)
|
||||
if (!success) {
|
||||
return res.status(400).send('Invalid playlist request data')
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// Validation
|
||||
if (!reqBody.name || !reqBody.libraryId) {
|
||||
return res.status(400).send('Invalid playlist data')
|
||||
}
|
||||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid playlist description')
|
||||
}
|
||||
const items = reqBody.items || []
|
||||
const isPodcast = items.some((i) => i.episodeId)
|
||||
const libraryItemIds = new Set()
|
||||
for (const item of items) {
|
||||
if (!item.libraryItemId || typeof item.libraryItemId !== 'string') {
|
||||
return res.status(400).send('Invalid playlist item')
|
||||
}
|
||||
if (isPodcast && (!item.episodeId || typeof item.episodeId !== 'string')) {
|
||||
return res.status(400).send('Invalid playlist item episodeId')
|
||||
} else if (!isPodcast && item.episodeId) {
|
||||
return res.status(400).send('Invalid playlist item episodeId')
|
||||
}
|
||||
libraryItemIds.add(item.libraryItemId)
|
||||
}
|
||||
|
||||
// Create Playlist record
|
||||
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
|
||||
|
||||
// Lookup all library items in playlist
|
||||
const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId).filter((i) => i)
|
||||
const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
|
||||
// Load library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
id: Array.from(libraryItemIds),
|
||||
libraryId: reqBody.libraryId,
|
||||
mediaType: isPodcast ? 'podcast' : 'book'
|
||||
}
|
||||
})
|
||||
if (libraryItems.length !== libraryItemIds.size) {
|
||||
return res.status(400).send('Invalid playlist data. Invalid items')
|
||||
}
|
||||
|
||||
// Create playlistMediaItem records
|
||||
const mediaItemsToAdd = []
|
||||
let order = 1
|
||||
for (const mediaItemObj of oldPlaylist.items) {
|
||||
const libraryItem = libraryItemsInPlaylist.find((li) => li.id === mediaItemObj.libraryItemId)
|
||||
if (!libraryItem) continue
|
||||
|
||||
mediaItemsToAdd.push({
|
||||
mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId,
|
||||
mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book',
|
||||
playlistId: oldPlaylist.id,
|
||||
order: order++
|
||||
// Validate podcast episodes
|
||||
if (isPodcast) {
|
||||
const podcastEpisodeIds = items.map((i) => i.episodeId)
|
||||
const podcastEpisodes = await Database.podcastEpisodeModel.findAll({
|
||||
attributes: ['id'],
|
||||
where: {
|
||||
id: podcastEpisodeIds
|
||||
}
|
||||
})
|
||||
}
|
||||
if (mediaItemsToAdd.length) {
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
if (podcastEpisodes.length !== podcastEpisodeIds.length) {
|
||||
return res.status(400).send('Invalid playlist data. Invalid podcast episodes')
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
// Create playlist
|
||||
const newPlaylist = await Database.playlistModel.create(
|
||||
{
|
||||
libraryId: reqBody.libraryId,
|
||||
userId: req.user.id,
|
||||
name: reqBody.name,
|
||||
description: reqBody.description || null
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
// Create playlistMediaItems
|
||||
const playlistItemPayloads = []
|
||||
for (const [index, item] of items.entries()) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
playlistItemPayloads.push({
|
||||
playlistId: newPlaylist.id,
|
||||
mediaItemId: item.episodeId || libraryItem.mediaId,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: index + 1
|
||||
})
|
||||
}
|
||||
|
||||
await Database.playlistMediaItemModel.bulkCreate(playlistItemPayloads, { transaction })
|
||||
|
||||
await transaction.commit()
|
||||
|
||||
newPlaylist.playlistMediaItems = await newPlaylist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const jsonExpanded = newPlaylist.toOldJSONExpanded()
|
||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
Logger.error('[PlaylistController] create:', error)
|
||||
res.status(500).send('Failed to create playlist')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - Use /api/libraries/:libraryId/playlists
|
||||
* This is not used by Abs web client or mobile apps
|
||||
* TODO: Remove this endpoint or make it the primary
|
||||
*
|
||||
* GET: /api/playlists
|
||||
* Get all playlists for user
|
||||
*
|
||||
@ -72,68 +130,89 @@ class PlaylistController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findAllForUser(req, res) {
|
||||
const playlistsForUser = await Database.playlistModel.findAll({
|
||||
where: {
|
||||
userId: req.user.id
|
||||
}
|
||||
})
|
||||
const playlists = []
|
||||
for (const playlist of playlistsForUser) {
|
||||
const jsonExpanded = await playlist.getOldJsonExpanded()
|
||||
playlists.push(jsonExpanded)
|
||||
}
|
||||
const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
|
||||
res.json({
|
||||
playlists
|
||||
playlists: playlistsForUser
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/playlists/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
res.json(jsonExpanded)
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
res.json(req.playlist.toOldJSONExpanded())
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: /api/playlists/:id
|
||||
* Update playlist
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* Used for updating name and description or reordering items
|
||||
*
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
const updatedPlaylist = req.playlist.set(req.body)
|
||||
let wasUpdated = false
|
||||
const changed = updatedPlaylist.changed()
|
||||
if (changed?.length) {
|
||||
await req.playlist.save()
|
||||
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
|
||||
wasUpdated = true
|
||||
// Validation
|
||||
const reqBody = req.body || {}
|
||||
if (reqBody.libraryId || reqBody.userId) {
|
||||
// Could allow support for this if needed with additional validation
|
||||
return res.status(400).send('Invalid playlist data. Cannot update libraryId or userId')
|
||||
}
|
||||
if (reqBody.name && typeof reqBody.name !== 'string') {
|
||||
return res.status(400).send('Invalid playlist name')
|
||||
}
|
||||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid playlist description')
|
||||
}
|
||||
if (reqBody.items && (!Array.isArray(reqBody.items) || reqBody.items.some((i) => !i.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string')))) {
|
||||
return res.status(400).send('Invalid playlist items')
|
||||
}
|
||||
|
||||
// If array of items is passed in then update order of playlist media items
|
||||
const libraryItemIds = req.body.items?.map((i) => i.libraryItemId).filter((i) => i) || []
|
||||
if (libraryItemIds.length) {
|
||||
const playlistUpdatePayload = {}
|
||||
if (reqBody.name) playlistUpdatePayload.name = reqBody.name
|
||||
if (reqBody.description) playlistUpdatePayload.description = reqBody.description
|
||||
|
||||
// Update name and description
|
||||
let wasUpdated = false
|
||||
if (Object.keys(playlistUpdatePayload).length) {
|
||||
req.playlist.set(playlistUpdatePayload)
|
||||
const changed = req.playlist.changed()
|
||||
if (changed?.length) {
|
||||
await req.playlist.save()
|
||||
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
// If array of items is set then update order of playlist media items
|
||||
if (reqBody.items?.length) {
|
||||
const libraryItemIds = Array.from(new Set(reqBody.items.map((i) => i.libraryItemId)))
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType'],
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({
|
||||
if (libraryItems.length !== libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid playlist items. Items not found')
|
||||
}
|
||||
/** @type {import('../models/PlaylistMediaItem')[]} */
|
||||
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
if (existingPlaylistMediaItems.length !== reqBody.items.length) {
|
||||
return res.status(400).send('Invalid playlist items. Length mismatch')
|
||||
}
|
||||
|
||||
// Set an array of mediaItemId
|
||||
const newMediaItemIdOrder = []
|
||||
for (const item of req.body.items) {
|
||||
for (const item of reqBody.items) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
continue
|
||||
}
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
newMediaItemIdOrder.push(mediaItemId)
|
||||
}
|
||||
@ -146,21 +225,21 @@ class PlaylistController {
|
||||
})
|
||||
|
||||
// Update order on playlistMediaItem records
|
||||
let order = 1
|
||||
for (const playlistMediaItem of existingPlaylistMediaItems) {
|
||||
if (playlistMediaItem.order !== order) {
|
||||
for (const [index, playlistMediaItem] of existingPlaylistMediaItems.entries()) {
|
||||
if (playlistMediaItem.order !== index + 1) {
|
||||
await playlistMediaItem.update({
|
||||
order
|
||||
order: index + 1
|
||||
})
|
||||
wasUpdated = true
|
||||
}
|
||||
order++
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await updatedPlaylist.getOldJsonExpanded()
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
if (wasUpdated) {
|
||||
SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded)
|
||||
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
@ -169,11 +248,13 @@ class PlaylistController {
|
||||
* DELETE: /api/playlists/:id
|
||||
* Remove playlist
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||
res.sendStatus(200)
|
||||
@ -183,43 +264,64 @@ class PlaylistController {
|
||||
* POST: /api/playlists/:id/item
|
||||
* Add item to playlist
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* This is not used by Abs web client or mobile apps. Only the batch endpoints are used.
|
||||
*
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async addItem(req, res) {
|
||||
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
|
||||
const itemToAdd = req.body
|
||||
const itemToAdd = req.body || {}
|
||||
|
||||
if (!itemToAdd.libraryItemId) {
|
||||
return res.status(400).send('Request body has no libraryItemId')
|
||||
}
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(itemToAdd.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(400).send('Library item not found')
|
||||
}
|
||||
if (libraryItem.libraryId !== oldPlaylist.libraryId) {
|
||||
if (libraryItem.libraryId !== req.playlist.libraryId) {
|
||||
return res.status(400).send('Library item in different library')
|
||||
}
|
||||
if (oldPlaylist.containsItem(itemToAdd)) {
|
||||
return res.status(400).send('Item already in playlist')
|
||||
}
|
||||
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
|
||||
return res.status(400).send('Invalid item to add for this library type')
|
||||
}
|
||||
if (itemToAdd.episodeId && !libraryItem.media.checkHasEpisode(itemToAdd.episodeId)) {
|
||||
if (itemToAdd.episodeId && !libraryItem.media.podcastEpisodes.some((pe) => pe.id === itemToAdd.episodeId)) {
|
||||
return res.status(400).send('Episode not found in library item')
|
||||
}
|
||||
|
||||
const playlistMediaItem = {
|
||||
playlistId: oldPlaylist.id,
|
||||
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
|
||||
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: oldPlaylist.items.length + 1
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
if (req.playlist.checkHasMediaItem(itemToAdd.libraryItemId, itemToAdd.episodeId)) {
|
||||
return res.status(400).send('Item already in playlist')
|
||||
}
|
||||
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
const playlistMediaItem = {
|
||||
playlistId: req.playlist.id,
|
||||
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
|
||||
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: req.playlist.playlistMediaItems.length + 1
|
||||
}
|
||||
await Database.playlistMediaItemModel.create(playlistMediaItem)
|
||||
|
||||
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
|
||||
if (itemToAdd.episodeId) {
|
||||
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === itemToAdd.episodeId)
|
||||
jsonExpanded.items.push({
|
||||
episodeId: itemToAdd.episodeId,
|
||||
episode: episode.toOldJSONExpanded(libraryItem.id),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONMinified()
|
||||
})
|
||||
} else {
|
||||
jsonExpanded.items.push({
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONExpanded()
|
||||
})
|
||||
}
|
||||
|
||||
await Database.createPlaylistMediaItem(playlistMediaItem)
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
@ -228,43 +330,36 @@ class PlaylistController {
|
||||
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
|
||||
* Remove item from playlist
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeItem(req, res) {
|
||||
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
|
||||
if (!oldLibraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
let playlistMediaItem = null
|
||||
if (req.params.episodeId) {
|
||||
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === req.params.episodeId)
|
||||
} else {
|
||||
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === req.params.libraryItemId)
|
||||
}
|
||||
|
||||
// Get playlist media items
|
||||
const mediaItemId = req.params.episodeId || oldLibraryItem.media.id
|
||||
const playlistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
|
||||
// Check if media item to delete is in playlist
|
||||
const mediaItemToRemove = playlistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
|
||||
if (!mediaItemToRemove) {
|
||||
if (!playlistMediaItem) {
|
||||
return res.status(404).send('Media item not found in playlist')
|
||||
}
|
||||
|
||||
// Remove record
|
||||
await mediaItemToRemove.destroy()
|
||||
await playlistMediaItem.destroy()
|
||||
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
|
||||
|
||||
// Update playlist media items order
|
||||
let order = 1
|
||||
for (const mediaItem of playlistMediaItems) {
|
||||
if (mediaItem.mediaItemId === mediaItemId) continue
|
||||
if (mediaItem.order !== order) {
|
||||
for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) {
|
||||
if (mediaItem.order !== index + 1) {
|
||||
await mediaItem.update({
|
||||
order
|
||||
order: index + 1
|
||||
})
|
||||
}
|
||||
order++
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
// Playlist is removed when there are no items
|
||||
if (!jsonExpanded.items.length) {
|
||||
@ -282,64 +377,68 @@ class PlaylistController {
|
||||
* POST: /api/playlists/:id/batch/add
|
||||
* Batch add playlist items
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async addBatch(req, res) {
|
||||
if (!req.body.items?.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
const itemsToAdd = req.body.items
|
||||
|
||||
const libraryItemIds = itemsToAdd.map((i) => i.libraryItemId).filter((i) => i)
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
}
|
||||
|
||||
// Find all library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
|
||||
|
||||
// Get all existing playlist media items
|
||||
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) })
|
||||
if (libraryItems.length !== libraryItemIds.size) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
}
|
||||
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const mediaItemsToAdd = []
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
// Setup array of playlistMediaItem records to add
|
||||
let order = existingPlaylistMediaItems.length + 1
|
||||
for (const item of itemsToAdd) {
|
||||
let order = req.playlist.playlistMediaItems.length + 1
|
||||
for (const item of req.body.items) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Item not found with id ' + item.libraryItemId)
|
||||
|
||||
const mediaItemId = item.episodeId || libraryItem.media.id
|
||||
if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
|
||||
// Already exists in playlist
|
||||
continue
|
||||
} else {
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
if (existingPlaylistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
|
||||
// Already exists in playlist
|
||||
continue
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: req.playlist.id,
|
||||
mediaItemId,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: order++
|
||||
})
|
||||
|
||||
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
|
||||
if (item.episodeId) {
|
||||
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === item.episodeId)
|
||||
jsonExpanded.items.push({
|
||||
episodeId: item.episodeId,
|
||||
episode: episode.toOldJSONExpanded(libraryItem.id),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONMinified()
|
||||
})
|
||||
} else {
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: req.playlist.id,
|
||||
mediaItemId,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: order++
|
||||
jsonExpanded.items.push({
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONExpanded()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let jsonExpanded = null
|
||||
if (mediaItemsToAdd.length) {
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)
|
||||
|
||||
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
} else {
|
||||
jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
}
|
||||
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
@ -347,50 +446,40 @@ class PlaylistController {
|
||||
* POST: /api/playlists/:id/batch/remove
|
||||
* Batch remove playlist items
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeBatch(req, res) {
|
||||
if (!req.body.items?.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
}
|
||||
|
||||
const itemsToRemove = req.body.items
|
||||
const libraryItemIds = itemsToRemove.map((i) => i.libraryItemId).filter((i) => i)
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Find all library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
|
||||
// Get all existing playlist media items for playlist
|
||||
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
let numMediaItems = existingPlaylistMediaItems.length
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
// Remove playlist media items
|
||||
let hasUpdated = false
|
||||
for (const item of itemsToRemove) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
if (!libraryItem) continue
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
const existingMediaItem = existingPlaylistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
|
||||
if (!existingMediaItem) continue
|
||||
await existingMediaItem.destroy()
|
||||
for (const item of req.body.items) {
|
||||
let playlistMediaItem = null
|
||||
if (item.episodeId) {
|
||||
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === item.episodeId)
|
||||
} else {
|
||||
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === item.libraryItemId)
|
||||
}
|
||||
if (!playlistMediaItem) {
|
||||
Logger.warn(`[PlaylistController] Playlist item not found in playlist ${req.playlist.id}`, item)
|
||||
continue
|
||||
}
|
||||
|
||||
await playlistMediaItem.destroy()
|
||||
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
|
||||
|
||||
hasUpdated = true
|
||||
numMediaItems--
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
if (hasUpdated) {
|
||||
// Playlist is removed when there are no items
|
||||
if (!numMediaItems) {
|
||||
if (!req.playlist.playlistMediaItems.length) {
|
||||
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||
@ -425,33 +514,41 @@ class PlaylistController {
|
||||
return res.status(400).send('Collection has no books')
|
||||
}
|
||||
|
||||
const oldPlaylist = new Playlist()
|
||||
oldPlaylist.setData({
|
||||
userId: req.user.id,
|
||||
libraryId: collection.libraryId,
|
||||
name: collection.name,
|
||||
description: collection.description || null
|
||||
})
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
const playlist = await Database.playlistModel.create(
|
||||
{
|
||||
userId: req.user.id,
|
||||
libraryId: collection.libraryId,
|
||||
name: collection.name,
|
||||
description: collection.description || null
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
// Create Playlist record
|
||||
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
|
||||
const mediaItemsToAdd = []
|
||||
for (const [index, libraryItem] of collectionExpanded.books.entries()) {
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: playlist.id,
|
||||
mediaItemId: libraryItem.media.id,
|
||||
mediaItemType: 'book',
|
||||
order: index + 1
|
||||
})
|
||||
}
|
||||
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd, { transaction })
|
||||
|
||||
// Create PlaylistMediaItem records
|
||||
const mediaItemsToAdd = []
|
||||
let order = 1
|
||||
for (const libraryItem of collectionExpanded.books) {
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: newPlaylist.id,
|
||||
mediaItemId: libraryItem.media.id,
|
||||
mediaItemType: 'book',
|
||||
order: order++
|
||||
})
|
||||
await transaction.commit()
|
||||
|
||||
playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const jsonExpanded = playlist.toOldJSONExpanded()
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
Logger.error('[PlaylistController] createFromCollection:', error)
|
||||
res.status(500).send('Failed to create playlist')
|
||||
}
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
|
||||
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,3 +1,4 @@
|
||||
const Path = require('path')
|
||||
const { Request, Response, NextFunction } = require('express')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
@ -12,13 +13,16 @@ const { validateUrl } = require('../utils/index')
|
||||
const Scanner = require('../scanner/Scanner')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/LibraryItem')} libraryItem
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
|
||||
*/
|
||||
|
||||
class PodcastController {
|
||||
@ -37,6 +41,9 @@ class PodcastController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const payload = req.body
|
||||
if (!payload.media || !payload.media.metadata) {
|
||||
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
|
||||
}
|
||||
|
||||
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
|
||||
if (!library) {
|
||||
@ -78,48 +85,87 @@ class PodcastController {
|
||||
let relPath = payload.path.replace(folder.fullPath, '')
|
||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||
|
||||
const libraryItemPayload = {
|
||||
path: podcastPath,
|
||||
relPath,
|
||||
folderId: payload.folderId,
|
||||
libraryId: payload.libraryId,
|
||||
ino: libraryItemFolderStats.ino,
|
||||
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
||||
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
||||
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
||||
media: payload.media
|
||||
let newLibraryItem = null
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
const podcast = await Database.podcastModel.createFromRequest(payload.media, transaction)
|
||||
|
||||
newLibraryItem = await Database.libraryItemModel.create(
|
||||
{
|
||||
ino: libraryItemFolderStats.ino,
|
||||
path: podcastPath,
|
||||
relPath,
|
||||
mediaId: podcast.id,
|
||||
mediaType: 'podcast',
|
||||
isFile: false,
|
||||
isMissing: false,
|
||||
isInvalid: false,
|
||||
mtime: libraryItemFolderStats.mtimeMs || 0,
|
||||
ctime: libraryItemFolderStats.ctimeMs || 0,
|
||||
birthtime: libraryItemFolderStats.birthtimeMs || 0,
|
||||
size: 0,
|
||||
libraryFiles: [],
|
||||
extraData: {},
|
||||
libraryId: library.id,
|
||||
libraryFolderId: folder.id
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
await transaction.commit()
|
||||
} catch (error) {
|
||||
Logger.error(`[PodcastController] Failed to create podcast: ${error}`)
|
||||
await transaction.rollback()
|
||||
return res.status(500).send('Failed to create podcast')
|
||||
}
|
||||
|
||||
const libraryItem = new LibraryItem()
|
||||
libraryItem.setData('podcast', libraryItemPayload)
|
||||
newLibraryItem.media = await newLibraryItem.getMediaExpanded()
|
||||
|
||||
// Download and save cover image
|
||||
if (payload.media.metadata.imageUrl) {
|
||||
// TODO: Scan cover image to library files
|
||||
if (typeof payload.media.metadata.imageUrl === 'string' && payload.media.metadata.imageUrl.startsWith('http')) {
|
||||
// Podcast cover will always go into library item folder
|
||||
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
|
||||
if (coverResponse) {
|
||||
if (coverResponse.error) {
|
||||
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
|
||||
} else if (coverResponse.cover) {
|
||||
libraryItem.media.coverPath = coverResponse.cover
|
||||
const coverResponse = await CoverManager.downloadCoverFromUrlNew(payload.media.metadata.imageUrl, newLibraryItem.id, newLibraryItem.path, true)
|
||||
if (coverResponse.error) {
|
||||
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
|
||||
} else if (coverResponse.cover) {
|
||||
const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
|
||||
if (!coverImageFileStats) {
|
||||
Logger.error(`[PodcastController] Failed to get cover image stats for "${coverResponse.cover}"`)
|
||||
} else {
|
||||
// Add libraryFile to libraryItem and coverPath to podcast
|
||||
const newLibraryFile = {
|
||||
ino: coverImageFileStats.ino,
|
||||
fileType: 'image',
|
||||
addedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
metadata: {
|
||||
filename: Path.basename(coverResponse.cover),
|
||||
ext: Path.extname(coverResponse.cover).slice(1),
|
||||
path: coverResponse.cover,
|
||||
relPath: Path.basename(coverResponse.cover),
|
||||
size: coverImageFileStats.size,
|
||||
mtimeMs: coverImageFileStats.mtimeMs || 0,
|
||||
ctimeMs: coverImageFileStats.ctimeMs || 0,
|
||||
birthtimeMs: coverImageFileStats.birthtimeMs || 0
|
||||
}
|
||||
}
|
||||
newLibraryItem.libraryFiles.push(newLibraryFile)
|
||||
newLibraryItem.changed('libraryFiles', true)
|
||||
await newLibraryItem.save()
|
||||
|
||||
newLibraryItem.media.coverPath = coverResponse.cover
|
||||
await newLibraryItem.media.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Database.createLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
|
||||
SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
|
||||
|
||||
res.json(libraryItem.toJSONExpanded())
|
||||
|
||||
if (payload.episodesToDownload?.length) {
|
||||
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
|
||||
this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
|
||||
}
|
||||
res.json(newLibraryItem.toOldJSONExpanded())
|
||||
|
||||
// Turn on podcast auto download cron if not already on
|
||||
if (libraryItem.media.autoDownloadEpisodes) {
|
||||
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
||||
if (newLibraryItem.media.autoDownloadEpisodes) {
|
||||
this.cronManager.checkUpdatePodcastCron(newLibraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
@ -213,7 +259,7 @@ class PodcastController {
|
||||
*
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async checkNewEpisodes(req, res) {
|
||||
@ -222,15 +268,14 @@ class PodcastController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var libraryItem = req.libraryItem
|
||||
if (!libraryItem.media.metadata.feedUrl) {
|
||||
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
|
||||
return res.status(500).send('Podcast has no rss feed url')
|
||||
if (!req.libraryItem.media.feedURL) {
|
||||
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${req.libraryItem.id}`)
|
||||
return res.status(400).send('Podcast has no rss feed url')
|
||||
}
|
||||
|
||||
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3
|
||||
|
||||
var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload)
|
||||
const newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(req.libraryItem, maxEpisodesToDownload)
|
||||
res.json({
|
||||
episodes: newEpisodes || []
|
||||
})
|
||||
@ -258,23 +303,28 @@ class PodcastController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
getEpisodeDownloads(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
|
||||
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
|
||||
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||
res.json({
|
||||
downloads: downloadsInQueue.map((d) => d.toJSONForClient())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/podcasts/:id/search-episode
|
||||
* Search for an episode in a podcast
|
||||
*
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findEpisode(req, res) {
|
||||
const rssFeedUrl = req.libraryItem.media.metadata.feedUrl
|
||||
const rssFeedUrl = req.libraryItem.media.feedURL
|
||||
if (!rssFeedUrl) {
|
||||
Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`)
|
||||
return res.status(500).send('Podcast does not have an RSS feed URL')
|
||||
return res.status(400).send('Podcast does not have an RSS feed URL')
|
||||
}
|
||||
|
||||
const searchTitle = req.query.title
|
||||
@ -292,7 +342,7 @@ class PodcastController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async downloadEpisodes(req, res) {
|
||||
@ -300,13 +350,13 @@ class PodcastController {
|
||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const libraryItem = req.libraryItem
|
||||
|
||||
const episodes = req.body
|
||||
if (!episodes?.length) {
|
||||
if (!Array.isArray(episodes) || !episodes.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes)
|
||||
this.podcastManager.downloadPodcastEpisodes(req.libraryItem, episodes)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@ -315,7 +365,7 @@ class PodcastController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async quickMatchEpisodes(req, res) {
|
||||
@ -325,10 +375,11 @@ class PodcastController {
|
||||
}
|
||||
|
||||
const overrideDetails = req.query.override === '1'
|
||||
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem)
|
||||
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(oldLibraryItem, { overrideDetails })
|
||||
if (episodesUpdated) {
|
||||
await Database.updateLibraryItem(req.libraryItem)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
await Database.updateLibraryItem(oldLibraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
}
|
||||
|
||||
res.json({
|
||||
@ -339,58 +390,76 @@ class PodcastController {
|
||||
/**
|
||||
* PATCH: /api/podcasts/:id/episode/:episodeId
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateEpisode(req, res) {
|
||||
const libraryItem = req.libraryItem
|
||||
|
||||
var episodeId = req.params.episodeId
|
||||
if (!libraryItem.media.checkHasEpisode(episodeId)) {
|
||||
/** @type {import('../models/PodcastEpisode')} */
|
||||
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === req.params.episodeId)
|
||||
if (!episode) {
|
||||
return res.status(404).send('Episode not found')
|
||||
}
|
||||
|
||||
if (libraryItem.media.updateEpisode(episodeId, req.body)) {
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
const updatePayload = {}
|
||||
const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType']
|
||||
for (const key in req.body) {
|
||||
if (supportedStringKeys.includes(key) && typeof req.body[key] === 'string') {
|
||||
updatePayload[key] = req.body[key]
|
||||
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
|
||||
updatePayload[key] = req.body[key]
|
||||
} else if (key === 'publishedAt' && typeof req.body[key] === 'number') {
|
||||
updatePayload[key] = req.body[key]
|
||||
}
|
||||
}
|
||||
|
||||
res.json(libraryItem.toJSONExpanded())
|
||||
if (Object.keys(updatePayload).length) {
|
||||
episode.set(updatePayload)
|
||||
if (episode.changed()) {
|
||||
Logger.info(`[PodcastController] Updated episode "${episode.title}" keys`, episode.changed())
|
||||
await episode.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
} else {
|
||||
Logger.info(`[PodcastController] No changes to episode "${episode.title}"`)
|
||||
}
|
||||
}
|
||||
|
||||
res.json(req.libraryItem.toOldJSONExpanded())
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/podcasts/:id/episode/:episodeId
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getEpisode(req, res) {
|
||||
const episodeId = req.params.episodeId
|
||||
const libraryItem = req.libraryItem
|
||||
|
||||
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
|
||||
/** @type {import('../models/PodcastEpisode')} */
|
||||
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)
|
||||
if (!episode) {
|
||||
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
res.json(episode)
|
||||
res.json(episode.toOldJSON(req.libraryItem.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: /api/podcasts/:id/episode/:episodeId
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeEpisode(req, res) {
|
||||
const episodeId = req.params.episodeId
|
||||
const libraryItem = req.libraryItem
|
||||
const hardDelete = req.query.hard === '1'
|
||||
|
||||
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
|
||||
/** @type {import('../models/PodcastEpisode')} */
|
||||
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)
|
||||
if (!episode) {
|
||||
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
@ -407,36 +476,8 @@ class PodcastController {
|
||||
})
|
||||
}
|
||||
|
||||
// Remove episode from Podcast and library file
|
||||
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
|
||||
if (episodeRemoved?.audioFile) {
|
||||
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
|
||||
}
|
||||
|
||||
// Update/remove playlists that had this podcast episode
|
||||
const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
|
||||
where: {
|
||||
mediaItemId: episodeId
|
||||
},
|
||||
include: {
|
||||
model: Database.playlistModel,
|
||||
include: Database.playlistMediaItemModel
|
||||
}
|
||||
})
|
||||
for (const pmi of playlistMediaItems) {
|
||||
const numItems = pmi.playlist.playlistMediaItems.length - 1
|
||||
|
||||
if (!numItems) {
|
||||
Logger.info(`[PodcastController] Playlist "${pmi.playlist.name}" has no more items - removing it`)
|
||||
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
await pmi.playlist.destroy()
|
||||
} else {
|
||||
await pmi.destroy()
|
||||
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
}
|
||||
// Remove episode from playlists
|
||||
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeId])
|
||||
|
||||
// Remove media progress for this episode
|
||||
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||
@ -448,9 +489,16 @@ class PodcastController {
|
||||
Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`)
|
||||
}
|
||||
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
res.json(libraryItem.toJSON())
|
||||
// Remove episode
|
||||
await episode.destroy()
|
||||
|
||||
// Remove library file
|
||||
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((file) => file.ino !== episode.audioFile.ino)
|
||||
req.libraryItem.changed('libraryFiles', true)
|
||||
await req.libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
res.json(req.libraryItem.toOldJSON())
|
||||
}
|
||||
|
||||
/**
|
||||
@ -460,15 +508,15 @@ class PodcastController {
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async middleware(req, res, next) {
|
||||
const item = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
if (!item?.media) return res.sendStatus(404)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
|
||||
if (!libraryItem?.media) return res.sendStatus(404)
|
||||
|
||||
if (!item.isPodcast) {
|
||||
if (!libraryItem.isPodcast) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
@ -480,7 +528,7 @@ class PodcastController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
req.libraryItem = item
|
||||
req.libraryItem = libraryItem
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
@ -149,7 +149,7 @@ class SessionController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getOpenSession(req, res) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(req.playbackSession.libraryItemId)
|
||||
const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)
|
||||
res.json(sessionForClient)
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ const Database = require('../Database')
|
||||
|
||||
const { PlayMethod } = require('../utils/constants')
|
||||
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
|
||||
const zipHelpers = require('../utils/zipHelpers')
|
||||
|
||||
const PlaybackSession = require('../objects/PlaybackSession')
|
||||
const ShareManager = require('../managers/ShareManager')
|
||||
@ -69,14 +70,13 @@ class ShareController {
|
||||
}
|
||||
|
||||
try {
|
||||
const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
|
||||
|
||||
if (!oldLibraryItem) {
|
||||
const libraryItem = await Database.mediaItemShareModel.getMediaItemsLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Media item not found')
|
||||
}
|
||||
|
||||
let startOffset = 0
|
||||
const publicTracks = oldLibraryItem.media.includedAudioFiles.map((audioFile) => {
|
||||
const publicTracks = libraryItem.media.includedAudioFiles.map((audioFile) => {
|
||||
const audioTrack = {
|
||||
index: audioFile.index,
|
||||
startOffset,
|
||||
@ -85,7 +85,7 @@ class ShareController {
|
||||
contentUrl: `${global.RouterBasePath}/public/share/${slug}/track/${audioFile.index}`,
|
||||
mimeType: audioFile.mimeType,
|
||||
codec: audioFile.codec || null,
|
||||
metadata: audioFile.metadata.clone()
|
||||
metadata: structuredClone(audioFile.metadata)
|
||||
}
|
||||
startOffset += audioTrack.duration
|
||||
return audioTrack
|
||||
@ -104,12 +104,12 @@ class ShareController {
|
||||
const deviceInfo = await this.playbackSessionManager.getDeviceInfo(req, clientDeviceInfo)
|
||||
|
||||
const newPlaybackSession = new PlaybackSession()
|
||||
newPlaybackSession.setData(oldLibraryItem, null, 'web-share', deviceInfo, startTime)
|
||||
newPlaybackSession.setData(libraryItem, null, 'web-share', deviceInfo, startTime)
|
||||
newPlaybackSession.audioTracks = publicTracks
|
||||
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
||||
newPlaybackSession.shareSessionId = shareSessionId
|
||||
newPlaybackSession.mediaItemShareId = mediaItemShare.id
|
||||
newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.coverAspectRatio
|
||||
newPlaybackSession.coverAspectRatio = libraryItem.library.settings.coverAspectRatio
|
||||
|
||||
mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient()
|
||||
ShareManager.addOpenSharePlaybackSession(newPlaybackSession)
|
||||
@ -210,6 +210,65 @@ class ShareController {
|
||||
res.sendFile(audioTrackPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Public route - requires share_session_id cookie
|
||||
*
|
||||
* GET: /api/share/:slug/download
|
||||
* Downloads media item share
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async downloadMediaItemShare(req, res) {
|
||||
if (!req.cookies.share_session_id) {
|
||||
return res.status(404).send('Share session not set')
|
||||
}
|
||||
|
||||
const { slug } = req.params
|
||||
const mediaItemShare = ShareManager.findBySlug(slug)
|
||||
if (!mediaItemShare) {
|
||||
return res.status(404)
|
||||
}
|
||||
if (!mediaItemShare.isDownloadable) {
|
||||
return res.status(403).send('Download is not allowed for this item')
|
||||
}
|
||||
|
||||
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
|
||||
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
|
||||
return res.status(404).send('Share session not found')
|
||||
}
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.findByPk(playbackSession.libraryItemId, {
|
||||
attributes: ['id', 'path', 'relPath', 'isFile']
|
||||
})
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
}
|
||||
|
||||
const itemPath = libraryItem.path
|
||||
const itemTitle = playbackSession.displayTitle
|
||||
|
||||
Logger.info(`[ShareController] Requested download for book "${itemTitle}" at "${itemPath}"`)
|
||||
|
||||
try {
|
||||
if (libraryItem.isFile) {
|
||||
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(itemPath))
|
||||
if (audioMimeType) {
|
||||
res.setHeader('Content-Type', audioMimeType)
|
||||
}
|
||||
await new Promise((resolve, reject) => res.download(itemPath, libraryItem.relPath, (error) => (error ? reject(error) : resolve())))
|
||||
} else {
|
||||
const filename = `${itemTitle}.zip`
|
||||
await zipHelpers.zipDirectoryPipe(itemPath, filename, res)
|
||||
}
|
||||
|
||||
Logger.info(`[ShareController] Downloaded item "${itemTitle}" at "${itemPath}"`)
|
||||
} catch (error) {
|
||||
Logger.error(`[ShareController] Download failed for item "${itemTitle}" at "${itemPath}"`, error)
|
||||
res.status(500).send('Failed to download the item')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public route - requires share_session_id cookie
|
||||
*
|
||||
@ -259,7 +318,7 @@ class ShareController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const { slug, expiresAt, mediaItemType, mediaItemId } = req.body
|
||||
const { slug, expiresAt, mediaItemType, mediaItemId, isDownloadable } = req.body
|
||||
|
||||
if (!slug?.trim?.() || typeof mediaItemType !== 'string' || typeof mediaItemId !== 'string') {
|
||||
return res.status(400).send('Missing or invalid required fields')
|
||||
@ -298,7 +357,8 @@ class ShareController {
|
||||
expiresAt: expiresAt || null,
|
||||
mediaItemId,
|
||||
mediaItemType,
|
||||
userId: req.user.id
|
||||
userId: req.user.id,
|
||||
isDownloadable
|
||||
})
|
||||
|
||||
ShareManager.openMediaItemShare(mediaItemShare)
|
||||
|
@ -42,6 +42,8 @@ class ApiCacheManager {
|
||||
Logger.debug(`[ApiCacheManager] Skipping cache for random sort`)
|
||||
return next()
|
||||
}
|
||||
// Force URL to be lower case for matching against routes
|
||||
req.url = req.url.toLowerCase()
|
||||
const key = { user: req.user.username, url: req.url }
|
||||
const stringifiedKey = JSON.stringify(key)
|
||||
Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`)
|
||||
|
@ -34,8 +34,13 @@ class AudioMetadataMangaer {
|
||||
return this.tasksQueued.some((t) => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some((t) => t.data.libraryItemId === libraryItemId)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @returns
|
||||
*/
|
||||
getMetadataObjectForApi(libraryItem) {
|
||||
return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)
|
||||
return ffmpegHelpers.getFFMetadataObject(libraryItem.toOldJSONExpanded(), libraryItem.media.includedAudioFiles.length)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -79,6 +79,12 @@ class CoverManager {
|
||||
return imgType
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {*} coverFile - file object from req.files
|
||||
* @returns {Promise<{error:string}|{cover:string}>}
|
||||
*/
|
||||
async uploadCover(libraryItem, coverFile) {
|
||||
const extname = Path.extname(coverFile.name.toLowerCase())
|
||||
if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) {
|
||||
@ -110,14 +116,20 @@ class CoverManager {
|
||||
await this.removeOldCovers(coverDirPath, extname)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
|
||||
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
|
||||
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.title}"`)
|
||||
|
||||
libraryItem.updateMediaCover(coverFullPath)
|
||||
return {
|
||||
cover: coverFullPath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @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) {
|
||||
try {
|
||||
// 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) {
|
||||
// Invalid cover path
|
||||
if (!coverPath || coverPath.startsWith('http:') || coverPath.startsWith('https:')) {
|
||||
@ -235,7 +253,6 @@ class CoverManager {
|
||||
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
|
||||
libraryItem.updateMediaCover(coverPath)
|
||||
return {
|
||||
cover: coverPath,
|
||||
updated: true
|
||||
@ -321,13 +338,14 @@ class CoverManager {
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast
|
||||
* @param {string} [libraryItemPath] - null if library item isFile
|
||||
* @param {boolean} [forceLibraryItemFolder=false] - force save cover with library item (used for adding new podcasts)
|
||||
* @returns {Promise<{error:string}|{cover:string}>}
|
||||
*/
|
||||
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) {
|
||||
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath, forceLibraryItemFolder = false) {
|
||||
try {
|
||||
let coverDirPath = null
|
||||
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
|
||||
if ((global.ServerSettings.storeCoverWithItem || forceLibraryItemFolder) && libraryItemPath) {
|
||||
coverDirPath = libraryItemPath
|
||||
} else {
|
||||
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
|
||||
|
@ -181,7 +181,7 @@ class CronManager {
|
||||
// Get podcast library items to check
|
||||
const libraryItems = []
|
||||
for (const libraryItemId of libraryItemIds) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
|
||||
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out
|
||||
@ -215,6 +215,10 @@ class CronManager {
|
||||
this.podcastCrons = this.podcastCrons.filter((pc) => pc.expression !== podcastCron.expression)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
*/
|
||||
checkUpdatePodcastCron(libraryItem) {
|
||||
// Remove from old cron by library item id
|
||||
const existingCron = this.podcastCrons.find((pc) => pc.libraryItemIds.includes(libraryItem.id))
|
||||
@ -230,7 +234,10 @@ class CronManager {
|
||||
const cronMatchingExpression = this.podcastCrons.find((pc) => pc.expression === libraryItem.media.autoDownloadSchedule)
|
||||
if (cronMatchingExpression) {
|
||||
cronMatchingExpression.libraryItemIds.push(libraryItem.id)
|
||||
Logger.info(`[CronManager] Added podcast "${libraryItem.media.metadata.title}" to auto dl episode cron "${cronMatchingExpression.expression}"`)
|
||||
|
||||
// TODO: Update after old model removed
|
||||
const podcastTitle = libraryItem.media.title || libraryItem.media.metadata?.title
|
||||
Logger.info(`[CronManager] Added podcast "${podcastTitle}" to auto dl episode cron "${cronMatchingExpression.expression}"`)
|
||||
} else {
|
||||
this.startPodcastCron(libraryItem.media.autoDownloadSchedule, [libraryItem.id])
|
||||
}
|
||||
|
@ -14,6 +14,11 @@ class NotificationManager {
|
||||
return notificationData
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {import('../models/PodcastEpisode')} episode
|
||||
*/
|
||||
async onPodcastEpisodeDownloaded(libraryItem, episode) {
|
||||
if (!Database.notificationSettings.isUseable) return
|
||||
|
||||
@ -22,17 +27,17 @@ class NotificationManager {
|
||||
return
|
||||
}
|
||||
|
||||
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
|
||||
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.title}`)
|
||||
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
|
||||
const eventData = {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryId: libraryItem.libraryId,
|
||||
libraryName: library?.name || 'Unknown',
|
||||
mediaTags: (libraryItem.media.tags || []).join(', '),
|
||||
podcastTitle: libraryItem.media.metadata.title,
|
||||
podcastAuthor: libraryItem.media.metadata.author || '',
|
||||
podcastDescription: libraryItem.media.metadata.description || '',
|
||||
podcastGenres: (libraryItem.media.metadata.genres || []).join(', '),
|
||||
podcastTitle: libraryItem.media.title,
|
||||
podcastAuthor: libraryItem.media.author || '',
|
||||
podcastDescription: libraryItem.media.description || '',
|
||||
podcastGenres: (libraryItem.media.genres || []).join(', '),
|
||||
episodeId: episode.id,
|
||||
episodeTitle: episode.title,
|
||||
episodeSubtitle: episode.subtitle || '',
|
||||
|
@ -39,7 +39,7 @@ class PlaybackSessionManager {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../controllers/SessionController').RequestWithUser} req
|
||||
* @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req
|
||||
* @param {Object} [clientDeviceInfo]
|
||||
* @returns {Promise<DeviceInfo>}
|
||||
*/
|
||||
@ -67,7 +67,7 @@ class PlaybackSessionManager {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../controllers/SessionController').RequestWithUser} req
|
||||
* @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {string} [episodeId]
|
||||
*/
|
||||
@ -120,8 +120,8 @@ class PlaybackSessionManager {
|
||||
*/
|
||||
async syncLocalSession(user, sessionJson, deviceInfo) {
|
||||
// TODO: Combine libraryItem query with library query
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId)
|
||||
const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(sessionJson.libraryItemId)
|
||||
const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.podcastEpisodes.find((pe) => pe.id === sessionJson.episodeId) : null
|
||||
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
|
||||
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
|
||||
return {
|
||||
@ -175,7 +175,8 @@ class PlaybackSessionManager {
|
||||
// New session from local
|
||||
session = new PlaybackSession(sessionJson)
|
||||
session.deviceInfo = deviceInfo
|
||||
session.setDuration(libraryItem, sessionJson.episodeId)
|
||||
session.duration = libraryItem.media.getPlaybackDuration(sessionJson.episodeId)
|
||||
|
||||
Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`)
|
||||
await Database.createPlaybackSession(session)
|
||||
} else {
|
||||
@ -279,7 +280,7 @@ class PlaybackSessionManager {
|
||||
*
|
||||
* @param {import('../models/User')} user
|
||||
* @param {DeviceInfo} deviceInfo
|
||||
* @param {import('../objects/LibraryItem')} libraryItem
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {string|null} episodeId
|
||||
* @param {{forceDirectPlay?:boolean, forceTranscode?:boolean, mediaPlayer:string, supportedMimeTypes?:string[]}} options
|
||||
* @returns {Promise<PlaybackSession>}
|
||||
@ -292,7 +293,7 @@ class PlaybackSessionManager {
|
||||
await this.closeSession(user, session, null)
|
||||
}
|
||||
|
||||
const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
|
||||
const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options.supportedMimeTypes, episodeId))
|
||||
const mediaPlayer = options.mediaPlayer || 'unknown'
|
||||
|
||||
const mediaItemId = episodeId || libraryItem.media.id
|
||||
@ -300,7 +301,7 @@ class PlaybackSessionManager {
|
||||
let userStartTime = 0
|
||||
if (userProgress) {
|
||||
if (userProgress.isFinished) {
|
||||
Logger.info(`[PlaybackSessionManager] Starting session for user "${user.username}" and resetting progress for finished item "${libraryItem.media.metadata.title}"`)
|
||||
Logger.info(`[PlaybackSessionManager] Starting session for user "${user.username}" and resetting progress for finished item "${libraryItem.media.title}"`)
|
||||
// Keep userStartTime as 0 so the client restarts the media
|
||||
} else {
|
||||
userStartTime = Number.parseFloat(userProgress.currentTime) || 0
|
||||
@ -312,7 +313,7 @@ class PlaybackSessionManager {
|
||||
let audioTracks = []
|
||||
if (shouldDirectPlay) {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`)
|
||||
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
|
||||
audioTracks = libraryItem.getTrackList(episodeId)
|
||||
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
||||
} else {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`)
|
||||
@ -346,7 +347,7 @@ class PlaybackSessionManager {
|
||||
*/
|
||||
async syncSession(user, session, syncData) {
|
||||
// TODO: Combine libraryItem query with library query
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(session.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
|
||||
return null
|
||||
@ -381,9 +382,6 @@ class PlaybackSessionManager {
|
||||
})
|
||||
}
|
||||
this.saveSession(session)
|
||||
return {
|
||||
libraryItem
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,3 +1,4 @@
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
@ -19,9 +20,7 @@ const NotificationManager = require('../managers/NotificationManager')
|
||||
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
|
||||
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
|
||||
class PodcastManager {
|
||||
constructor() {
|
||||
@ -52,15 +51,16 @@ class PodcastManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {import('../utils/podcastUtils').RssPodcastEpisode[]} episodesToDownload
|
||||
* @param {boolean} isAutoDownload - If this download was triggered by auto download
|
||||
*/
|
||||
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
|
||||
let index = Math.max(...libraryItem.media.episodes.filter((ep) => ep.index == null || isNaN(ep.index)).map((ep) => Number(ep.index))) + 1
|
||||
for (const ep of episodesToDownload) {
|
||||
const newPe = new PodcastEpisode()
|
||||
newPe.setData(ep, index++)
|
||||
newPe.libraryItemId = libraryItem.id
|
||||
newPe.podcastId = libraryItem.media.id
|
||||
const newPeDl = new PodcastEpisodeDownload()
|
||||
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId)
|
||||
newPeDl.setData(ep, libraryItem, isAutoDownload, libraryItem.libraryId)
|
||||
this.startPodcastEpisodeDownload(newPeDl)
|
||||
}
|
||||
}
|
||||
@ -86,20 +86,20 @@ class PodcastManager {
|
||||
key: 'MessageDownloadingEpisode'
|
||||
}
|
||||
const taskDescriptionString = {
|
||||
text: `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`,
|
||||
text: `Downloading episode "${podcastEpisodeDownload.episodeTitle}".`,
|
||||
key: 'MessageTaskDownloadingEpisodeDescription',
|
||||
subs: [podcastEpisodeDownload.podcastEpisode.title]
|
||||
subs: [podcastEpisodeDownload.episodeTitle]
|
||||
}
|
||||
const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData)
|
||||
|
||||
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
|
||||
this.currentDownload = podcastEpisodeDownload
|
||||
|
||||
// If this file already exists then append the episode id to the filename
|
||||
// If this file already exists then append a uuid to the filename
|
||||
// e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3"
|
||||
// this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802)
|
||||
if (await fs.pathExists(this.currentDownload.targetPath)) {
|
||||
this.currentDownload.appendEpisodeId = true
|
||||
this.currentDownload.appendRandomId = true
|
||||
}
|
||||
|
||||
// Ignores all added files to this dir
|
||||
@ -140,7 +140,7 @@ class PodcastManager {
|
||||
}
|
||||
task.setFailed(taskFailedString)
|
||||
} else {
|
||||
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`)
|
||||
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.episodeTitle}"`)
|
||||
this.currentDownload.setFinished(true)
|
||||
task.setFinished()
|
||||
}
|
||||
@ -166,47 +166,61 @@ class PodcastManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the downloaded audio file, create the podcast episode, remove oldest episode if necessary
|
||||
* @returns {Promise<boolean>} - Returns true if added
|
||||
*/
|
||||
async scanAddPodcastEpisodeAudioFile() {
|
||||
const libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
|
||||
const libraryFile = new LibraryFile()
|
||||
await libraryFile.setDataFromPath(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
|
||||
|
||||
const audioFile = await this.probeAudioFile(libraryFile)
|
||||
if (!audioFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(this.currentDownload.libraryItem.id)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const podcastEpisode = this.currentDownload.podcastEpisode
|
||||
podcastEpisode.audioFile = audioFile
|
||||
const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, audioFile)
|
||||
|
||||
if (audioFile.chapters?.length) {
|
||||
podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch }))
|
||||
}
|
||||
libraryItem.libraryFiles.push(libraryFile.toJSON())
|
||||
libraryItem.changed('libraryFiles', true)
|
||||
|
||||
libraryItem.media.addPodcastEpisode(podcastEpisode)
|
||||
if (libraryItem.isInvalid) {
|
||||
// First episode added to an empty podcast
|
||||
libraryItem.isInvalid = false
|
||||
}
|
||||
libraryItem.libraryFiles.push(libraryFile)
|
||||
libraryItem.media.podcastEpisodes.push(podcastEpisode)
|
||||
|
||||
if (this.currentDownload.isAutoDownload) {
|
||||
// Check setting maxEpisodesToKeep and remove episode if necessary
|
||||
if (libraryItem.media.maxEpisodesToKeep && libraryItem.media.episodesWithPubDate.length > libraryItem.media.maxEpisodesToKeep) {
|
||||
Logger.info(`[PodcastManager] # of episodes (${libraryItem.media.episodesWithPubDate.length}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)
|
||||
await this.removeOldestEpisode(libraryItem, podcastEpisode.id)
|
||||
const numEpisodesWithPubDate = libraryItem.media.podcastEpisodes.filter((ep) => !!ep.publishedAt).length
|
||||
if (libraryItem.media.maxEpisodesToKeep && numEpisodesWithPubDate > libraryItem.media.maxEpisodesToKeep) {
|
||||
Logger.info(`[PodcastManager] # of episodes (${numEpisodesWithPubDate}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)
|
||||
const episodeToRemove = await this.getRemoveOldestEpisode(libraryItem, podcastEpisode.id)
|
||||
if (episodeToRemove) {
|
||||
// Remove episode from playlists
|
||||
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id])
|
||||
// Remove media progress for this episode
|
||||
await Database.mediaProgressModel.destroy({
|
||||
where: {
|
||||
mediaItemId: episodeToRemove.id
|
||||
}
|
||||
})
|
||||
await episodeToRemove.destroy()
|
||||
libraryItem.media.podcastEpisodes = libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeToRemove.id)
|
||||
|
||||
// Remove library file
|
||||
libraryItem.libraryFiles = libraryItem.libraryFiles.filter((lf) => lf.ino !== episodeToRemove.audioFile.ino)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
libraryItem.updatedAt = Date.now()
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
const podcastEpisodeExpanded = podcastEpisode.toJSONExpanded()
|
||||
podcastEpisodeExpanded.libraryItem = libraryItem.toJSONExpanded()
|
||||
await libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)
|
||||
podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()
|
||||
SocketAuthority.emitter('episode_added', podcastEpisodeExpanded)
|
||||
|
||||
if (this.currentDownload.isAutoDownload) {
|
||||
@ -217,45 +231,53 @@ class PodcastManager {
|
||||
return true
|
||||
}
|
||||
|
||||
async removeOldestEpisode(libraryItem, episodeIdJustDownloaded) {
|
||||
var smallestPublishedAt = 0
|
||||
var oldestEpisode = null
|
||||
libraryItem.media.episodesWithPubDate
|
||||
.filter((ep) => ep.id !== episodeIdJustDownloaded)
|
||||
.forEach((ep) => {
|
||||
if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) {
|
||||
smallestPublishedAt = ep.publishedAt
|
||||
oldestEpisode = ep
|
||||
}
|
||||
})
|
||||
// TODO: Should we check for open playback sessions for this episode?
|
||||
// TODO: remove all user progress for this episode
|
||||
/**
|
||||
* Find oldest episode publishedAt and delete the audio file
|
||||
*
|
||||
* @param {import('../models/LibraryItem').LibraryItemExpanded} libraryItem
|
||||
* @param {string} episodeIdJustDownloaded
|
||||
* @returns {Promise<import('../models/PodcastEpisode')|null>} - Returns the episode to remove
|
||||
*/
|
||||
async getRemoveOldestEpisode(libraryItem, episodeIdJustDownloaded) {
|
||||
let smallestPublishedAt = 0
|
||||
/** @type {import('../models/PodcastEpisode')} */
|
||||
let oldestEpisode = null
|
||||
|
||||
/** @type {import('../models/PodcastEpisode')[]} */
|
||||
const podcastEpisodes = libraryItem.media.podcastEpisodes
|
||||
|
||||
for (const ep of podcastEpisodes) {
|
||||
if (ep.id === episodeIdJustDownloaded || !ep.publishedAt) continue
|
||||
|
||||
if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) {
|
||||
smallestPublishedAt = ep.publishedAt
|
||||
oldestEpisode = ep
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestEpisode?.audioFile) {
|
||||
Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`)
|
||||
const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path)
|
||||
if (successfullyDeleted) {
|
||||
libraryItem.media.removeEpisode(oldestEpisode.id)
|
||||
libraryItem.removeLibraryFile(oldestEpisode.audioFile.ino)
|
||||
return true
|
||||
return oldestEpisode
|
||||
} else {
|
||||
Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async getLibraryFile(path, relPath) {
|
||||
var newLibFile = new LibraryFile()
|
||||
await newLibFile.setDataFromPath(path, relPath)
|
||||
return newLibFile
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {LibraryFile} libraryFile
|
||||
* @returns {Promise<AudioFile|null>}
|
||||
*/
|
||||
async probeAudioFile(libraryFile) {
|
||||
const path = libraryFile.metadata.path
|
||||
const mediaProbeData = await prober.probe(path)
|
||||
if (mediaProbeData.error) {
|
||||
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
|
||||
return false
|
||||
return null
|
||||
}
|
||||
const newAudioFile = new AudioFile()
|
||||
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
|
||||
@ -263,18 +285,23 @@ class PodcastManager {
|
||||
return newAudioFile
|
||||
}
|
||||
|
||||
// Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @returns {Promise<boolean>} - Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
|
||||
*/
|
||||
async runEpisodeCheck(libraryItem) {
|
||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
|
||||
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
|
||||
const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
|
||||
const latestEpisodePublishedAt = libraryItem.media.getLatestEpisodePublishedAt()
|
||||
|
||||
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
|
||||
// lastEpisodeCheckDate will be the current time when adding a new podcast
|
||||
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
|
||||
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
|
||||
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" | Last check: ${new Date(lastEpisodeCheck)} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
|
||||
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
|
||||
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheck
|
||||
// lastEpisodeCheck will be the current time when adding a new podcast
|
||||
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheck
|
||||
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
|
||||
|
||||
const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
|
||||
Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`)
|
||||
|
||||
if (!newEpisodes) {
|
||||
@ -283,37 +310,48 @@ class PodcastManager {
|
||||
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
|
||||
this.failedCheckMap[libraryItem.id]++
|
||||
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
|
||||
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
|
||||
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`)
|
||||
libraryItem.media.autoDownloadEpisodes = false
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
} else {
|
||||
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
|
||||
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`)
|
||||
}
|
||||
} else if (newEpisodes.length) {
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`)
|
||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes, true)
|
||||
} else {
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
|
||||
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.title}"`)
|
||||
}
|
||||
|
||||
libraryItem.media.lastEpisodeCheck = Date.now()
|
||||
libraryItem.updatedAt = Date.now()
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
libraryItem.media.lastEpisodeCheck = new Date()
|
||||
await libraryItem.media.save()
|
||||
|
||||
libraryItem.changed('updatedAt', true)
|
||||
await libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
|
||||
return libraryItem.media.autoDownloadEpisodes
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} podcastLibraryItem
|
||||
* @param {number} dateToCheckForEpisodesAfter - Unix timestamp
|
||||
* @param {number} maxNewEpisodes
|
||||
* @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]|null>}
|
||||
*/
|
||||
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) {
|
||||
if (!podcastLibraryItem.media.metadata.feedUrl) {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
|
||||
return false
|
||||
if (!podcastLibraryItem.media.feedURL) {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)
|
||||
return null
|
||||
}
|
||||
const feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
||||
const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL)
|
||||
if (!feed?.episodes) {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed)
|
||||
return false
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed)
|
||||
return null
|
||||
}
|
||||
|
||||
// Filter new and not already has
|
||||
@ -326,23 +364,34 @@ class PodcastManager {
|
||||
return newEpisodes
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {*} maxEpisodesToDownload
|
||||
* @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]>}
|
||||
*/
|
||||
async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) {
|
||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck, maxEpisodesToDownload)
|
||||
if (newEpisodes.length) {
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||
const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
|
||||
const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never'
|
||||
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||
|
||||
const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload)
|
||||
if (newEpisodes?.length) {
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`)
|
||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes, false)
|
||||
} else {
|
||||
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`)
|
||||
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.title}"`)
|
||||
}
|
||||
|
||||
libraryItem.media.lastEpisodeCheck = Date.now()
|
||||
libraryItem.updatedAt = Date.now()
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
libraryItem.media.lastEpisodeCheck = new Date()
|
||||
await libraryItem.media.save()
|
||||
|
||||
return newEpisodes
|
||||
libraryItem.changed('updatedAt', true)
|
||||
await libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
|
||||
return newEpisodes || []
|
||||
}
|
||||
|
||||
async findEpisode(rssFeedUrl, searchTitle) {
|
||||
@ -518,64 +567,123 @@ class PodcastManager {
|
||||
continue
|
||||
}
|
||||
|
||||
const newPodcastMetadata = {
|
||||
title: feed.metadata.title,
|
||||
author: feed.metadata.author,
|
||||
description: feed.metadata.description,
|
||||
releaseDate: '',
|
||||
genres: [...feed.metadata.categories],
|
||||
feedUrl: feed.metadata.feedUrl,
|
||||
imageUrl: feed.metadata.image,
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
language: '',
|
||||
numEpisodes: feed.numEpisodes
|
||||
}
|
||||
let newLibraryItem = null
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
||||
|
||||
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
||||
const libraryItemPayload = {
|
||||
path: podcastPath,
|
||||
relPath: podcastFilename,
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
ino: libraryItemFolderStats.ino,
|
||||
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
||||
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
||||
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
||||
media: {
|
||||
metadata: newPodcastMetadata,
|
||||
autoDownloadEpisodes
|
||||
const podcastPayload = {
|
||||
autoDownloadEpisodes,
|
||||
metadata: {
|
||||
title: feed.metadata.title,
|
||||
author: feed.metadata.author,
|
||||
description: feed.metadata.description,
|
||||
releaseDate: '',
|
||||
genres: [...feed.metadata.categories],
|
||||
feedUrl: feed.metadata.feedUrl,
|
||||
imageUrl: feed.metadata.image,
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
language: '',
|
||||
numEpisodes: feed.numEpisodes
|
||||
}
|
||||
}
|
||||
const podcast = await Database.podcastModel.createFromRequest(podcastPayload, transaction)
|
||||
|
||||
newLibraryItem = await Database.libraryItemModel.create(
|
||||
{
|
||||
ino: libraryItemFolderStats.ino,
|
||||
path: podcastPath,
|
||||
relPath: podcastFilename,
|
||||
mediaId: podcast.id,
|
||||
mediaType: 'podcast',
|
||||
isFile: false,
|
||||
isMissing: false,
|
||||
isInvalid: false,
|
||||
mtime: libraryItemFolderStats.mtimeMs || 0,
|
||||
ctime: libraryItemFolderStats.ctimeMs || 0,
|
||||
birthtime: libraryItemFolderStats.birthtimeMs || 0,
|
||||
size: 0,
|
||||
libraryFiles: [],
|
||||
extraData: {},
|
||||
libraryId: folder.libraryId,
|
||||
libraryFolderId: folder.id
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
await transaction.commit()
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast library item for "${feed.metadata.title}"`, error)
|
||||
const taskTitleStringFeed = {
|
||||
text: 'OPML import feed',
|
||||
key: 'MessageTaskOpmlImportFeed'
|
||||
}
|
||||
const taskDescriptionStringPodcast = {
|
||||
text: `Creating podcast "${feed.metadata.title}"`,
|
||||
key: 'MessageTaskOpmlImportFeedPodcastDescription',
|
||||
subs: [feed.metadata.title]
|
||||
}
|
||||
const taskErrorString = {
|
||||
text: 'Failed to create podcast library item',
|
||||
key: 'MessageTaskOpmlImportFeedPodcastFailed'
|
||||
}
|
||||
TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString)
|
||||
continue
|
||||
}
|
||||
|
||||
const libraryItem = new LibraryItem()
|
||||
libraryItem.setData('podcast', libraryItemPayload)
|
||||
newLibraryItem.media = await newLibraryItem.getMediaExpanded()
|
||||
|
||||
// Download and save cover image
|
||||
if (newPodcastMetadata.imageUrl) {
|
||||
// TODO: Scan cover image to library files
|
||||
if (typeof feed.metadata.image === 'string' && feed.metadata.image.startsWith('http')) {
|
||||
// Podcast cover will always go into library item folder
|
||||
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, newPodcastMetadata.imageUrl, true)
|
||||
if (coverResponse) {
|
||||
if (coverResponse.error) {
|
||||
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Download cover error from "${newPodcastMetadata.imageUrl}": ${coverResponse.error}`)
|
||||
} else if (coverResponse.cover) {
|
||||
libraryItem.media.coverPath = coverResponse.cover
|
||||
const coverResponse = await CoverManager.downloadCoverFromUrlNew(feed.metadata.image, newLibraryItem.id, newLibraryItem.path, true)
|
||||
if (coverResponse.error) {
|
||||
Logger.error(`[PodcastManager] Download cover error from "${feed.metadata.image}": ${coverResponse.error}`)
|
||||
} else if (coverResponse.cover) {
|
||||
const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
|
||||
if (!coverImageFileStats) {
|
||||
Logger.error(`[PodcastManager] Failed to get cover image stats for "${coverResponse.cover}"`)
|
||||
} else {
|
||||
// Add libraryFile to libraryItem and coverPath to podcast
|
||||
const newLibraryFile = {
|
||||
ino: coverImageFileStats.ino,
|
||||
fileType: 'image',
|
||||
addedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
metadata: {
|
||||
filename: Path.basename(coverResponse.cover),
|
||||
ext: Path.extname(coverResponse.cover).slice(1),
|
||||
path: coverResponse.cover,
|
||||
relPath: Path.basename(coverResponse.cover),
|
||||
size: coverImageFileStats.size,
|
||||
mtimeMs: coverImageFileStats.mtimeMs || 0,
|
||||
ctimeMs: coverImageFileStats.ctimeMs || 0,
|
||||
birthtimeMs: coverImageFileStats.birthtimeMs || 0
|
||||
}
|
||||
}
|
||||
newLibraryItem.libraryFiles.push(newLibraryFile)
|
||||
newLibraryItem.changed('libraryFiles', true)
|
||||
await newLibraryItem.save()
|
||||
|
||||
newLibraryItem.media.coverPath = coverResponse.cover
|
||||
await newLibraryItem.media.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Database.createLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
|
||||
SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
|
||||
|
||||
// Turn on podcast auto download cron if not already on
|
||||
if (libraryItem.media.autoDownloadEpisodes) {
|
||||
cronManager.checkUpdatePodcastCron(libraryItem)
|
||||
if (newLibraryItem.media.autoDownloadEpisodes) {
|
||||
cronManager.checkUpdatePodcastCron(newLibraryItem)
|
||||
}
|
||||
|
||||
numPodcastsAdded++
|
||||
}
|
||||
|
||||
const taskFinishedString = {
|
||||
text: `Added ${numPodcastsAdded} podcasts`,
|
||||
key: 'MessageTaskOpmlImportFinished',
|
||||
|
@ -98,11 +98,22 @@ class RssFeedManager {
|
||||
podcastId: feed.entity.mediaId
|
||||
},
|
||||
attributes: ['id', 'updatedAt'],
|
||||
order: [['createdAt', 'DESC']]
|
||||
order: [['updatedAt', 'DESC']]
|
||||
})
|
||||
|
||||
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
|
||||
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
|
||||
}
|
||||
} else {
|
||||
const book = await Database.bookModel.findOne({
|
||||
where: {
|
||||
id: feed.entity.mediaId
|
||||
},
|
||||
attributes: ['id', 'updatedAt']
|
||||
})
|
||||
if (book && book.updatedAt > newEntityUpdatedAt) {
|
||||
newEntityUpdatedAt = book.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
return newEntityUpdatedAt > feed.entityUpdatedAt
|
||||
@ -111,7 +122,7 @@ class RssFeedManager {
|
||||
attributes: ['id', 'updatedAt'],
|
||||
include: {
|
||||
model: Database.bookModel,
|
||||
attributes: ['id'],
|
||||
attributes: ['id', 'audioFiles', 'updatedAt'],
|
||||
through: {
|
||||
attributes: []
|
||||
},
|
||||
@ -122,13 +133,16 @@ class RssFeedManager {
|
||||
}
|
||||
})
|
||||
|
||||
const totalBookTracks = feed.entity.books.reduce((total, book) => total + book.includedAudioFiles.length, 0)
|
||||
if (feed.feedEpisodes.length !== totalBookTracks) {
|
||||
return true
|
||||
}
|
||||
|
||||
let newEntityUpdatedAt = feed.entity.updatedAt
|
||||
|
||||
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
|
||||
if (book.libraryItem.updatedAt > mostRecent) {
|
||||
return book.libraryItem.updatedAt
|
||||
}
|
||||
return mostRecent
|
||||
let updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
|
||||
return updatedAt > mostRecent ? updatedAt : mostRecent
|
||||
}, 0)
|
||||
|
||||
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
|
||||
@ -151,6 +165,9 @@ class RssFeedManager {
|
||||
let feed = await Database.feedModel.findOne({
|
||||
where: {
|
||||
slug: req.params.slug
|
||||
},
|
||||
include: {
|
||||
model: Database.feedEpisodeModel
|
||||
}
|
||||
})
|
||||
if (!feed) {
|
||||
@ -163,8 +180,6 @@ class RssFeedManager {
|
||||
if (feedRequiresUpdate) {
|
||||
Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
|
||||
feed = await feed.updateFeedForEntity()
|
||||
} else {
|
||||
feed.feedEpisodes = await feed.getFeedEpisodes()
|
||||
}
|
||||
|
||||
const xml = feed.buildXml(req.originalHostPrefix)
|
||||
|
@ -11,3 +11,5 @@ Please add a record of every database migration that you create to this file. Th
|
||||
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
|
||||
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
|
||||
| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables |
|
||||
| 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 |
|
||||
|
68
server/migrations/v2.17.6-share-add-isdownloadable.js
Normal file
68
server/migrations/v2.17.6-share-add-isdownloadable.js
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.17.6'
|
||||
const migrationName = `${migrationVersion}-share-add-isdownloadable`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This migration script adds the isDownloadable column to the mediaItemShares table.
|
||||
*
|
||||
* @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 } }) {
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
if (await queryInterface.tableExists('mediaItemShares')) {
|
||||
const tableDescription = await queryInterface.describeTable('mediaItemShares')
|
||||
if (!tableDescription.isDownloadable) {
|
||||
logger.info(`${loggerPrefix} Adding isDownloadable column to mediaItemShares table`)
|
||||
await queryInterface.addColumn('mediaItemShares', 'isDownloadable', {
|
||||
type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false
|
||||
})
|
||||
logger.info(`${loggerPrefix} Added isDownloadable column to mediaItemShares table`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} isDownloadable column already exists in mediaItemShares table`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} mediaItemShares table does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This migration script removes the isDownloadable column from the mediaItemShares table.
|
||||
*
|
||||
* @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 } }) {
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
if (await queryInterface.tableExists('mediaItemShares')) {
|
||||
const tableDescription = await queryInterface.describeTable('mediaItemShares')
|
||||
if (tableDescription.isDownloadable) {
|
||||
logger.info(`${loggerPrefix} Removing isDownloadable column from mediaItemShares table`)
|
||||
await queryInterface.removeColumn('mediaItemShares', 'isDownloadable')
|
||||
logger.info(`${loggerPrefix} Removed isDownloadable column from mediaItemShares table`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} isDownloadable column does not exist in mediaItemShares table`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} mediaItemShares table does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
83
server/migrations/v2.17.7-add-indices.js
Normal file
83
server/migrations/v2.17.7-add-indices.js
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.17.7'
|
||||
const migrationName = `${migrationVersion}-add-indices`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This upward migration adds some indices to the libraryItems and books tables to improve query performance
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
// Upwards migration script
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])
|
||||
await addIndex(queryInterface, logger, 'books', ['duration'])
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration script removes the indices added in the upward migration script
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])
|
||||
await removeIndex(queryInterface, logger, 'books', ['duration'])
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to add an index to a table. If the index already exists, it logs a message and continues.
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface
|
||||
* @param {import ('../Logger')} logger
|
||||
* @param {string} tableName
|
||||
* @param {string[]} columns
|
||||
*/
|
||||
async function addIndex(queryInterface, logger, tableName, columns) {
|
||||
try {
|
||||
logger.info(`${loggerPrefix} adding index [${columns.join(', ')}] to table "${tableName}"`)
|
||||
await queryInterface.addIndex(tableName, columns)
|
||||
logger.info(`${loggerPrefix} added index [${columns.join(', ')}] to table "${tableName}"`)
|
||||
} catch (error) {
|
||||
if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) {
|
||||
logger.info(`${loggerPrefix} index [${columns.join(', ')}] for table "${tableName}" already exists`)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to remove an index from a table.
|
||||
* Sequelize implemets it using DROP INDEX IF EXISTS, so it won't throw an error if the index doesn't exist.
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface
|
||||
* @param {import ('../Logger')} logger
|
||||
* @param {string} tableName
|
||||
* @param {string[]} columns
|
||||
*/
|
||||
async function removeIndex(queryInterface, logger, tableName, columns) {
|
||||
logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`)
|
||||
await queryInterface.removeIndex(tableName, columns)
|
||||
logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
@ -1,5 +1,7 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
|
||||
const parseNameString = require('../utils/parsers/parseNameString')
|
||||
|
||||
/**
|
||||
* @typedef EBookFileObject
|
||||
@ -60,6 +62,13 @@ const Logger = require('../Logger')
|
||||
* @property {ChapterObject[]} chapters
|
||||
* @property {Object} metaTags
|
||||
* @property {string} mimeType
|
||||
*
|
||||
* @typedef AudioTrackProperties
|
||||
* @property {string} title
|
||||
* @property {string} contentUrl
|
||||
* @property {number} startOffset
|
||||
*
|
||||
* @typedef {AudioFileObject & AudioTrackProperties} AudioTrack
|
||||
*/
|
||||
|
||||
class Book extends Model {
|
||||
@ -113,8 +122,12 @@ class Book extends Model {
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./Author')[]} - optional if expanded */
|
||||
this.authors
|
||||
/** @type {import('./Series')[]} - optional if expanded */
|
||||
this.series
|
||||
}
|
||||
|
||||
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
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
@ -321,10 +308,10 @@ class Book extends Model {
|
||||
// },
|
||||
{
|
||||
fields: ['publishedYear']
|
||||
},
|
||||
{
|
||||
fields: ['duration']
|
||||
}
|
||||
// {
|
||||
// fields: ['duration']
|
||||
// }
|
||||
]
|
||||
}
|
||||
)
|
||||
@ -343,18 +330,395 @@ class Book extends Model {
|
||||
}
|
||||
return this.authors.map((au) => au.name).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Comma separated array of author names in Last, First format
|
||||
* Requires authors to be loaded
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
get authorNameLF() {
|
||||
if (this.authors === undefined) {
|
||||
Logger.error(`[Book] authorNameLF: Cannot get authorNameLF because authors are not loaded`)
|
||||
return ''
|
||||
}
|
||||
|
||||
// Last, First
|
||||
if (!this.authors.length) return ''
|
||||
return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Comma separated array of series with sequence
|
||||
* Requires series to be loaded
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
get seriesName() {
|
||||
if (this.series === undefined) {
|
||||
Logger.error(`[Book] seriesName: Cannot get seriesName because series are not loaded`)
|
||||
return ''
|
||||
}
|
||||
|
||||
if (!this.series.length) return ''
|
||||
return this.series
|
||||
.map((se) => {
|
||||
const sequence = se.bookSeries?.sequence || ''
|
||||
if (!sequence) return se.name
|
||||
return `${se.name} #${sequence}`
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
get includedAudioFiles() {
|
||||
return this.audioFiles.filter((af) => !af.exclude)
|
||||
}
|
||||
get trackList() {
|
||||
|
||||
get hasMediaFiles() {
|
||||
return !!this.hasAudioTracks || !!this.ebookFile
|
||||
}
|
||||
|
||||
get hasAudioTracks() {
|
||||
return !!this.includedAudioFiles.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported mime types are sent from the web client and are retrieved using the browser Audio player "canPlayType" function.
|
||||
*
|
||||
* @param {string[]} supportedMimeTypes
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkCanDirectPlay(supportedMimeTypes) {
|
||||
if (!Array.isArray(supportedMimeTypes)) {
|
||||
Logger.error(`[Book] checkCanDirectPlay: supportedMimeTypes is not an array`, supportedMimeTypes)
|
||||
return false
|
||||
}
|
||||
return this.includedAudioFiles.every((af) => supportedMimeTypes.includes(af.mimeType))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the track list to be used in client audio players
|
||||
* AudioTrack is the AudioFile with startOffset, contentUrl and title
|
||||
*
|
||||
* @param {string} libraryItemId
|
||||
* @returns {AudioTrack[]}
|
||||
*/
|
||||
getTracklist(libraryItemId) {
|
||||
let startOffset = 0
|
||||
return this.includedAudioFiles.map((af) => {
|
||||
const track = structuredClone(af)
|
||||
track.title = af.metadata.filename
|
||||
track.startOffset = startOffset
|
||||
track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}`
|
||||
startOffset += track.duration
|
||||
return track
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {ChapterObject[]}
|
||||
*/
|
||||
getChapters() {
|
||||
return structuredClone(this.chapters) || []
|
||||
}
|
||||
|
||||
getPlaybackTitle() {
|
||||
return this.title
|
||||
}
|
||||
|
||||
getPlaybackAuthor() {
|
||||
return this.authorName
|
||||
}
|
||||
|
||||
getPlaybackDuration() {
|
||||
return this.duration
|
||||
}
|
||||
|
||||
/**
|
||||
* Total file size of all audio files and ebook file
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
get size() {
|
||||
let total = 0
|
||||
this.audioFiles.forEach((af) => (total += af.metadata.size))
|
||||
if (this.ebookFile) {
|
||||
total += this.ebookFile.metadata.size
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
getAbsMetadataJson() {
|
||||
return {
|
||||
tags: this.tags || [],
|
||||
chapters: this.chapters?.map((c) => ({ ...c })) || [],
|
||||
title: this.title,
|
||||
subtitle: this.subtitle,
|
||||
authors: this.authors.map((a) => a.name),
|
||||
narrators: this.narrators,
|
||||
series: this.series.map((se) => {
|
||||
const sequence = se.bookSeries?.sequence || ''
|
||||
if (!sequence) return se.name
|
||||
return `${se.name} #${sequence}`
|
||||
}),
|
||||
genres: this.genres || [],
|
||||
publishedYear: this.publishedYear,
|
||||
publishedDate: this.publishedDate,
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
isbn: this.isbn,
|
||||
asin: this.asin,
|
||||
language: this.language,
|
||||
explicit: !!this.explicit,
|
||||
abridged: !!this.abridged
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} payload - old book object
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async updateFromRequest(payload) {
|
||||
if (!payload) return false
|
||||
|
||||
let hasUpdates = false
|
||||
|
||||
if (payload.metadata) {
|
||||
const metadataStringKeys = ['title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language']
|
||||
metadataStringKeys.forEach((key) => {
|
||||
if (typeof payload.metadata[key] === 'string' && this[key] !== payload.metadata[key]) {
|
||||
this[key] = payload.metadata[key] || null
|
||||
|
||||
if (key === 'title') {
|
||||
this.titleIgnorePrefix = getTitleIgnorePrefix(this.title)
|
||||
}
|
||||
|
||||
hasUpdates = true
|
||||
}
|
||||
})
|
||||
if (payload.metadata.explicit !== undefined && this.explicit !== !!payload.metadata.explicit) {
|
||||
this.explicit = !!payload.metadata.explicit
|
||||
hasUpdates = true
|
||||
}
|
||||
if (payload.metadata.abridged !== undefined && this.abridged !== !!payload.metadata.abridged) {
|
||||
this.abridged = !!payload.metadata.abridged
|
||||
hasUpdates = true
|
||||
}
|
||||
const arrayOfStringsKeys = ['narrators', 'genres']
|
||||
arrayOfStringsKeys.forEach((key) => {
|
||||
if (Array.isArray(payload.metadata[key]) && !payload.metadata[key].some((item) => typeof item !== 'string') && JSON.stringify(this[key]) !== JSON.stringify(payload.metadata[key])) {
|
||||
this[key] = payload.metadata[key]
|
||||
this.changed(key, true)
|
||||
hasUpdates = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.tags) && !payload.tags.some((tag) => typeof tag !== 'string') && JSON.stringify(this.tags) !== JSON.stringify(payload.tags)) {
|
||||
this.tags = payload.tags
|
||||
this.changed('tags', true)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// TODO: Remove support for updating audioFiles, chapters and ebookFile here
|
||||
const arrayOfObjectsKeys = ['audioFiles', 'chapters']
|
||||
arrayOfObjectsKeys.forEach((key) => {
|
||||
if (Array.isArray(payload[key]) && !payload[key].some((item) => typeof item !== 'object') && JSON.stringify(this[key]) !== JSON.stringify(payload[key])) {
|
||||
this[key] = payload[key]
|
||||
this.changed(key, true)
|
||||
hasUpdates = true
|
||||
}
|
||||
})
|
||||
if (payload.ebookFile && JSON.stringify(this.ebookFile) !== JSON.stringify(payload.ebookFile)) {
|
||||
this.ebookFile = payload.ebookFile
|
||||
this.changed('ebookFile', true)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
Logger.debug(`[Book] "${this.title}" changed keys:`, this.changed())
|
||||
await this.save()
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -1,7 +1,5 @@
|
||||
const { DataTypes, Model, Sequelize } = require('sequelize')
|
||||
|
||||
const oldCollection = require('../objects/Collection')
|
||||
|
||||
class Collection extends Model {
|
||||
constructor(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 {string} [libraryId]
|
||||
* @param {string[]} [include]
|
||||
* @returns {Promise<oldCollection[]>} oldCollection.toJSONExpanded
|
||||
* @async
|
||||
*/
|
||||
static async getOldCollectionsJsonExpanded(user, libraryId, include) {
|
||||
let collectionWhere = null
|
||||
@ -79,8 +77,6 @@ class Collection extends Model {
|
||||
// TODO: Handle user permission restrictions on initial query
|
||||
return collections
|
||||
.map((c) => {
|
||||
const oldCollection = this.getOldCollection(c)
|
||||
|
||||
// Filter books using user permissions
|
||||
const books =
|
||||
c.books?.filter((b) => {
|
||||
@ -95,20 +91,14 @@ class Collection extends Model {
|
||||
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
|
||||
if (!books.length && oldCollection.books.length) {
|
||||
if (!books.length && c.books.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
|
||||
this.books = books
|
||||
|
||||
const collectionExpanded = c.toOldJSONExpanded()
|
||||
|
||||
// Map feed if found
|
||||
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
|
||||
* @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 {string[]} [include]
|
||||
* @returns {Promise<oldCollection>} oldCollection.toJSONExpanded
|
||||
* @async
|
||||
*/
|
||||
async getOldJsonExpanded(user, include) {
|
||||
this.books =
|
||||
(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')]
|
||||
})) || []
|
||||
this.books = await this.getBooksExpandedWithLibraryItem()
|
||||
|
||||
// Filter books using user permissions
|
||||
// TODO: Handle user permission restrictions on initial query
|
||||
const books =
|
||||
this.books?.filter((b) => {
|
||||
if (user) {
|
||||
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
|
||||
return false
|
||||
}
|
||||
if (b.explicit === true && !user.canAccessExplicitContent) {
|
||||
return false
|
||||
}
|
||||
if (user) {
|
||||
const books = this.books.filter((b) => {
|
||||
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
|
||||
return false
|
||||
}
|
||||
if (b.explicit === true && !user.canAccessExplicitContent) {
|
||||
return false
|
||||
}
|
||||
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
|
||||
if (!books.length && this.books.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Users with restricted permissions will not see this collection
|
||||
if (!books.length && this.books.length) {
|
||||
return null
|
||||
this.books = books
|
||||
}
|
||||
|
||||
const collectionExpanded = this.toOldJSONExpanded(libraryItems)
|
||||
const collectionExpanded = this.toOldJSONExpanded()
|
||||
|
||||
if (include?.includes('rssfeed')) {
|
||||
const feeds = await this.getFeeds()
|
||||
@ -357,10 +257,10 @@ class Collection extends Model {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string[]} libraryItemIds
|
||||
* @param {string[]} [libraryItemIds=[]]
|
||||
* @returns
|
||||
*/
|
||||
toOldJSON(libraryItemIds) {
|
||||
toOldJSON(libraryItemIds = []) {
|
||||
return {
|
||||
id: this.id,
|
||||
libraryId: this.libraryId,
|
||||
@ -372,19 +272,19 @@ class Collection extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../objects/LibraryItem')} oldLibraryItems
|
||||
* @returns
|
||||
*/
|
||||
toOldJSONExpanded(oldLibraryItems) {
|
||||
const json = this.toOldJSON(oldLibraryItems.map((li) => li.id))
|
||||
json.books = json.books
|
||||
.map((libraryItemId) => {
|
||||
const book = oldLibraryItems.find((li) => li.id === libraryItemId)
|
||||
return book ? book.toJSONExpanded() : null
|
||||
})
|
||||
.filter((b) => !!b)
|
||||
toOldJSONExpanded() {
|
||||
if (!this.books) {
|
||||
throw new Error('Books are required to expand Collection')
|
||||
}
|
||||
|
||||
const json = this.toOldJSON()
|
||||
json.books = this.books.map((book) => {
|
||||
const libraryItem = book.libraryItem
|
||||
delete book.libraryItem
|
||||
libraryItem.media = book
|
||||
return libraryItem.toOldJSONExpanded()
|
||||
})
|
||||
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
@ -16,15 +16,6 @@ class CollectionBook extends Model {
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
static removeByIds(collectionId, bookId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
bookId,
|
||||
collectionId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static init(sequelize) {
|
||||
super.init(
|
||||
{
|
||||
|
@ -107,6 +107,9 @@ class Feed extends Model {
|
||||
entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => {
|
||||
return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent
|
||||
}, entityUpdatedAt)
|
||||
} else if (libraryItem.media.updatedAt > entityUpdatedAt) {
|
||||
// Book feeds will use Book.updatedAt if more recent
|
||||
entityUpdatedAt = libraryItem.media.updatedAt
|
||||
}
|
||||
|
||||
const feedObj = {
|
||||
@ -187,7 +190,8 @@ class Feed extends Model {
|
||||
const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length)
|
||||
|
||||
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)
|
||||
|
||||
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
|
||||
@ -275,7 +279,8 @@ class Feed extends Model {
|
||||
static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) {
|
||||
const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length)
|
||||
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
|
||||
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
|
||||
const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
|
||||
return updatedAt > mostRecent ? updatedAt : mostRecent
|
||||
}, seriesExpanded.updatedAt)
|
||||
|
||||
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
|
||||
@ -516,17 +521,24 @@ class Feed extends Model {
|
||||
try {
|
||||
const updatedFeed = await this.update(feedObj, { transaction })
|
||||
|
||||
// Remove existing feed episodes
|
||||
await feedEpisodeModel.destroy({
|
||||
where: {
|
||||
feedId: this.id
|
||||
},
|
||||
transaction
|
||||
})
|
||||
const existingFeedEpisodeIds = this.feedEpisodes.map((ep) => ep.id)
|
||||
|
||||
// Create new feed episodes
|
||||
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()
|
||||
|
||||
return updatedFeed
|
||||
|
@ -53,9 +53,10 @@ class FeedEpisode extends Model {
|
||||
* @param {import('./Feed')} feed
|
||||
* @param {string} slug
|
||||
* @param {import('./PodcastEpisode')} episode
|
||||
* @param {string} [existingEpisodeId]
|
||||
*/
|
||||
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) {
|
||||
const episodeId = uuidv4()
|
||||
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisodeId = null) {
|
||||
const episodeId = existingEpisodeId || uuidv4()
|
||||
return {
|
||||
id: episodeId,
|
||||
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))
|
||||
}
|
||||
|
||||
let numExisting = 0
|
||||
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}`)
|
||||
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
||||
Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
|
||||
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
|
||||
*
|
||||
* @param {import('./Book').AudioTrack[]} trackList
|
||||
* @param {import('./Book')} book
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static checkUseChapterTitlesForEpisodes(book) {
|
||||
const tracks = book.trackList || []
|
||||
static checkUseChapterTitlesForEpisodes(trackList, book) {
|
||||
const chapters = book.chapters || []
|
||||
if (tracks.length !== chapters.length) return false
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) {
|
||||
if (trackList.length !== chapters.length) return false
|
||||
for (let i = 0; i < trackList.length; i++) {
|
||||
if (Math.abs(chapters[i].start - trackList[i].startOffset) >= 1) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -127,19 +135,20 @@ class FeedEpisode extends Model {
|
||||
* @param {string} slug
|
||||
* @param {import('./Book').AudioFileObject} audioTrack
|
||||
* @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>
|
||||
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
|
||||
const audiobookPubDate = date.format(new Date(pubDateStart.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
||||
|
||||
const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`
|
||||
|
||||
let title = audioTrack.title
|
||||
if (book.trackList.length == 1) {
|
||||
let title = Path.basename(audioTrack.metadata.filename, Path.extname(audioTrack.metadata.filename))
|
||||
if (book.includedAudioFiles.length == 1) {
|
||||
// If audiobook is a single file, use book title instead of chapter/file title
|
||||
title = book.title
|
||||
} else {
|
||||
@ -176,19 +185,27 @@ class FeedEpisode extends Model {
|
||||
* @returns {Promise<FeedEpisode[]>}
|
||||
*/
|
||||
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 = []
|
||||
for (const track of libraryItemExpanded.media.trackList) {
|
||||
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles))
|
||||
let numExisting = 0
|
||||
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}`)
|
||||
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
||||
Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
|
||||
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 {string} slug
|
||||
* @param {import('sequelize').Transaction} transaction
|
||||
@ -200,14 +217,22 @@ class FeedEpisode extends Model {
|
||||
}).libraryItem.createdAt
|
||||
|
||||
const feedEpisodeObjs = []
|
||||
let numExisting = 0
|
||||
for (const book of books) {
|
||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book)
|
||||
for (const track of book.trackList) {
|
||||
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles))
|
||||
const trackList = book.libraryItem.getTrackList()
|
||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, book)
|
||||
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}`)
|
||||
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
||||
Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
|
||||
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -122,45 +122,6 @@ class LibraryItem extends Model {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Currently unused because this is too slow and uses too much mem
|
||||
* @param {import('sequelize').WhereOptions} [where]
|
||||
* @returns {Array<objects.LibraryItem>} old library items
|
||||
*/
|
||||
static async getAllOldLibraryItems(where = null) {
|
||||
let libraryItems = await this.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.book,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.author,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.podcast,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.podcastEpisode
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
return libraryItems.map((ti) => this.getOldLibraryItem(ti))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an expanded LibraryItem into an old library item
|
||||
*
|
||||
@ -199,40 +160,6 @@ class LibraryItem extends Model {
|
||||
})
|
||||
}
|
||||
|
||||
static async fullCreateFromOld(oldLibraryItem) {
|
||||
const newLibraryItem = await this.create(this.getFromOld(oldLibraryItem))
|
||||
|
||||
if (oldLibraryItem.mediaType === 'book') {
|
||||
const bookObj = this.sequelize.models.book.getFromOld(oldLibraryItem.media)
|
||||
bookObj.libraryItemId = newLibraryItem.id
|
||||
const newBook = await this.sequelize.models.book.create(bookObj)
|
||||
|
||||
const oldBookAuthors = oldLibraryItem.media.metadata.authors || []
|
||||
const oldBookSeriesAll = oldLibraryItem.media.metadata.series || []
|
||||
|
||||
for (const oldBookAuthor of oldBookAuthors) {
|
||||
await this.sequelize.models.bookAuthor.create({ authorId: oldBookAuthor.id, bookId: newBook.id })
|
||||
}
|
||||
for (const oldSeries of oldBookSeriesAll) {
|
||||
await this.sequelize.models.bookSeries.create({ seriesId: oldSeries.id, bookId: newBook.id, sequence: oldSeries.sequence })
|
||||
}
|
||||
} else if (oldLibraryItem.mediaType === 'podcast') {
|
||||
const podcastObj = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media)
|
||||
podcastObj.libraryItemId = newLibraryItem.id
|
||||
const newPodcast = await this.sequelize.models.podcast.create(podcastObj)
|
||||
|
||||
const oldEpisodes = oldLibraryItem.media.episodes || []
|
||||
for (const oldEpisode of oldEpisodes) {
|
||||
const episodeObj = this.sequelize.models.podcastEpisode.getFromOld(oldEpisode)
|
||||
episodeObj.libraryItemId = newLibraryItem.id
|
||||
episodeObj.podcastId = newPodcast.id
|
||||
await this.sequelize.models.podcastEpisode.create(episodeObj)
|
||||
}
|
||||
}
|
||||
|
||||
return newLibraryItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates libraryItem, book, authors and series from old library item
|
||||
*
|
||||
@ -448,6 +375,47 @@ class LibraryItem extends Model {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('sequelize').WhereOptions} where
|
||||
* @returns {Promise<LibraryItemExpanded[]>}
|
||||
*/
|
||||
static async findAllExpandedWhere(where = null) {
|
||||
return this.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.book,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.author,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.podcast,
|
||||
include: {
|
||||
model: this.sequelize.models.podcastEpisode
|
||||
}
|
||||
}
|
||||
],
|
||||
order: [
|
||||
// Ensure author & series stay in the same order
|
||||
[this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
|
||||
[this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} libraryItemId
|
||||
@ -497,6 +465,57 @@ class LibraryItem extends Model {
|
||||
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
|
||||
* @param {string} libraryItemId
|
||||
@ -560,7 +579,7 @@ class LibraryItem extends Model {
|
||||
|
||||
return {
|
||||
libraryItems: libraryItems.map((li) => {
|
||||
const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified()
|
||||
const oldLibraryItem = li.toOldJSONMinified()
|
||||
if (li.collapsedSeries) {
|
||||
oldLibraryItem.collapsedSeries = li.collapsedSeries
|
||||
}
|
||||
@ -766,21 +785,11 @@ class LibraryItem extends Model {
|
||||
* Get book library items for author, optional use user permissions
|
||||
* @param {import('./Author')} author
|
||||
* @param {import('./User')} user
|
||||
* @returns {Promise<oldLibraryItem[]>}
|
||||
* @returns {Promise<LibraryItemExpanded[]>}
|
||||
*/
|
||||
static async getForAuthor(author, user = null) {
|
||||
const { libraryItems } = await libraryFilters.getLibraryItemsForAuthor(author, user, undefined, undefined)
|
||||
return libraryItems.map((li) => this.getOldLibraryItem(li))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get book library items in a collection
|
||||
* @param {oldCollection} collection
|
||||
* @returns {Promise<oldLibraryItem[]>}
|
||||
*/
|
||||
static async getForCollection(collection) {
|
||||
const libraryItems = await libraryFilters.getLibraryItemsForCollection(collection)
|
||||
return libraryItems.map((li) => this.getOldLibraryItem(li))
|
||||
return libraryItems
|
||||
}
|
||||
|
||||
/**
|
||||
@ -865,54 +874,6 @@ class LibraryItem extends Model {
|
||||
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}
|
||||
@ -1061,6 +1022,9 @@ class LibraryItem extends Model {
|
||||
{
|
||||
fields: ['libraryId', 'mediaType']
|
||||
},
|
||||
{
|
||||
fields: ['libraryId', 'mediaType', 'size']
|
||||
},
|
||||
{
|
||||
fields: ['libraryId', 'mediaId', 'mediaType']
|
||||
},
|
||||
@ -1128,6 +1092,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
|
||||
* Requires expanded library item
|
||||
@ -1139,12 +1161,149 @@ class LibraryItem extends Model {
|
||||
Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
|
||||
return false
|
||||
}
|
||||
if (this.mediaType === 'book') {
|
||||
if (this.isBook) {
|
||||
return this.media.audioFiles?.length > 0
|
||||
} else {
|
||||
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
|
||||
|
@ -12,6 +12,7 @@ const { DataTypes, Model } = require('sequelize')
|
||||
* @property {Object} extraData
|
||||
* @property {Date} createdAt
|
||||
* @property {Date} updatedAt
|
||||
* @property {boolean} isDownloadable
|
||||
*
|
||||
* @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel
|
||||
*/
|
||||
@ -25,11 +26,40 @@ const { DataTypes, Model } = require('sequelize')
|
||||
* @property {Date} expiresAt
|
||||
* @property {Date} createdAt
|
||||
* @property {Date} updatedAt
|
||||
* @property {boolean} isDownloadable
|
||||
*/
|
||||
|
||||
class MediaItemShare extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {UUIDV4} */
|
||||
this.mediaItemId
|
||||
/** @type {string} */
|
||||
this.mediaItemType
|
||||
/** @type {string} */
|
||||
this.slug
|
||||
/** @type {string} */
|
||||
this.pash
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {Date} */
|
||||
this.expiresAt
|
||||
/** @type {Object} */
|
||||
this.extraData
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {boolean} */
|
||||
this.isDownloadable
|
||||
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./Book')|import('./PodcastEpisode')} */
|
||||
this.mediaItem
|
||||
}
|
||||
|
||||
toJSONForClient() {
|
||||
@ -40,47 +70,32 @@ class MediaItemShare extends Model {
|
||||
slug: this.slug,
|
||||
expiresAt: this.expiresAt,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
updatedAt: this.updatedAt,
|
||||
isDownloadable: this.isDownloadable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded book that includes library settings
|
||||
*
|
||||
* @param {string} mediaItemId
|
||||
* @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') {
|
||||
const book = await this.sequelize.models.book.findByPk(mediaItemId, {
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.author,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
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
|
||||
const libraryItem = await libraryItemModel.findOneExpanded(
|
||||
{ mediaId: mediaItemId },
|
||||
{
|
||||
model: this.sequelize.models.library,
|
||||
attributes: ['settings']
|
||||
}
|
||||
)
|
||||
|
||||
return libraryItem
|
||||
}
|
||||
return null
|
||||
}
|
||||
@ -114,7 +129,8 @@ class MediaItemShare extends Model {
|
||||
slug: DataTypes.STRING,
|
||||
pash: DataTypes.STRING,
|
||||
expiresAt: DataTypes.DATE,
|
||||
extraData: DataTypes.JSON
|
||||
extraData: DataTypes.JSON,
|
||||
isDownloadable: DataTypes.BOOLEAN
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
|
@ -1,7 +1,6 @@
|
||||
const { DataTypes, Model, Op, literal } = require('sequelize')
|
||||
const { DataTypes, Model, Op } = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const oldPlaylist = require('../objects/Playlist')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
|
||||
class Playlist extends Model {
|
||||
constructor(values, options) {
|
||||
@ -21,134 +20,23 @@ class Playlist extends Model {
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
static getOldPlaylist(playlistExpanded) {
|
||||
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)
|
||||
// Expanded properties
|
||||
|
||||
return new oldPlaylist({
|
||||
id: playlistExpanded.id,
|
||||
libraryId: playlistExpanded.libraryId,
|
||||
userId: playlistExpanded.userId,
|
||||
name: playlistExpanded.name,
|
||||
description: playlistExpanded.description,
|
||||
items,
|
||||
lastUpdate: playlistExpanded.updatedAt.valueOf(),
|
||||
createdAt: playlistExpanded.createdAt.valueOf()
|
||||
})
|
||||
/** @type {import('./PlaylistMediaItem')[]} - only set when expanded */
|
||||
this.playlistMediaItems
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old playlist toJSONExpanded
|
||||
* @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
|
||||
* Get old playlists for user and library
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {string} [libraryId]
|
||||
* @returns {Promise<oldPlaylist[]>}
|
||||
* @param {string} libraryId
|
||||
* @async
|
||||
*/
|
||||
static async getOldPlaylistsForUserAndLibrary(userId, libraryId = null) {
|
||||
static async getOldPlaylistsForUserAndLibrary(userId, libraryId) {
|
||||
if (!userId && !libraryId) return []
|
||||
|
||||
const whereQuery = {}
|
||||
if (userId) {
|
||||
whereQuery.userId = userId
|
||||
@ -163,7 +51,23 @@ class Playlist extends Model {
|
||||
include: [
|
||||
{
|
||||
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,
|
||||
@ -174,42 +78,13 @@ class Playlist extends Model {
|
||||
}
|
||||
]
|
||||
},
|
||||
order: [
|
||||
[literal('name COLLATE NOCASE'), 'ASC'],
|
||||
['playlistMediaItems', 'order', 'ASC']
|
||||
]
|
||||
order: [['playlistMediaItems', 'order', 'ASC']]
|
||||
})
|
||||
|
||||
const oldPlaylists = []
|
||||
for (const playlistExpanded of playlistsExpanded) {
|
||||
const oldPlaylist = this.getOldPlaylist(playlistExpanded)
|
||||
const libraryItems = []
|
||||
for (const pmi of playlistExpanded.playlistMediaItems) {
|
||||
let mediaItem = pmi.mediaItem || pmi.dataValues.mediaItem
|
||||
// Sort by name asc
|
||||
playlistsExpanded.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
if (!mediaItem) {
|
||||
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
|
||||
return playlistsExpanded.map((playlist) => playlist.toOldJSONExpanded())
|
||||
}
|
||||
|
||||
/**
|
||||
@ -289,6 +164,49 @@ class Playlist extends Model {
|
||||
return playlists
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes media items and re-orders playlists
|
||||
*
|
||||
* @param {string[]} mediaItemIds
|
||||
*/
|
||||
static async removeMediaItemsFromPlaylists(mediaItemIds) {
|
||||
if (!mediaItemIds?.length) return
|
||||
|
||||
const playlistsWithItem = await this.getPlaylistsForMediaItemIds(mediaItemIds)
|
||||
|
||||
if (!playlistsWithItem.length) return
|
||||
|
||||
for (const playlist of playlistsWithItem) {
|
||||
let numMediaItems = playlist.playlistMediaItems.length
|
||||
|
||||
let order = 1
|
||||
// Remove items in playlist and re-order
|
||||
for (const playlistMediaItem of playlist.playlistMediaItems) {
|
||||
if (mediaItemIds.includes(playlistMediaItem.mediaItemId)) {
|
||||
await playlistMediaItem.destroy()
|
||||
numMediaItems--
|
||||
} else {
|
||||
if (playlistMediaItem.order !== order) {
|
||||
playlistMediaItem.update({
|
||||
order
|
||||
})
|
||||
}
|
||||
order++
|
||||
}
|
||||
}
|
||||
|
||||
// If playlist is now empty then remove it
|
||||
const jsonExpanded = await playlist.getOldJsonExpanded()
|
||||
if (!numMediaItems) {
|
||||
Logger.info(`[ApiRouter] Playlist "${playlist.name}" has no more items - removing it`)
|
||||
await playlist.destroy()
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
} else {
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
@ -345,6 +263,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: libraryItem.toOldJSONExpanded()
|
||||
}
|
||||
}
|
||||
|
||||
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: libraryItem.toOldJSONMinified()
|
||||
}
|
||||
})
|
||||
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Playlist
|
||||
|
@ -16,15 +16,11 @@ class PlaylistMediaItem extends Model {
|
||||
this.playlistId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
static removeByIds(playlistId, mediaItemId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
playlistId,
|
||||
mediaItemId
|
||||
}
|
||||
})
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./Book')|import('./PodcastEpisode')} - only set when expanded */
|
||||
this.mediaItem
|
||||
}
|
||||
|
||||
getMediaItem(options) {
|
||||
|
@ -1,4 +1,6 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
/**
|
||||
* @typedef PodcastExpandedProperties
|
||||
@ -47,6 +49,8 @@ class Podcast extends Model {
|
||||
this.lastEpisodeCheck
|
||||
/** @type {number} */
|
||||
this.maxEpisodesToKeep
|
||||
/** @type {number} */
|
||||
this.maxNewEpisodesToDownload
|
||||
/** @type {string} */
|
||||
this.coverPath
|
||||
/** @type {string[]} */
|
||||
@ -57,6 +61,9 @@ class Podcast extends Model {
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
|
||||
/** @type {import('./PodcastEpisode')[]} */
|
||||
this.podcastEpisodes
|
||||
}
|
||||
|
||||
static getOldPodcast(libraryItemExpanded) {
|
||||
@ -119,23 +126,43 @@ 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
|
||||
}
|
||||
/**
|
||||
* Payload from the /api/podcasts POST endpoint
|
||||
*
|
||||
* @param {Object} payload
|
||||
* @param {import('sequelize').Transaction} transaction
|
||||
*/
|
||||
static async createFromRequest(payload, transaction) {
|
||||
const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null
|
||||
const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null
|
||||
const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : []
|
||||
const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : []
|
||||
|
||||
return this.create(
|
||||
{
|
||||
title,
|
||||
titleIgnorePrefix: getTitleIgnorePrefix(title),
|
||||
author: typeof payload.metadata.author === 'string' ? payload.metadata.author : null,
|
||||
releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null,
|
||||
feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null,
|
||||
imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null,
|
||||
description: typeof payload.metadata.description === 'string' ? payload.metadata.description : null,
|
||||
itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null,
|
||||
itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null,
|
||||
itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null,
|
||||
language: typeof payload.metadata.language === 'string' ? payload.metadata.language : null,
|
||||
podcastType: typeof payload.metadata.type === 'string' ? payload.metadata.type : null,
|
||||
explicit: !!payload.metadata.explicit,
|
||||
autoDownloadEpisodes: !!payload.autoDownloadEpisodes,
|
||||
autoDownloadSchedule: autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule,
|
||||
lastEpisodeCheck: new Date(),
|
||||
maxEpisodesToKeep: 0,
|
||||
maxNewEpisodesToDownload: 3,
|
||||
tags,
|
||||
genres
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -179,6 +206,311 @@ 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
|
||||
}
|
||||
if (typeof payload.lastEpisodeCheck === 'number' && payload.lastEpisodeCheck !== this.lastEpisodeCheck?.valueOf()) {
|
||||
this.lastEpisodeCheck = payload.lastEpisodeCheck
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {number} - Unix timestamp
|
||||
*/
|
||||
getLatestEpisodePublishedAt() {
|
||||
return this.podcastEpisodes.reduce((latest, episode) => {
|
||||
if (episode.publishedAt?.valueOf() > latest) {
|
||||
return episode.publishedAt.valueOf()
|
||||
}
|
||||
return latest
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for checking if an rss feed episode is already in the podcast
|
||||
*
|
||||
* @param {import('../utils/podcastUtils').RssPodcastEpisode} feedEpisode - object from rss feed
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkHasEpisodeByFeedEpisode(feedEpisode) {
|
||||
const guid = feedEpisode.guid
|
||||
const url = feedEpisode.enclosure.url
|
||||
return this.podcastEpisodes.some((ep) => ep.checkMatchesGuidOrEnclosureUrl(guid, url))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
@ -53,42 +53,6 @@ class PodcastEpisode extends Model {
|
||||
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) {
|
||||
const podcastEpisode = this.getFromOld(oldEpisode)
|
||||
return this.create(podcastEpisode)
|
||||
@ -123,6 +87,40 @@ class PodcastEpisode extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode
|
||||
* @param {string} podcastId
|
||||
* @param {import('../objects/files/AudioFile')} audioFile
|
||||
*/
|
||||
static async createFromRssPodcastEpisode(rssPodcastEpisode, podcastId, audioFile) {
|
||||
const podcastEpisode = {
|
||||
index: null,
|
||||
season: rssPodcastEpisode.season,
|
||||
episode: rssPodcastEpisode.episode,
|
||||
episodeType: rssPodcastEpisode.episodeType,
|
||||
title: rssPodcastEpisode.title,
|
||||
subtitle: rssPodcastEpisode.subtitle,
|
||||
description: rssPodcastEpisode.description,
|
||||
pubDate: rssPodcastEpisode.pubDate,
|
||||
enclosureURL: rssPodcastEpisode.enclosure?.url || null,
|
||||
enclosureSize: rssPodcastEpisode.enclosure?.length || null,
|
||||
enclosureType: rssPodcastEpisode.enclosure?.type || null,
|
||||
publishedAt: rssPodcastEpisode.publishedAt,
|
||||
podcastId,
|
||||
audioFile: audioFile.toJSON(),
|
||||
chapters: [],
|
||||
extraData: {}
|
||||
}
|
||||
if (rssPodcastEpisode.guid) {
|
||||
podcastEpisode.extraData.guid = rssPodcastEpisode.guid
|
||||
}
|
||||
if (audioFile.chapters?.length) {
|
||||
podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch }))
|
||||
}
|
||||
return this.create(podcastEpisode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
@ -170,6 +168,128 @@ class PodcastEpisode extends Model {
|
||||
})
|
||||
PodcastEpisode.belongsTo(podcast)
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.audioFile?.metadata.size || 0
|
||||
}
|
||||
|
||||
get duration() {
|
||||
return this.audioFile?.duration || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for matching the episode with an episode in the RSS feed
|
||||
*
|
||||
* @param {string} guid
|
||||
* @param {string} enclosureURL
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkMatchesGuidOrEnclosureUrl(guid, enclosureURL) {
|
||||
if (this.extraData?.guid && this.extraData.guid === guid) {
|
||||
return true
|
||||
}
|
||||
if (this.enclosureURL && this.enclosureURL === enclosureURL) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
@ -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
|
@ -1,4 +1,3 @@
|
||||
const uuidv4 = require('uuid').v4
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
@ -177,48 +176,6 @@ class LibraryItem {
|
||||
get hasAudioFiles() {
|
||||
return this.libraryFiles.some((lf) => lf.fileType === 'audio')
|
||||
}
|
||||
get hasMediaEntities() {
|
||||
return this.media.hasMediaEntities
|
||||
}
|
||||
|
||||
// Data comes from scandir library item data
|
||||
// TODO: Remove this function. Only used when creating a new podcast now
|
||||
setData(libraryMediaType, payload) {
|
||||
this.id = uuidv4()
|
||||
this.mediaType = libraryMediaType
|
||||
if (libraryMediaType === 'podcast') {
|
||||
this.media = new Podcast()
|
||||
} else {
|
||||
Logger.error(`[LibraryItem] setData called with unsupported media type "${libraryMediaType}"`)
|
||||
return
|
||||
}
|
||||
this.media.id = uuidv4()
|
||||
this.media.libraryItemId = this.id
|
||||
|
||||
for (const key in payload) {
|
||||
if (key === 'libraryFiles') {
|
||||
this.libraryFiles = payload.libraryFiles.map((lf) => lf.clone())
|
||||
|
||||
// Set cover image
|
||||
const imageFiles = this.libraryFiles.filter((lf) => lf.fileType === 'image')
|
||||
const coverMatch = imageFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
if (coverMatch) {
|
||||
this.media.coverPath = coverMatch.metadata.path
|
||||
} else if (imageFiles.length) {
|
||||
this.media.coverPath = imageFiles[0].metadata.path
|
||||
}
|
||||
} else if (this[key] !== undefined && key !== 'media') {
|
||||
this[key] = payload[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.media) {
|
||||
this.media.setData(payload.media)
|
||||
}
|
||||
|
||||
this.addedAt = Date.now()
|
||||
this.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
const json = this.toJSON()
|
||||
@ -252,10 +209,6 @@ class LibraryItem {
|
||||
this.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
getDirectPlayTracklist(episodeId) {
|
||||
return this.media.getDirectPlayTracklist(episodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save metadata.json file
|
||||
* TODO: Move to new LibraryItem model
|
||||
@ -316,31 +269,5 @@ class LibraryItem {
|
||||
this.isSavingMetadata = false
|
||||
})
|
||||
}
|
||||
|
||||
removeLibraryFile(ino) {
|
||||
if (!ino) return false
|
||||
const libraryFile = this.libraryFiles.find((lf) => lf.ino === ino)
|
||||
if (libraryFile) {
|
||||
this.libraryFiles = this.libraryFiles.filter((lf) => lf.ino !== ino)
|
||||
this.updatedAt = Date.now()
|
||||
return true
|
||||
}
|
||||
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
|
||||
|
@ -1,8 +1,6 @@
|
||||
const date = require('../libs/dateAndTime')
|
||||
const uuidv4 = require('uuid').v4
|
||||
const serverVersion = require('../../package.json').version
|
||||
const BookMetadata = require('./metadata/BookMetadata')
|
||||
const PodcastMetadata = require('./metadata/PodcastMetadata')
|
||||
const DeviceInfo = require('./DeviceInfo')
|
||||
|
||||
class PlaybackSession {
|
||||
@ -60,7 +58,7 @@ class PlaybackSession {
|
||||
bookId: this.bookId,
|
||||
episodeId: this.episodeId,
|
||||
mediaType: this.mediaType,
|
||||
mediaMetadata: this.mediaMetadata?.toJSON() || null,
|
||||
mediaMetadata: structuredClone(this.mediaMetadata),
|
||||
chapters: (this.chapters || []).map((c) => ({ ...c })),
|
||||
displayTitle: this.displayTitle,
|
||||
displayAuthor: this.displayAuthor,
|
||||
@ -82,7 +80,7 @@ class PlaybackSession {
|
||||
|
||||
/**
|
||||
* Session data to send to clients
|
||||
* @param {Object} [libraryItem] - old library item
|
||||
* @param {import('../models/LibraryItem')} [libraryItem]
|
||||
* @returns
|
||||
*/
|
||||
toJSONForClient(libraryItem) {
|
||||
@ -94,7 +92,7 @@ class PlaybackSession {
|
||||
bookId: this.bookId,
|
||||
episodeId: this.episodeId,
|
||||
mediaType: this.mediaType,
|
||||
mediaMetadata: this.mediaMetadata?.toJSON() || null,
|
||||
mediaMetadata: structuredClone(this.mediaMetadata),
|
||||
chapters: (this.chapters || []).map((c) => ({ ...c })),
|
||||
displayTitle: this.displayTitle,
|
||||
displayAuthor: this.displayAuthor,
|
||||
@ -112,7 +110,7 @@ class PlaybackSession {
|
||||
startedAt: this.startedAt,
|
||||
updatedAt: this.updatedAt,
|
||||
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.chapters = session.chapters || []
|
||||
|
||||
this.mediaMetadata = null
|
||||
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.mediaMetadata = session.mediaMetadata
|
||||
this.displayTitle = session.displayTitle || ''
|
||||
this.displayAuthor = session.displayAuthor || ''
|
||||
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) {
|
||||
this.id = uuidv4()
|
||||
this.userId = userId
|
||||
@ -213,13 +213,12 @@ class PlaybackSession {
|
||||
this.bookId = episodeId ? null : libraryItem.media.id
|
||||
this.episodeId = episodeId
|
||||
this.mediaType = libraryItem.mediaType
|
||||
this.mediaMetadata = libraryItem.media.metadata.clone()
|
||||
this.mediaMetadata = libraryItem.media.oldMetadataToJSON()
|
||||
this.chapters = libraryItem.media.getChapters(episodeId)
|
||||
this.displayTitle = libraryItem.media.getPlaybackTitle(episodeId)
|
||||
this.displayAuthor = libraryItem.media.getPlaybackAuthor()
|
||||
this.coverPath = libraryItem.media.coverPath
|
||||
|
||||
this.setDuration(libraryItem, episodeId)
|
||||
this.duration = libraryItem.media.getPlaybackDuration(episodeId)
|
||||
|
||||
this.mediaPlayer = mediaPlayer
|
||||
this.deviceInfo = deviceInfo || new DeviceInfo()
|
||||
@ -235,14 +234,6 @@ class PlaybackSession {
|
||||
this.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
setDuration(libraryItem, episodeId) {
|
||||
if (episodeId) {
|
||||
this.duration = libraryItem.media.getEpisodeDuration(episodeId)
|
||||
} else {
|
||||
this.duration = libraryItem.media.duration
|
||||
}
|
||||
}
|
||||
|
||||
addListeningTime(timeListened) {
|
||||
if (!timeListened || isNaN(timeListened)) return
|
||||
|
||||
|
@ -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
|
@ -6,8 +6,11 @@ const globals = require('../utils/globals')
|
||||
class PodcastEpisodeDownload {
|
||||
constructor() {
|
||||
this.id = null
|
||||
this.podcastEpisode = null
|
||||
/** @type {import('../utils/podcastUtils').RssPodcastEpisode} */
|
||||
this.rssPodcastEpisode = null
|
||||
|
||||
this.url = null
|
||||
/** @type {import('../models/LibraryItem')} */
|
||||
this.libraryItem = null
|
||||
this.libraryId = null
|
||||
|
||||
@ -15,7 +18,7 @@ class PodcastEpisodeDownload {
|
||||
this.isFinished = false
|
||||
this.failed = false
|
||||
|
||||
this.appendEpisodeId = false
|
||||
this.appendRandomId = false
|
||||
|
||||
this.startedAt = null
|
||||
this.createdAt = null
|
||||
@ -25,22 +28,22 @@ class PodcastEpisodeDownload {
|
||||
toJSONForClient() {
|
||||
return {
|
||||
id: this.id,
|
||||
episodeDisplayTitle: this.podcastEpisode?.title ?? null,
|
||||
episodeDisplayTitle: this.rssPodcastEpisode?.title ?? null,
|
||||
url: this.url,
|
||||
libraryItemId: this.libraryItem?.id || null,
|
||||
libraryItemId: this.libraryItemId,
|
||||
libraryId: this.libraryId || null,
|
||||
isFinished: this.isFinished,
|
||||
failed: this.failed,
|
||||
appendEpisodeId: this.appendEpisodeId,
|
||||
appendRandomId: this.appendRandomId,
|
||||
startedAt: this.startedAt,
|
||||
createdAt: this.createdAt,
|
||||
finishedAt: this.finishedAt,
|
||||
podcastTitle: this.libraryItem?.media.metadata.title ?? null,
|
||||
podcastExplicit: !!this.libraryItem?.media.metadata.explicit,
|
||||
season: this.podcastEpisode?.season ?? null,
|
||||
episode: this.podcastEpisode?.episode ?? null,
|
||||
episodeType: this.podcastEpisode?.episodeType ?? 'full',
|
||||
publishedAt: this.podcastEpisode?.publishedAt ?? null
|
||||
podcastTitle: this.libraryItem?.media.title ?? null,
|
||||
podcastExplicit: !!this.libraryItem?.media.explicit,
|
||||
season: this.rssPodcastEpisode?.season ?? null,
|
||||
episode: this.rssPodcastEpisode?.episode ?? null,
|
||||
episodeType: this.rssPodcastEpisode?.episodeType ?? 'full',
|
||||
publishedAt: this.rssPodcastEpisode?.publishedAt ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,7 +57,7 @@ class PodcastEpisodeDownload {
|
||||
return 'mp3'
|
||||
}
|
||||
get enclosureType() {
|
||||
const enclosureType = this.podcastEpisode?.enclosure?.type
|
||||
const enclosureType = this.rssPodcastEpisode.enclosure.type
|
||||
return typeof enclosureType === 'string' ? enclosureType : null
|
||||
}
|
||||
/**
|
||||
@ -67,10 +70,12 @@ class PodcastEpisodeDownload {
|
||||
if (this.enclosureType && !this.enclosureType.includes('mpeg')) return false
|
||||
return this.fileExtension === 'mp3'
|
||||
}
|
||||
|
||||
get episodeTitle() {
|
||||
return this.rssPodcastEpisode.title
|
||||
}
|
||||
get targetFilename() {
|
||||
const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : ''
|
||||
const filename = `${this.podcastEpisode.title}${appendage}.${this.fileExtension}`
|
||||
const appendage = this.appendRandomId ? ` (${uuidv4()})` : ''
|
||||
const filename = `${this.rssPodcastEpisode.title}${appendage}.${this.fileExtension}`
|
||||
return sanitizeFilename(filename)
|
||||
}
|
||||
get targetPath() {
|
||||
@ -80,14 +85,25 @@ class PodcastEpisodeDownload {
|
||||
return this.targetFilename
|
||||
}
|
||||
get libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
return this.libraryItem?.id || null
|
||||
}
|
||||
get pubYear() {
|
||||
if (!this.rssPodcastEpisode.publishedAt) return null
|
||||
return new Date(this.rssPodcastEpisode.publishedAt).getFullYear()
|
||||
}
|
||||
|
||||
setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) {
|
||||
/**
|
||||
*
|
||||
* @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode - from rss feed
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {*} isAutoDownload
|
||||
* @param {*} libraryId
|
||||
*/
|
||||
setData(rssPodcastEpisode, libraryItem, isAutoDownload, libraryId) {
|
||||
this.id = uuidv4()
|
||||
this.podcastEpisode = podcastEpisode
|
||||
this.rssPodcastEpisode = rssPodcastEpisode
|
||||
|
||||
const url = podcastEpisode.enclosure.url
|
||||
const url = rssPodcastEpisode.enclosure.url
|
||||
if (decodeURIComponent(url) !== url) {
|
||||
// Already encoded
|
||||
this.url = url
|
||||
|
@ -18,6 +18,7 @@ class Stream extends EventEmitter {
|
||||
|
||||
this.id = sessionId
|
||||
this.user = user
|
||||
/** @type {import('../models/LibraryItem')} */
|
||||
this.libraryItem = libraryItem
|
||||
this.episodeId = episodeId
|
||||
|
||||
@ -40,31 +41,25 @@ class Stream extends EventEmitter {
|
||||
this.furthestSegmentCreated = 0
|
||||
}
|
||||
|
||||
get isPodcast() {
|
||||
return this.libraryItem.mediaType === 'podcast'
|
||||
}
|
||||
/**
|
||||
* @returns {import('../models/PodcastEpisode') | null}
|
||||
*/
|
||||
get episode() {
|
||||
if (!this.isPodcast) return null
|
||||
return this.libraryItem.media.episodes.find((ep) => ep.id === this.episodeId)
|
||||
}
|
||||
get libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
if (!this.libraryItem.isPodcast) return null
|
||||
return this.libraryItem.media.podcastEpisodes.find((ep) => ep.id === this.episodeId)
|
||||
}
|
||||
get mediaTitle() {
|
||||
if (this.episode) return this.episode.title || ''
|
||||
return this.libraryItem.media.metadata.title || ''
|
||||
return this.libraryItem.media.getPlaybackTitle(this.episodeId)
|
||||
}
|
||||
get totalDuration() {
|
||||
if (this.episode) return this.episode.duration
|
||||
return this.libraryItem.media.duration
|
||||
return this.libraryItem.media.getPlaybackDuration(this.episodeId)
|
||||
}
|
||||
get tracks() {
|
||||
if (this.episode) return this.episode.tracks
|
||||
return this.libraryItem.media.tracks
|
||||
return this.libraryItem.getTrackList(this.episodeId)
|
||||
}
|
||||
get tracksAudioFileType() {
|
||||
if (!this.tracks.length) return null
|
||||
return this.tracks[0].metadata.format
|
||||
return this.tracks[0].metadata.ext.slice(1)
|
||||
}
|
||||
get tracksMimeType() {
|
||||
if (!this.tracks.length) return null
|
||||
@ -116,8 +111,8 @@ class Stream extends EventEmitter {
|
||||
return {
|
||||
id: this.id,
|
||||
userId: this.user.id,
|
||||
libraryItem: this.libraryItem.toJSONExpanded(),
|
||||
episode: this.episode ? this.episode.toJSONExpanded() : null,
|
||||
libraryItem: this.libraryItem.toOldJSONExpanded(),
|
||||
episode: this.episode ? this.episode.toOldJSONExpanded(this.libraryItem.id) : null,
|
||||
segmentLength: this.segmentLength,
|
||||
playlistPath: this.playlistPath,
|
||||
clientPlaylistUri: this.clientPlaylistUri,
|
||||
@ -280,15 +275,15 @@ class Stream extends EventEmitter {
|
||||
|
||||
this.ffmpeg.addOption([`-loglevel ${logLevel}`, '-map 0:a', `-c:a ${audioCodec}`])
|
||||
const hlsOptions = ['-f hls', '-copyts', '-avoid_negative_ts make_non_negative', '-max_delay 5000000', '-max_muxing_queue_size 2048', `-hls_time 6`, `-hls_segment_type ${this.hlsSegmentType}`, `-start_number ${this.segmentStartNumber}`, '-hls_playlist_type vod', '-hls_list_size 0', '-hls_allow_cache 0']
|
||||
this.ffmpeg.addOption(hlsOptions)
|
||||
if (this.hlsSegmentType === 'fmp4') {
|
||||
hlsOptions.push('-strict -2')
|
||||
this.ffmpeg.addOption('-strict -2')
|
||||
var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4')
|
||||
// var fmp4InitFilename = 'init.mp4'
|
||||
hlsOptions.push(`-hls_fmp4_init_filename ${fmp4InitFilename}`)
|
||||
this.ffmpeg.addOption('-hls_fmp4_init_filename', fmp4InitFilename)
|
||||
}
|
||||
this.ffmpeg.addOption(hlsOptions)
|
||||
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
|
||||
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
|
||||
this.ffmpeg.addOption('-hls_segment_filename', segmentFilename)
|
||||
this.ffmpeg.output(this.finalPlaylistPath)
|
||||
|
||||
this.ffmpeg.on('start', (command) => {
|
||||
|
@ -1,4 +1,3 @@
|
||||
const uuidv4 = require('uuid').v4
|
||||
const { areEquivalent, copyValue } = require('../../utils/index')
|
||||
const AudioFile = require('../files/AudioFile')
|
||||
const AudioTrack = require('../files/AudioTrack')
|
||||
@ -127,27 +126,6 @@ class PodcastEpisode {
|
||||
get enclosureUrl() {
|
||||
return this.enclosure?.url || null
|
||||
}
|
||||
get pubYear() {
|
||||
if (!this.publishedAt) return null
|
||||
return new Date(this.publishedAt).getFullYear()
|
||||
}
|
||||
|
||||
setData(data, index = 1) {
|
||||
this.id = uuidv4()
|
||||
this.index = index
|
||||
this.title = data.title
|
||||
this.subtitle = data.subtitle || ''
|
||||
this.pubDate = data.pubDate || ''
|
||||
this.description = data.description || ''
|
||||
this.enclosure = data.enclosure ? { ...data.enclosure } : null
|
||||
this.guid = data.guid || null
|
||||
this.season = data.season || ''
|
||||
this.episode = data.episode || ''
|
||||
this.episodeType = data.episodeType || 'full'
|
||||
this.publishedAt = data.publishedAt || 0
|
||||
this.addedAt = Date.now()
|
||||
this.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
let hasUpdates = false
|
||||
@ -167,20 +145,5 @@ class PodcastEpisode {
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
// Only checks container format
|
||||
checkCanDirectPlay(payload) {
|
||||
const supportedMimeTypes = payload.supportedMimeTypes || []
|
||||
return supportedMimeTypes.includes(this.audioFile.mimeType)
|
||||
}
|
||||
|
||||
getDirectPlayTracklist() {
|
||||
return this.tracks
|
||||
}
|
||||
|
||||
checkEqualsEnclosureUrl(url) {
|
||||
if (!this.enclosure?.url) return false
|
||||
return this.enclosure.url == url
|
||||
}
|
||||
}
|
||||
module.exports = PodcastEpisode
|
||||
|
@ -33,8 +33,8 @@ class Book {
|
||||
this.metadata = new BookMetadata(book.metadata)
|
||||
this.coverPath = book.coverPath
|
||||
this.tags = [...book.tags]
|
||||
this.audioFiles = book.audioFiles.map(f => new AudioFile(f))
|
||||
this.chapters = book.chapters.map(c => ({ ...c }))
|
||||
this.audioFiles = book.audioFiles.map((f) => new AudioFile(f))
|
||||
this.chapters = book.chapters.map((c) => ({ ...c }))
|
||||
this.ebookFile = book.ebookFile ? new EBookFile(book.ebookFile) : null
|
||||
this.lastCoverSearch = book.lastCoverSearch || null
|
||||
this.lastCoverSearchQuery = book.lastCoverSearchQuery || null
|
||||
@ -47,8 +47,8 @@ class Book {
|
||||
metadata: this.metadata.toJSON(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...this.tags],
|
||||
audioFiles: this.audioFiles.map(f => f.toJSON()),
|
||||
chapters: this.chapters.map(c => ({ ...c })),
|
||||
audioFiles: this.audioFiles.map((f) => f.toJSON()),
|
||||
chapters: this.chapters.map((c) => ({ ...c })),
|
||||
ebookFile: this.ebookFile ? this.ebookFile.toJSON() : null
|
||||
}
|
||||
}
|
||||
@ -75,11 +75,11 @@ class Book {
|
||||
metadata: this.metadata.toJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...this.tags],
|
||||
audioFiles: this.audioFiles.map(f => f.toJSON()),
|
||||
chapters: this.chapters.map(c => ({ ...c })),
|
||||
audioFiles: this.audioFiles.map((f) => f.toJSON()),
|
||||
chapters: this.chapters.map((c) => ({ ...c })),
|
||||
duration: this.duration,
|
||||
size: this.size,
|
||||
tracks: this.tracks.map(t => t.toJSON()),
|
||||
tracks: this.tracks.map((t) => t.toJSON()),
|
||||
ebookFile: this.ebookFile?.toJSON() || null
|
||||
}
|
||||
}
|
||||
@ -87,24 +87,21 @@ class Book {
|
||||
toJSONForMetadataFile() {
|
||||
return {
|
||||
tags: [...this.tags],
|
||||
chapters: this.chapters.map(c => ({ ...c })),
|
||||
chapters: this.chapters.map((c) => ({ ...c })),
|
||||
...this.metadata.toJSONForMetadataFile()
|
||||
}
|
||||
}
|
||||
|
||||
get size() {
|
||||
var total = 0
|
||||
this.audioFiles.forEach((af) => total += af.metadata.size)
|
||||
this.audioFiles.forEach((af) => (total += af.metadata.size))
|
||||
if (this.ebookFile) {
|
||||
total += this.ebookFile.metadata.size
|
||||
}
|
||||
return total
|
||||
}
|
||||
get hasMediaEntities() {
|
||||
return !!this.tracks.length || this.ebookFile
|
||||
}
|
||||
get includedAudioFiles() {
|
||||
return this.audioFiles.filter(af => !af.exclude)
|
||||
return this.audioFiles.filter((af) => !af.exclude)
|
||||
}
|
||||
get tracks() {
|
||||
let startOffset = 0
|
||||
@ -117,7 +114,7 @@ class Book {
|
||||
}
|
||||
get duration() {
|
||||
let total = 0
|
||||
this.tracks.forEach((track) => total += track.duration)
|
||||
this.tracks.forEach((track) => (total += track.duration))
|
||||
return total
|
||||
}
|
||||
get numTracks() {
|
||||
@ -129,8 +126,6 @@ class Book {
|
||||
|
||||
update(payload) {
|
||||
const json = this.toJSON()
|
||||
delete json.audiobooks // do not update media entities here
|
||||
delete json.ebooks
|
||||
|
||||
let hasUpdates = false
|
||||
for (const key in json) {
|
||||
@ -149,126 +144,11 @@ class Book {
|
||||
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) {
|
||||
coverPath = filePathToPOSIX(coverPath)
|
||||
if (this.coverPath === coverPath) return false
|
||||
this.coverPath = coverPath
|
||||
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
|
||||
|
@ -124,9 +124,6 @@ class Podcast {
|
||||
this.episodes.forEach((ep) => (total += ep.size))
|
||||
return total
|
||||
}
|
||||
get hasMediaEntities() {
|
||||
return !!this.episodes.length
|
||||
}
|
||||
get duration() {
|
||||
let total = 0
|
||||
this.episodes.forEach((ep) => (total += ep.duration))
|
||||
@ -135,18 +132,6 @@ class Podcast {
|
||||
get numTracks() {
|
||||
return this.episodes.length
|
||||
}
|
||||
get latestEpisodePublished() {
|
||||
var largestPublishedAt = 0
|
||||
this.episodes.forEach((ep) => {
|
||||
if (ep.publishedAt && ep.publishedAt > largestPublishedAt) {
|
||||
largestPublishedAt = ep.publishedAt
|
||||
}
|
||||
})
|
||||
return largestPublishedAt
|
||||
}
|
||||
get episodesWithPubDate() {
|
||||
return this.episodes.filter((ep) => !!ep.publishedAt)
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var json = this.toJSON()
|
||||
@ -181,82 +166,6 @@ class Podcast {
|
||||
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) {
|
||||
this.metadata = new PodcastMetadata()
|
||||
if (mediaData.metadata) {
|
||||
this.metadata.setData(mediaData.metadata)
|
||||
}
|
||||
|
||||
this.coverPath = mediaData.coverPath || null
|
||||
this.autoDownloadEpisodes = !!mediaData.autoDownloadEpisodes
|
||||
this.autoDownloadSchedule = mediaData.autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule
|
||||
this.lastEpisodeCheck = Date.now() // Makes sure new episodes are after this
|
||||
}
|
||||
|
||||
checkHasEpisode(episodeId) {
|
||||
return this.episodes.some((ep) => ep.id === episodeId)
|
||||
}
|
||||
checkHasEpisodeByFeedEpisode(feedEpisode) {
|
||||
const guid = feedEpisode.guid
|
||||
const url = feedEpisode.enclosure.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) {
|
||||
this.episodes.push(podcastEpisode)
|
||||
}
|
||||
|
||||
removeEpisode(episodeId) {
|
||||
const episode = this.episodes.find((ep) => ep.id === episodeId)
|
||||
if (episode) {
|
||||
this.episodes = this.episodes.filter((ep) => ep.id !== episodeId)
|
||||
}
|
||||
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) {
|
||||
if (!episodeId) return null
|
||||
|
||||
@ -265,9 +174,5 @@ class Podcast {
|
||||
|
||||
return this.episodes.find((ep) => ep.id == episodeId)
|
||||
}
|
||||
|
||||
getChapters(episodeId) {
|
||||
return this.getEpisode(episodeId)?.chapters?.map((ch) => ({ ...ch })) || []
|
||||
}
|
||||
}
|
||||
module.exports = Podcast
|
||||
|
@ -159,11 +159,6 @@ class BookMetadata {
|
||||
getSeries(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) {
|
||||
const json = this.toJSON()
|
||||
|
@ -91,24 +91,6 @@ class PodcastMetadata {
|
||||
return getTitlePrefixAtEnd(this.title)
|
||||
}
|
||||
|
||||
setData(mediaMetadata = {}) {
|
||||
this.title = mediaMetadata.title || null
|
||||
this.author = mediaMetadata.author || null
|
||||
this.description = mediaMetadata.description || null
|
||||
this.releaseDate = mediaMetadata.releaseDate || null
|
||||
this.feedUrl = mediaMetadata.feedUrl || null
|
||||
this.imageUrl = mediaMetadata.imageUrl || null
|
||||
this.itunesPageUrl = mediaMetadata.itunesPageUrl || null
|
||||
this.itunesId = mediaMetadata.itunesId || null
|
||||
this.itunesArtistId = mediaMetadata.itunesArtistId || null
|
||||
this.explicit = !!mediaMetadata.explicit
|
||||
this.language = mediaMetadata.language || null
|
||||
this.type = mediaMetadata.type || null
|
||||
if (mediaMetadata.genres && mediaMetadata.genres.length) {
|
||||
this.genres = [...mediaMetadata.genres]
|
||||
}
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
const json = this.toJSON()
|
||||
let hasUpdates = false
|
||||
|
@ -65,7 +65,7 @@ class ApiRouter {
|
||||
//
|
||||
// Library Routes
|
||||
//
|
||||
this.router.get(/^\/libraries/, this.apiCacheManager.middleware)
|
||||
this.router.get(/^\/libraries/i, this.apiCacheManager.middleware)
|
||||
this.router.post('/libraries', LibraryController.create.bind(this))
|
||||
this.router.get('/libraries', LibraryController.findAll.bind(this))
|
||||
this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this))
|
||||
@ -361,36 +361,7 @@ class ApiRouter {
|
||||
}
|
||||
|
||||
// remove item from playlists
|
||||
const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds)
|
||||
for (const playlist of playlistsWithItem) {
|
||||
let numMediaItems = playlist.playlistMediaItems.length
|
||||
|
||||
let order = 1
|
||||
// Remove items in playlist and re-order
|
||||
for (const playlistMediaItem of playlist.playlistMediaItems) {
|
||||
if (mediaItemIds.includes(playlistMediaItem.mediaItemId)) {
|
||||
await playlistMediaItem.destroy()
|
||||
numMediaItems--
|
||||
} else {
|
||||
if (playlistMediaItem.order !== order) {
|
||||
playlistMediaItem.update({
|
||||
order
|
||||
})
|
||||
}
|
||||
order++
|
||||
}
|
||||
}
|
||||
|
||||
// If playlist is now empty then remove it
|
||||
const jsonExpanded = await playlist.getOldJsonExpanded()
|
||||
if (!numMediaItems) {
|
||||
Logger.info(`[ApiRouter] Playlist "${playlist.name}" has no more items - removing it`)
|
||||
await playlist.destroy()
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
} else {
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
}
|
||||
await Database.playlistModel.removeMediaItemsFromPlaylists(mediaItemIds)
|
||||
|
||||
// Close rss feed - remove from db and emit socket event
|
||||
await RssFeedManager.closeFeedForEntityId(libraryItemId)
|
||||
|
@ -15,6 +15,7 @@ class PublicRouter {
|
||||
this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this))
|
||||
this.router.get('/share/:slug/track/:index', ShareController.getMediaItemShareAudioTrack.bind(this))
|
||||
this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this))
|
||||
this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.bind(this))
|
||||
this.router.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this))
|
||||
}
|
||||
}
|
||||
|
@ -202,12 +202,12 @@ class AudioFileScanner {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {AudioFile} audioFile
|
||||
* @param {string} audioFilePath
|
||||
* @returns {object}
|
||||
*/
|
||||
probeAudioFile(audioFile) {
|
||||
Logger.debug(`[AudioFileScanner] Running ffprobe for audio file at "${audioFile.metadata.path}"`)
|
||||
return prober.rawProbe(audioFile.metadata.path)
|
||||
probeAudioFile(audioFilePath) {
|
||||
Logger.debug(`[AudioFileScanner] Running ffprobe for audio file at "${audioFilePath}"`)
|
||||
return prober.rawProbe(audioFilePath)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -64,8 +64,7 @@ class LibraryItemScanner {
|
||||
|
||||
const { libraryItem: expandedLibraryItem, wasUpdated } = await this.rescanLibraryItemMedia(libraryItem, libraryItemScanData, library.settings, scanLogger)
|
||||
if (libraryItemDataUpdated || wasUpdated) {
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(expandedLibraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
SocketAuthority.emitter('item_updated', expandedLibraryItem.toOldJSONExpanded())
|
||||
|
||||
await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger)
|
||||
|
||||
|
@ -167,7 +167,7 @@ class LibraryScanner {
|
||||
if (this.shouldCancelScan(libraryScan)) return true
|
||||
|
||||
const libraryItemIdsMissing = []
|
||||
let oldLibraryItemsUpdated = []
|
||||
let libraryItemsUpdated = []
|
||||
for (const existingLibraryItem of existingLibraryItems) {
|
||||
// First try to find matching library item with exact file path
|
||||
let libraryItemData = libraryItemDataFound.find((lid) => lid.path === existingLibraryItem.path)
|
||||
@ -190,11 +190,11 @@ class LibraryScanner {
|
||||
libraryItemIdsMissing.push(existingLibraryItem.id)
|
||||
|
||||
// TODO: Temporary while using old model to socket emit
|
||||
const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id)
|
||||
if (oldLibraryItem) {
|
||||
oldLibraryItem.isMissing = true
|
||||
oldLibraryItem.updatedAt = Date.now()
|
||||
oldLibraryItemsUpdated.push(oldLibraryItem)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(existingLibraryItem.id)
|
||||
if (libraryItem) {
|
||||
libraryItem.isMissing = true
|
||||
await libraryItem.save()
|
||||
libraryItemsUpdated.push(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -206,16 +206,15 @@ class LibraryScanner {
|
||||
const { libraryItem, wasUpdated } = await LibraryItemScanner.rescanLibraryItemMedia(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan)
|
||||
if (!forceRescan || wasUpdated) {
|
||||
libraryScan.resultsUpdated++
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
oldLibraryItemsUpdated.push(oldLibraryItem)
|
||||
libraryItemsUpdated.push(libraryItem)
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`)
|
||||
}
|
||||
} else {
|
||||
libraryScan.resultsUpdated++
|
||||
// TODO: Temporary while using old model to socket emit
|
||||
const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id)
|
||||
oldLibraryItemsUpdated.push(oldLibraryItem)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(existingLibraryItem.id)
|
||||
libraryItemsUpdated.push(libraryItem)
|
||||
}
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`)
|
||||
@ -223,23 +222,23 @@ class LibraryScanner {
|
||||
}
|
||||
|
||||
// Emit item updates in chunks of 10 to client
|
||||
if (oldLibraryItemsUpdated.length === 10) {
|
||||
if (libraryItemsUpdated.length === 10) {
|
||||
// TODO: Should only emit to clients where library item is accessible
|
||||
SocketAuthority.emitter(
|
||||
'items_updated',
|
||||
oldLibraryItemsUpdated.map((li) => li.toJSONExpanded())
|
||||
libraryItemsUpdated.map((li) => li.toOldJSONExpanded())
|
||||
)
|
||||
oldLibraryItemsUpdated = []
|
||||
libraryItemsUpdated = []
|
||||
}
|
||||
|
||||
if (this.shouldCancelScan(libraryScan)) return true
|
||||
}
|
||||
// Emit item updates to client
|
||||
if (oldLibraryItemsUpdated.length) {
|
||||
if (libraryItemsUpdated.length) {
|
||||
// TODO: Should only emit to clients where library item is accessible
|
||||
SocketAuthority.emitter(
|
||||
'items_updated',
|
||||
oldLibraryItemsUpdated.map((li) => li.toJSONExpanded())
|
||||
libraryItemsUpdated.map((li) => li.toOldJSONExpanded())
|
||||
)
|
||||
}
|
||||
|
||||
@ -267,34 +266,33 @@ class LibraryScanner {
|
||||
|
||||
// Add new library items
|
||||
if (libraryItemDataFound.length) {
|
||||
let newOldLibraryItems = []
|
||||
let newLibraryItems = []
|
||||
for (const libraryItemData of libraryItemDataFound) {
|
||||
const newLibraryItem = await LibraryItemScanner.scanNewLibraryItem(libraryItemData, libraryScan.library.settings, libraryScan)
|
||||
if (newLibraryItem) {
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem)
|
||||
newOldLibraryItems.push(oldLibraryItem)
|
||||
newLibraryItems.push(newLibraryItem)
|
||||
|
||||
libraryScan.resultsAdded++
|
||||
}
|
||||
|
||||
// Emit new items in chunks of 10 to client
|
||||
if (newOldLibraryItems.length === 10) {
|
||||
if (newLibraryItems.length === 10) {
|
||||
// TODO: Should only emit to clients where library item is accessible
|
||||
SocketAuthority.emitter(
|
||||
'items_added',
|
||||
newOldLibraryItems.map((li) => li.toJSONExpanded())
|
||||
newLibraryItems.map((li) => li.toOldJSONExpanded())
|
||||
)
|
||||
newOldLibraryItems = []
|
||||
newLibraryItems = []
|
||||
}
|
||||
|
||||
if (this.shouldCancelScan(libraryScan)) return true
|
||||
}
|
||||
// Emit new items to client
|
||||
if (newOldLibraryItems.length) {
|
||||
if (newLibraryItems.length) {
|
||||
// TODO: Should only emit to clients where library item is accessible
|
||||
SocketAuthority.emitter(
|
||||
'items_added',
|
||||
newOldLibraryItems.map((li) => li.toJSONExpanded())
|
||||
newLibraryItems.map((li) => li.toOldJSONExpanded())
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -645,8 +643,7 @@ class LibraryScanner {
|
||||
const isSingleMediaItem = isSingleMediaFile(fileUpdateGroup, itemDir)
|
||||
const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem)
|
||||
if (newLibraryItem) {
|
||||
const oldNewLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem)
|
||||
SocketAuthority.emitter('item_added', oldNewLibraryItem.toJSONExpanded())
|
||||
SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
|
||||
}
|
||||
itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING
|
||||
}
|
||||
|
@ -8,8 +8,8 @@ const { filePathToPOSIX, copyToExisting } = require('./fileUtils')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
|
||||
function escapeSingleQuotes(path) {
|
||||
// return path.replace(/'/g, '\'\\\'\'')
|
||||
return filePathToPOSIX(path).replace(/ /g, '\\ ').replace(/'/g, "\\'")
|
||||
// A ' within a quoted string is escaped with '\'' in ffmpeg (see https://www.ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping)
|
||||
return filePathToPOSIX(path).replace(/'/g, "'\\''")
|
||||
}
|
||||
|
||||
// Returns first track start time
|
||||
@ -33,7 +33,7 @@ async function writeConcatFile(tracks, outputPath, startTime = 0) {
|
||||
|
||||
var tracksToInclude = tracks.filter((t) => t.index >= trackToStartWithIndex)
|
||||
var trackPaths = tracksToInclude.map((t) => {
|
||||
var line = 'file ' + escapeSingleQuotes(t.metadata.path) + '\n' + `duration ${t.duration}`
|
||||
var line = "file '" + escapeSingleQuotes(t.metadata.path) + "'\n" + `duration ${t.duration}`
|
||||
return line
|
||||
})
|
||||
var inputstr = trackPaths.join('\n\n')
|
||||
@ -97,6 +97,11 @@ async function resizeImage(filePath, outputPath, width, height) {
|
||||
}
|
||||
module.exports.resizeImage = resizeImage
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
|
||||
* @returns
|
||||
*/
|
||||
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
||||
return new Promise(async (resolve) => {
|
||||
const response = await axios({
|
||||
@ -118,32 +123,33 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
||||
ffmpeg.addOption('-loglevel debug') // Debug logs printed on error
|
||||
ffmpeg.outputOptions('-c:a', 'copy', '-map', '0:a', '-metadata', 'podcast=1')
|
||||
|
||||
const podcastMetadata = podcastEpisodeDownload.libraryItem.media.metadata
|
||||
const podcastEpisode = podcastEpisodeDownload.podcastEpisode
|
||||
/** @type {import('../models/Podcast')} */
|
||||
const podcast = podcastEpisodeDownload.libraryItem.media
|
||||
const podcastEpisode = podcastEpisodeDownload.rssPodcastEpisode
|
||||
const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0)
|
||||
|
||||
const taggings = {
|
||||
album: podcastMetadata.title,
|
||||
'album-sort': podcastMetadata.title,
|
||||
artist: podcastMetadata.author,
|
||||
'artist-sort': podcastMetadata.author,
|
||||
album: podcast.title,
|
||||
'album-sort': podcast.title,
|
||||
artist: podcast.author,
|
||||
'artist-sort': podcast.author,
|
||||
comment: podcastEpisode.description,
|
||||
subtitle: podcastEpisode.subtitle,
|
||||
disc: podcastEpisode.season,
|
||||
genre: podcastMetadata.genres.length ? podcastMetadata.genres.join(';') : null,
|
||||
language: podcastMetadata.language,
|
||||
MVNM: podcastMetadata.title,
|
||||
genre: podcast.genres.length ? podcast.genres.join(';') : null,
|
||||
language: podcast.language,
|
||||
MVNM: podcast.title,
|
||||
MVIN: podcastEpisode.episode,
|
||||
track: podcastEpisode.episode,
|
||||
'series-part': podcastEpisode.episode,
|
||||
title: podcastEpisode.title,
|
||||
'title-sort': podcastEpisode.title,
|
||||
year: podcastEpisode.pubYear,
|
||||
year: podcastEpisodeDownload.pubYear,
|
||||
date: podcastEpisode.pubDate,
|
||||
releasedate: podcastEpisode.pubDate,
|
||||
'itunes-id': podcastMetadata.itunesId,
|
||||
'podcast-type': podcastMetadata.type,
|
||||
'episode-type': podcastMetadata.episodeType
|
||||
'itunes-id': podcast.itunesId,
|
||||
'podcast-type': podcast.podcastType,
|
||||
'episode-type': podcastEpisode.episodeType
|
||||
}
|
||||
|
||||
for (const tag in taggings) {
|
||||
|
@ -1200,7 +1200,7 @@ async function migrationPatchNewColumns(queryInterface) {
|
||||
*/
|
||||
async function handleOldLibraryItems(ctx) {
|
||||
const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')
|
||||
const libraryItems = await ctx.models.libraryItem.getAllOldLibraryItems()
|
||||
const libraryItems = (await ctx.models.libraryItem.findAllExpandedWhere()).map((li) => ctx.models.libraryItem.getOldLibraryItem(li))
|
||||
|
||||
const bulkUpdateItems = []
|
||||
const bulkUpdateEpisodes = []
|
||||
|
@ -4,6 +4,49 @@ const Logger = require('../Logger')
|
||||
const { xmlToJSON, levenshteinDistance } = require('./index')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
/**
|
||||
* @typedef RssPodcastEpisode
|
||||
* @property {string} title
|
||||
* @property {string} subtitle
|
||||
* @property {string} description
|
||||
* @property {string} descriptionPlain
|
||||
* @property {string} pubDate
|
||||
* @property {string} episodeType
|
||||
* @property {string} season
|
||||
* @property {string} episode
|
||||
* @property {string} author
|
||||
* @property {string} duration
|
||||
* @property {string} explicit
|
||||
* @property {number} publishedAt - Unix timestamp
|
||||
* @property {{ url: string, type?: string, length?: string }} enclosure
|
||||
* @property {string} guid
|
||||
* @property {string} chaptersUrl
|
||||
* @property {string} chaptersType
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef RssPodcastMetadata
|
||||
* @property {string} title
|
||||
* @property {string} language
|
||||
* @property {string} explicit
|
||||
* @property {string} author
|
||||
* @property {string} pubDate
|
||||
* @property {string} link
|
||||
* @property {string} image
|
||||
* @property {string[]} categories
|
||||
* @property {string} feedUrl
|
||||
* @property {string} description
|
||||
* @property {string} descriptionPlain
|
||||
* @property {string} type
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef RssPodcast
|
||||
* @property {RssPodcastMetadata} metadata
|
||||
* @property {RssPodcastEpisode[]} episodes
|
||||
* @property {number} numEpisodes
|
||||
*/
|
||||
|
||||
function extractFirstArrayItem(json, key) {
|
||||
if (!json[key]?.length) return null
|
||||
return json[key][0]
|
||||
@ -223,7 +266,7 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal
|
||||
*
|
||||
* @param {string} feedUrl
|
||||
* @param {boolean} [excludeEpisodeMetadata=false]
|
||||
* @returns {Promise}
|
||||
* @returns {Promise<RssPodcast|null>}
|
||||
*/
|
||||
module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
||||
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)
|
||||
|
@ -18,7 +18,7 @@ module.exports = {
|
||||
* @param {string} libraryId
|
||||
* @param {import('../../models/User')} user
|
||||
* @param {object} options
|
||||
* @returns {object} { libraryItems:LibraryItem[], count:number }
|
||||
* @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}
|
||||
*/
|
||||
async getFilteredLibraryItems(libraryId, user, options) {
|
||||
const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType } = options
|
||||
@ -52,7 +52,7 @@ module.exports = {
|
||||
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'in-progress', 'progress', true, false, include, limit, 0, true)
|
||||
return {
|
||||
items: libraryItems.map((li) => {
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||
const oldLibraryItem = li.toOldJSONMinified()
|
||||
if (li.rssFeed) {
|
||||
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
@ -68,7 +68,7 @@ module.exports = {
|
||||
return {
|
||||
count,
|
||||
items: libraryItems.map((li) => {
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||
const oldLibraryItem = li.toOldJSONMinified()
|
||||
oldLibraryItem.recentEpisode = li.recentEpisode
|
||||
return oldLibraryItem
|
||||
})
|
||||
@ -89,7 +89,7 @@ module.exports = {
|
||||
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, false, include, limit, 0)
|
||||
return {
|
||||
libraryItems: libraryItems.map((li) => {
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||
const oldLibraryItem = li.toOldJSONMinified()
|
||||
if (li.rssFeed) {
|
||||
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
@ -107,7 +107,7 @@ module.exports = {
|
||||
const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, include, limit, 0)
|
||||
return {
|
||||
libraryItems: libraryItems.map((li) => {
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||
const oldLibraryItem = li.toOldJSONMinified()
|
||||
if (li.rssFeed) {
|
||||
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
@ -136,7 +136,7 @@ module.exports = {
|
||||
const { libraryItems, count } = await libraryItemsBookFilters.getContinueSeriesLibraryItems(library, user, include, limit, 0)
|
||||
return {
|
||||
libraryItems: libraryItems.map((li) => {
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||
const oldLibraryItem = li.toOldJSONMinified()
|
||||
if (li.rssFeed) {
|
||||
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
@ -166,7 +166,7 @@ module.exports = {
|
||||
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'finished', 'progress', true, false, include, limit, 0)
|
||||
return {
|
||||
items: libraryItems.map((li) => {
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||
const oldLibraryItem = li.toOldJSONMinified()
|
||||
if (li.rssFeed) {
|
||||
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
@ -182,7 +182,7 @@ module.exports = {
|
||||
return {
|
||||
count,
|
||||
items: libraryItems.map((li) => {
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||
const oldLibraryItem = li.toOldJSONMinified()
|
||||
oldLibraryItem.recentEpisode = li.recentEpisode
|
||||
return oldLibraryItem
|
||||
})
|
||||
@ -293,15 +293,17 @@ module.exports = {
|
||||
})
|
||||
oldSeries.books = s.bookSeries
|
||||
.map((bs) => {
|
||||
const libraryItem = bs.book.libraryItem?.toJSON()
|
||||
const libraryItem = bs.book.libraryItem
|
||||
if (!libraryItem) {
|
||||
Logger.warn(`Book series book has no libraryItem`, bs, bs.book, 'series=', series)
|
||||
return null
|
||||
}
|
||||
|
||||
delete bs.book.libraryItem
|
||||
bs.book.authors = [] // Not needed
|
||||
bs.book.series = [] // Not needed
|
||||
libraryItem.media = bs.book
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONMinified()
|
||||
const oldLibraryItem = libraryItem.toOldJSONMinified()
|
||||
return oldLibraryItem
|
||||
})
|
||||
.filter((b) => b)
|
||||
@ -373,7 +375,7 @@ module.exports = {
|
||||
const { libraryItems, count } = await libraryItemsBookFilters.getDiscoverLibraryItems(library.id, user, include, limit)
|
||||
return {
|
||||
libraryItems: libraryItems.map((li) => {
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||
const oldLibraryItem = li.toOldJSONMinified()
|
||||
if (li.rssFeed) {
|
||||
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
@ -400,7 +402,7 @@ module.exports = {
|
||||
return {
|
||||
count,
|
||||
libraryItems: libraryItems.map((li) => {
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||
const oldLibraryItem = li.toOldJSONMinified()
|
||||
oldLibraryItem.recentEpisode = li.recentEpisode
|
||||
return oldLibraryItem
|
||||
})
|
||||
|
@ -349,7 +349,7 @@ module.exports = {
|
||||
* @param {number} limit
|
||||
* @param {number} offset
|
||||
* @param {boolean} isHomePage for home page shelves
|
||||
* @returns {object} { libraryItems:LibraryItem[], count:number }
|
||||
* @returns {{ libraryItems: import('../../models/LibraryItem')[], count: number }}
|
||||
*/
|
||||
async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset, isHomePage = false) {
|
||||
// TODO: Handle collapse sub-series
|
||||
@ -583,8 +583,8 @@ module.exports = {
|
||||
})
|
||||
|
||||
const libraryItems = books.map((bookExpanded) => {
|
||||
const libraryItem = bookExpanded.libraryItem.toJSON()
|
||||
const book = bookExpanded.toJSON()
|
||||
const libraryItem = bookExpanded.libraryItem
|
||||
const book = bookExpanded
|
||||
|
||||
if (filterGroup === 'series' && book.series?.length) {
|
||||
// For showing sequence on book cover when filtering for series
|
||||
@ -596,27 +596,37 @@ module.exports = {
|
||||
}
|
||||
|
||||
delete book.libraryItem
|
||||
delete book.authors
|
||||
delete book.series
|
||||
|
||||
book.series =
|
||||
book.bookSeries?.map((bs) => {
|
||||
const series = bs.series
|
||||
delete bs.series
|
||||
series.bookSeries = bs
|
||||
return series
|
||||
}) || []
|
||||
delete book.bookSeries
|
||||
|
||||
book.authors = book.bookAuthors?.map((ba) => ba.author) || []
|
||||
delete book.bookAuthors
|
||||
|
||||
// For showing details of collapsed series
|
||||
if (collapseseries && book.bookSeries?.length) {
|
||||
const collapsedSeries = book.bookSeries.find((bs) => collapseSeriesBookSeries.some((cbs) => cbs.id === bs.id))
|
||||
if (collapseseries && book.series?.length) {
|
||||
const collapsedSeries = book.series.find((bs) => collapseSeriesBookSeries.some((cbs) => cbs.id === bs.bookSeries.id))
|
||||
if (collapsedSeries) {
|
||||
const collapseSeriesObj = collapseSeriesBookSeries.find((csbs) => csbs.id === collapsedSeries.id)
|
||||
const collapseSeriesObj = collapseSeriesBookSeries.find((csbs) => csbs.id === collapsedSeries.bookSeries.id)
|
||||
libraryItem.collapsedSeries = {
|
||||
id: collapsedSeries.series.id,
|
||||
name: collapsedSeries.series.name,
|
||||
nameIgnorePrefix: collapsedSeries.series.nameIgnorePrefix,
|
||||
sequence: collapsedSeries.sequence,
|
||||
id: collapsedSeries.id,
|
||||
name: collapsedSeries.name,
|
||||
nameIgnorePrefix: collapsedSeries.nameIgnorePrefix,
|
||||
sequence: collapsedSeries.bookSeries.sequence,
|
||||
numBooks: collapseSeriesObj?.numBooks || 0,
|
||||
libraryItemIds: collapseSeriesObj?.libraryItemIds || []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bookExpanded.libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0]
|
||||
if (libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
||||
}
|
||||
|
||||
if (includeMediaItemShare) {
|
||||
@ -646,7 +656,7 @@ module.exports = {
|
||||
* @param {string[]} include
|
||||
* @param {number} limit
|
||||
* @param {number} offset
|
||||
* @returns {{ libraryItems:import('../../models/LibraryItem')[], count:number }}
|
||||
* @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}
|
||||
*/
|
||||
async getContinueSeriesLibraryItems(library, user, include, limit, offset) {
|
||||
const libraryId = library.id
|
||||
@ -758,16 +768,19 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
|
||||
const libraryItem = s.bookSeries[bookIndex].book.libraryItem.toJSON()
|
||||
const book = s.bookSeries[bookIndex].book.toJSON()
|
||||
const libraryItem = s.bookSeries[bookIndex].book.libraryItem
|
||||
const book = s.bookSeries[bookIndex].book
|
||||
delete book.libraryItem
|
||||
|
||||
book.series = []
|
||||
|
||||
libraryItem.series = {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
sequence: s.bookSeries[bookIndex].sequence
|
||||
}
|
||||
if (s.bookSeries[bookIndex].book.libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = s.bookSeries[bookIndex].book.libraryItem.feeds[0]
|
||||
if (libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
||||
}
|
||||
libraryItem.media = book
|
||||
return libraryItem
|
||||
@ -788,7 +801,7 @@ module.exports = {
|
||||
* @param {import('../../models/User')} user
|
||||
* @param {string[]} include
|
||||
* @param {number} limit
|
||||
* @returns {object} {libraryItems:LibraryItem, count:number}
|
||||
* @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>}
|
||||
*/
|
||||
async getDiscoverLibraryItems(libraryId, user, include, limit) {
|
||||
const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)
|
||||
@ -895,13 +908,26 @@ module.exports = {
|
||||
|
||||
// Step 3: Map books to library items
|
||||
const libraryItems = books.map((bookExpanded) => {
|
||||
const libraryItem = bookExpanded.libraryItem.toJSON()
|
||||
const book = bookExpanded.toJSON()
|
||||
const libraryItem = bookExpanded.libraryItem
|
||||
const book = bookExpanded
|
||||
delete book.libraryItem
|
||||
|
||||
book.series =
|
||||
book.bookSeries?.map((bs) => {
|
||||
const series = bs.series
|
||||
delete bs.series
|
||||
series.bookSeries = bs
|
||||
return series
|
||||
}) || []
|
||||
delete book.bookSeries
|
||||
|
||||
book.authors = book.bookAuthors?.map((ba) => ba.author) || []
|
||||
delete book.bookAuthors
|
||||
|
||||
libraryItem.media = book
|
||||
|
||||
if (bookExpanded.libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0]
|
||||
if (libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
||||
}
|
||||
|
||||
return libraryItem
|
||||
@ -961,11 +987,11 @@ module.exports = {
|
||||
* Get library items for series
|
||||
* @param {import('../../models/Series')} series
|
||||
* @param {import('../../models/User')} [user]
|
||||
* @returns {Promise<import('../../objects/LibraryItem')[]>}
|
||||
* @returns {Promise<import('../../models/LibraryItem')[]>}
|
||||
*/
|
||||
async getLibraryItemsForSeries(series, user) {
|
||||
const { libraryItems } = await this.getFilteredLibraryItems(series.libraryId, user, 'series', series.id, null, null, false, [], null, null)
|
||||
return libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li))
|
||||
return libraryItems
|
||||
},
|
||||
|
||||
/**
|
||||
@ -1040,9 +1066,21 @@ module.exports = {
|
||||
for (const book of books) {
|
||||
const libraryItem = book.libraryItem
|
||||
delete book.libraryItem
|
||||
|
||||
book.series = book.bookSeries.map((bs) => {
|
||||
const series = bs.series
|
||||
delete bs.series
|
||||
series.bookSeries = bs
|
||||
return series
|
||||
})
|
||||
delete book.bookSeries
|
||||
|
||||
book.authors = book.bookAuthors.map((ba) => ba.author)
|
||||
delete book.bookAuthors
|
||||
|
||||
libraryItem.media = book
|
||||
itemMatches.push({
|
||||
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
|
||||
libraryItem: libraryItem.toOldJSONExpanded()
|
||||
})
|
||||
}
|
||||
|
||||
@ -1132,7 +1170,9 @@ module.exports = {
|
||||
const books = series.bookSeries.map((bs) => {
|
||||
const libraryItem = bs.book.libraryItem
|
||||
libraryItem.media = bs.book
|
||||
return Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSON()
|
||||
libraryItem.media.authors = []
|
||||
libraryItem.media.series = []
|
||||
return libraryItem.toOldJSON()
|
||||
})
|
||||
seriesMatches.push({
|
||||
series: series.toOldJSON(),
|
||||
|
@ -107,7 +107,7 @@ module.exports = {
|
||||
* @param {string[]} include
|
||||
* @param {number} limit
|
||||
* @param {number} offset
|
||||
* @returns {object} { libraryItems:LibraryItem[], count:number }
|
||||
* @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>}
|
||||
*/
|
||||
async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) {
|
||||
const includeRSSFeed = include.includes('rssfeed')
|
||||
@ -175,16 +175,19 @@ module.exports = {
|
||||
})
|
||||
|
||||
const libraryItems = podcasts.map((podcastExpanded) => {
|
||||
const libraryItem = podcastExpanded.libraryItem.toJSON()
|
||||
const podcast = podcastExpanded.toJSON()
|
||||
const libraryItem = podcastExpanded.libraryItem
|
||||
const podcast = podcastExpanded
|
||||
|
||||
delete podcast.libraryItem
|
||||
|
||||
if (podcastExpanded.libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = podcastExpanded.libraryItem.feeds[0]
|
||||
if (libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
||||
}
|
||||
if (podcast.numEpisodesIncomplete) {
|
||||
libraryItem.numEpisodesIncomplete = podcast.numEpisodesIncomplete
|
||||
if (podcast.dataValues.numEpisodesIncomplete) {
|
||||
libraryItem.numEpisodesIncomplete = podcast.dataValues.numEpisodesIncomplete
|
||||
}
|
||||
if (podcast.dataValues.numEpisodes) {
|
||||
podcast.numEpisodes = podcast.dataValues.numEpisodes
|
||||
}
|
||||
|
||||
libraryItem.media = podcast
|
||||
@ -209,7 +212,7 @@ module.exports = {
|
||||
* @param {number} limit
|
||||
* @param {number} offset
|
||||
* @param {boolean} isHomePage for home page shelves
|
||||
* @returns {object} {libraryItems:LibraryItem[], count:number}
|
||||
* @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>}
|
||||
*/
|
||||
async getFilteredPodcastEpisodes(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, limit, offset, isHomePage = false) {
|
||||
if (sortBy === 'progress' && filterGroup !== 'progress') {
|
||||
@ -289,10 +292,11 @@ module.exports = {
|
||||
})
|
||||
|
||||
const libraryItems = podcastEpisodes.map((ep) => {
|
||||
const libraryItem = ep.podcast.libraryItem.toJSON()
|
||||
const podcast = ep.podcast.toJSON()
|
||||
const libraryItem = ep.podcast.libraryItem
|
||||
const podcast = ep.podcast
|
||||
delete podcast.libraryItem
|
||||
libraryItem.media = podcast
|
||||
|
||||
libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSON()
|
||||
return libraryItem
|
||||
})
|
||||
@ -362,8 +366,9 @@ module.exports = {
|
||||
const libraryItem = podcast.libraryItem
|
||||
delete podcast.libraryItem
|
||||
libraryItem.media = podcast
|
||||
libraryItem.media.podcastEpisodes = []
|
||||
itemMatches.push({
|
||||
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
|
||||
libraryItem: libraryItem.toOldJSONExpanded()
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -162,6 +162,12 @@ module.exports = {
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
},
|
||||
{
|
||||
model: Database.authorModel
|
||||
},
|
||||
{
|
||||
model: Database.seriesModel
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -195,10 +201,10 @@ module.exports = {
|
||||
})
|
||||
})
|
||||
oldSeries.books = s.bookSeries.map((bs) => {
|
||||
const libraryItem = bs.book.libraryItem.toJSON()
|
||||
const libraryItem = bs.book.libraryItem
|
||||
delete bs.book.libraryItem
|
||||
libraryItem.media = bs.book
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONMinified()
|
||||
const oldLibraryItem = libraryItem.toOldJSONMinified()
|
||||
return oldLibraryItem
|
||||
})
|
||||
allOldSeries.push(oldSeries)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user