mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-26 12:06:18 +02:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32b886a0c3 | |||
| 2463c62bbf | |||
| 6057930507 | |||
| 9bbb23b853 | |||
| e865241258 | |||
| 1a67f57551 | |||
| 9b5bdc1fdb | |||
| acda776e3e | |||
| 8c4a9280ab | |||
| 1812282946 | |||
| 64e9ac9d8f | |||
| 0da9a04d8e | |||
| 11178f58bd | |||
| 08b2d07f65 | |||
| 3c210170b2 | |||
| 03d35421b4 | |||
| a176ba53e0 | |||
| e34dff8f30 | |||
| 0881ab4bfb | |||
| 20c32efd62 | |||
| e2b8127a5b | |||
| 90f32cefca | |||
| ab2e661e22 | |||
| a073aedca2 | |||
| b440a22ec9 | |||
| ec695e5f48 | |||
| 69ad0bf113 | |||
| 88f464398a | |||
| 6fce501389 | |||
| 559fab0d90 | |||
| 69c428802b | |||
| 6da631fa4f | |||
| f83b081791 | |||
| a6ce5fdd98 | |||
| 0a2e725bd3 | |||
| c07c4a3341 | |||
| 422773e745 | |||
| 7a298aa6f5 | |||
| 41daf557aa | |||
| de5bc63d88 | |||
| 5e2282ef76 | |||
| c819afc53b | |||
| 37221a0446 | |||
| 0f20ed101e | |||
| b0dbccd283 | |||
| 7001adb4dd | |||
| 02ecf7ccfe | |||
| 05ff5f1956 | |||
| 1649fb40db | |||
| 052e0059ff | |||
| 5edd799b3e | |||
| 1632d8edee | |||
| e6181196a7 | |||
| bea9d6aff4 | |||
| d410b13c9b | |||
| 8286aad7a4 | |||
| ed5960825b | |||
| 7fd8178dde | |||
| db17a5c88b | |||
| 2ec84edb5e | |||
| 0eed38b771 | |||
| 977bdbf0bb | |||
| a1ec10bd0d | |||
| 57d742b862 | |||
| 108eaba022 | |||
| ac159bea72 | |||
| d5ce7b4939 | |||
| e64302f1d4 | |||
| fdbca4feb6 | |||
| f366dfa909 | |||
| 5d1a17ffa8 | |||
| 0ed4ea9138 | |||
| 43d8d9b286 | |||
| f70f21455f | |||
| a6fd0c95b2 | |||
| fe2ba083be | |||
| 0d8d0a650b |
@@ -46,5 +46,10 @@ RUN apk del make python3 g++
|
|||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
|
ENV PORT=80
|
||||||
|
ENV CONFIG_PATH="/config"
|
||||||
|
ENV METADATA_PATH="/metadata"
|
||||||
|
ENV SOURCE="docker"
|
||||||
|
|
||||||
ENTRYPOINT ["tini", "--"]
|
ENTRYPOINT ["tini", "--"]
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :playback-rate="currentPlaybackRate" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||||
|
|
||||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||||
|
|
||||||
|
|||||||
@@ -5,24 +5,26 @@
|
|||||||
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
|
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div v-if="show" class="w-full rounded-lg bg-bg box-shadow-md relative" style="max-height: 80vh">
|
||||||
<div v-if="show" class="w-full h-full">
|
<div v-if="bookmarks.length" class="h-full max-h-[calc(80vh-60px)] w-full relative overflow-y-auto overflow-x-hidden">
|
||||||
<template v-for="bookmark in bookmarks">
|
<template v-for="bookmark in bookmarks">
|
||||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
|
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" :playback-rate="playbackRate" @click="clickBookmark" @delete="deleteBookmark" />
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
</div>
|
||||||
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
|
<div v-else class="flex h-32 items-center justify-center">
|
||||||
</div>
|
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
|
||||||
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
|
</div>
|
||||||
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
|
|
||||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
<div v-if="canCreateBookmark && !hideCreate" class="w-full border-t border-white/10">
|
||||||
|
<form @submit.prevent="submitCreateBookmark">
|
||||||
|
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
||||||
<div class="w-16 max-w-16 text-center">
|
<div class="w-16 max-w-16 text-center">
|
||||||
<p class="text-sm font-mono text-gray-400">
|
<p class="text-sm font-mono text-gray-400">
|
||||||
{{ this.$secondsToTimestamp(currentTime) }}
|
{{ this.$secondsToTimestamp(currentTime / playbackRate) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full h-10" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">add</span></ui-btn>
|
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">add</span></ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,6 +47,7 @@ export default {
|
|||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
libraryItemId: String,
|
libraryItemId: String,
|
||||||
|
playbackRate: Number,
|
||||||
hideCreate: Boolean
|
hideCreate: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -57,6 +60,7 @@ export default {
|
|||||||
watch: {
|
watch: {
|
||||||
show(newVal) {
|
show(newVal) {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
|
this.selectedBookmark = null
|
||||||
this.showBookmarkTitleInput = false
|
this.showBookmarkTitleInput = false
|
||||||
this.newBookmarkTitle = ''
|
this.newBookmarkTitle = ''
|
||||||
}
|
}
|
||||||
@@ -72,7 +76,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
canCreateBookmark() {
|
canCreateBookmark() {
|
||||||
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
|
return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.state.serverSettings.dateFormat
|
||||||
@@ -102,19 +106,6 @@ export default {
|
|||||||
clickBookmark(bm) {
|
clickBookmark(bm) {
|
||||||
this.$emit('select', bm)
|
this.$emit('select', bm)
|
||||||
},
|
},
|
||||||
submitUpdateBookmark(updatedBookmark) {
|
|
||||||
var bookmark = { ...updatedBookmark }
|
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success(this.$strings.ToastBookmarkUpdateSuccess)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
|
||||||
console.error(error)
|
|
||||||
})
|
|
||||||
this.show = false
|
|
||||||
},
|
|
||||||
submitCreateBookmark() {
|
submitCreateBookmark() {
|
||||||
if (!this.newBookmarkTitle) {
|
if (!this.newBookmarkTitle) {
|
||||||
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
|
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center px-4 py-4 justify-start relative bg-primary hover:bg-opacity-25" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-primary/10" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div class="w-16 max-w-16 text-center">
|
<div class="w-16 max-w-16 text-center">
|
||||||
<p class="text-sm font-mono text-gray-400">
|
<p class="text-sm font-mono text-gray-400">
|
||||||
{{ this.$secondsToTimestamp(bookmark.time) }}
|
{{ this.$secondsToTimestamp(bookmark.time / playbackRate) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow overflow-hidden px-2">
|
<div class="flex-grow overflow-hidden px-2">
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<form @submit.prevent="submitUpdate">
|
<form @submit.prevent="submitUpdate">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-grow pr-2">
|
<div class="flex-grow pr-2">
|
||||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full h-10" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">forward</span></ui-btn>
|
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">forward</span></ui-btn>
|
||||||
<div class="pl-2 flex items-center">
|
<div class="pl-2 flex items-center">
|
||||||
@@ -35,7 +35,8 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
highlight: Boolean
|
highlight: Boolean,
|
||||||
|
playbackRate: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -83,11 +84,19 @@ export default {
|
|||||||
if (this.newBookmarkTitle === this.bookmark.title) {
|
if (this.newBookmarkTitle === this.bookmark.title) {
|
||||||
return this.cancelEditing()
|
return this.cancelEditing()
|
||||||
}
|
}
|
||||||
var bookmark = { ...this.bookmark }
|
const bookmark = { ...this.bookmark }
|
||||||
bookmark.title = this.newBookmarkTitle
|
bookmark.title = this.newBookmarkTitle
|
||||||
this.$emit('update', bookmark)
|
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/me/item/${bookmark.libraryItemId}/bookmark`, bookmark)
|
||||||
|
.then(() => {
|
||||||
|
this.isEditing = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
mounted() {}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -113,6 +113,10 @@ export default {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
console.log('updateResult', updateResult)
|
console.log('updateResult', updateResult)
|
||||||
|
} else if (!lastEpisodeCheck) {
|
||||||
|
this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)
|
||||||
|
this.checkingNewEpisodes = false
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
||||||
</div>
|
</div>
|
||||||
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" />
|
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" />
|
||||||
|
<div v-else>
|
||||||
|
<p class="text-yellow-400 text-base">{{ $strings.MessageScheduleLibraryScanNote }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small />
|
<ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-2/5 p-1">
|
<div class="w-2/5 p-1">
|
||||||
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" :label="$strings.LabelPubDate" />
|
<ui-text-input-with-label v-model="pubDateInput" ref="pubdate" type="datetime-local" :label="$strings.LabelPubDate" @input="updatePubDate" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full p-1">
|
<div class="w-full p-1">
|
||||||
<ui-text-input-with-label v-model="newEpisode.title" :label="$strings.LabelTitle" />
|
<ui-text-input-with-label v-model="newEpisode.title" :label="$strings.LabelTitle" />
|
||||||
@@ -145,11 +145,18 @@ export default {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check pubdate is valid if it is being updated. Cannot be set to null in the web client
|
||||||
|
if (this.newEpisode.pubDate === null && this.$refs.pubdate?.$refs?.input?.isInvalidDate) {
|
||||||
|
this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const updatedDetails = this.getUpdatePayload()
|
const updatedDetails = this.getUpdatePayload()
|
||||||
if (!Object.keys(updatedDetails).length) {
|
if (!Object.keys(updatedDetails).length) {
|
||||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.updateDetails(updatedDetails)
|
return this.updateDetails(updatedDetails)
|
||||||
},
|
},
|
||||||
async updateDetails(updatedDetails) {
|
async updateDetails(updatedDetails) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full">
|
<div class="absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full">
|
||||||
<controls-playback-speed-control v-model="playbackRate" @input="setPlaybackRate" @change="playbackRateChanged" class="mx-2 block" />
|
<controls-playback-speed-control v-model="playbackRate" @input="setPlaybackRate" @change="playbackRateChanged" class="mx-2 block" />
|
||||||
|
|
||||||
<ui-tooltip direction="left" :text="$strings.LabelVolume">
|
<ui-tooltip direction="bottom" :text="$strings.LabelVolume">
|
||||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden sm:block" />
|
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden sm:block" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
|
|||||||
@@ -63,9 +63,6 @@ export default {
|
|||||||
dayOfWeekToday() {
|
dayOfWeekToday() {
|
||||||
return new Date().getDay()
|
return new Date().getDay()
|
||||||
},
|
},
|
||||||
firstWeekStart() {
|
|
||||||
return this.$addDaysToToday(-this.daysToShow)
|
|
||||||
},
|
|
||||||
dayLabels() {
|
dayLabels() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -198,12 +195,25 @@ export default {
|
|||||||
let minValue = 0
|
let minValue = 0
|
||||||
|
|
||||||
const dates = []
|
const dates = []
|
||||||
for (let i = 0; i < this.daysToShow + 1; i++) {
|
|
||||||
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
|
const numDaysInTheLastYear = 52 * 7 + this.dayOfWeekToday
|
||||||
|
const firstDay = this.$addDaysToToday(-numDaysInTheLastYear)
|
||||||
|
for (let i = 0; i < numDaysInTheLastYear + 1; i++) {
|
||||||
|
const date = i === 0 ? firstDay : this.$addDaysToDate(firstDay, i)
|
||||||
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
||||||
|
|
||||||
|
if (this.daysListening[dateString] > 0) {
|
||||||
|
this.daysListenedInTheLastYear++
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleDayIndex = i - (numDaysInTheLastYear - this.daysToShow)
|
||||||
|
if (visibleDayIndex < 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const dateObj = {
|
const dateObj = {
|
||||||
col: Math.floor(i / 7),
|
col: Math.floor(visibleDayIndex / 7),
|
||||||
row: i % 7,
|
row: visibleDayIndex % 7,
|
||||||
date,
|
date,
|
||||||
dateString,
|
dateString,
|
||||||
datePretty: this.$formatJsDate(date, 'MMM d, yyyy'),
|
datePretty: this.$formatJsDate(date, 'MMM d, yyyy'),
|
||||||
@@ -215,7 +225,6 @@ export default {
|
|||||||
dates.push(dateObj)
|
dates.push(dateObj)
|
||||||
|
|
||||||
if (dateObj.value > 0) {
|
if (dateObj.value > 0) {
|
||||||
this.daysListenedInTheLastYear++
|
|
||||||
if (dateObj.value > maxValue) maxValue = dateObj.value
|
if (dateObj.value > maxValue) maxValue = dateObj.value
|
||||||
if (!minValue || dateObj.value < minValue) minValue = dateObj.value
|
if (!minValue || dateObj.value < minValue) minValue = dateObj.value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default {
|
|||||||
return this.episode?.title || ''
|
return this.episode?.title || ''
|
||||||
},
|
},
|
||||||
episodeSubtitle() {
|
episodeSubtitle() {
|
||||||
return this.episode?.subtitle || ''
|
return this.episode?.subtitle || this.episode?.description || ''
|
||||||
},
|
},
|
||||||
episodeType() {
|
episodeType() {
|
||||||
return this.episode?.episodeType || ''
|
return this.episode?.episodeType || ''
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
|
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative min-h-[176px]">
|
<div class="relative min-h-44">
|
||||||
<template v-for="episode in totalEpisodes">
|
<template v-for="episode in totalEpisodes">
|
||||||
<div :key="episode" :id="`episode-${episode - 1}`" class="w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10">
|
<div :key="episode" :id="`episode-${episode - 1}`" class="w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10">
|
||||||
<!-- episode is mounted here -->
|
<!-- episode is mounted here -->
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<div v-if="isSearching" class="w-full h-full absolute inset-0 flex justify-center py-12" :class="{ 'bg-black/50': totalEpisodes }">
|
<div v-if="isSearching" class="w-full h-full absolute inset-0 flex justify-center py-12" :class="{ 'bg-black/50': totalEpisodes }">
|
||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!totalEpisodes" class="h-44 flex items-center justify-center">
|
<div v-else-if="!totalEpisodes" id="no-episodes" class="h-44 flex items-center justify-center">
|
||||||
<p class="text-lg">{{ $strings.MessageNoEpisodes }}</p>
|
<p class="text-lg">{{ $strings.MessageNoEpisodes }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +80,8 @@ export default {
|
|||||||
episodeComponentRefs: {},
|
episodeComponentRefs: {},
|
||||||
windowHeight: 0,
|
windowHeight: 0,
|
||||||
episodesTableOffsetTop: 0,
|
episodesTableOffsetTop: 0,
|
||||||
episodeRowHeight: 176
|
episodeRowHeight: 44 * 4, // h-44,
|
||||||
|
currScrollTop: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -484,9 +485,8 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scroll(evt) {
|
handleScroll() {
|
||||||
if (!evt?.target?.scrollTop) return
|
const scrollTop = this.currScrollTop
|
||||||
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
|
|
||||||
let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight)
|
let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight)
|
||||||
let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight)
|
let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight)
|
||||||
lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex)
|
lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex)
|
||||||
@@ -501,6 +501,12 @@ export default {
|
|||||||
})
|
})
|
||||||
this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1)
|
this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1)
|
||||||
},
|
},
|
||||||
|
scroll(evt) {
|
||||||
|
if (!evt?.target?.scrollTop) return
|
||||||
|
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
|
||||||
|
this.currScrollTop = scrollTop
|
||||||
|
this.handleScroll()
|
||||||
|
},
|
||||||
initListeners() {
|
initListeners() {
|
||||||
const itemPageWrapper = document.getElementById('item-page-wrapper')
|
const itemPageWrapper = document.getElementById('item-page-wrapper')
|
||||||
if (itemPageWrapper) {
|
if (itemPageWrapper) {
|
||||||
@@ -532,11 +538,24 @@ export default {
|
|||||||
this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64
|
this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64
|
||||||
|
|
||||||
this.windowHeight = window.innerHeight
|
this.windowHeight = window.innerHeight
|
||||||
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
|
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.mountEpisodes(0, Math.min(this.episodesPerPage, this.totalEpisodes))
|
this.recalcEpisodeRowHeight()
|
||||||
|
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
|
||||||
|
// Maybe update currScrollTop if items were removed
|
||||||
|
const itemPageWrapper = document.getElementById('item-page-wrapper')
|
||||||
|
const { scrollHeight, clientHeight } = itemPageWrapper
|
||||||
|
const maxScrollTop = scrollHeight - clientHeight
|
||||||
|
this.currScrollTop = Math.min(this.currScrollTop, maxScrollTop)
|
||||||
|
this.handleScroll()
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
recalcEpisodeRowHeight() {
|
||||||
|
const episodeRowEl = document.getElementById('episode-0') || document.getElementById('no-episodes')
|
||||||
|
if (episodeRowEl) {
|
||||||
|
const height = getComputedStyle(episodeRowEl).height
|
||||||
|
this.episodeRowHeight = parseInt(height) || this.episodeRowHeight
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -1,24 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative">
|
<div ref="wrapper" class="relative">
|
||||||
<input
|
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="rounded bg-primary text-gray-200 focus:bg-bg focus:outline-none border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||||
:id="inputId"
|
|
||||||
:name="inputName"
|
|
||||||
ref="input"
|
|
||||||
v-model="inputValue"
|
|
||||||
:type="actualType"
|
|
||||||
:step="step"
|
|
||||||
:min="min"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
dir="auto"
|
|
||||||
class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full"
|
|
||||||
:class="classList"
|
|
||||||
@keyup="keyup"
|
|
||||||
@change="change"
|
|
||||||
@focus="focused"
|
|
||||||
@blur="blurred"
|
|
||||||
/>
|
|
||||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||||
<span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
<span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +47,8 @@ export default {
|
|||||||
showPassword: false,
|
showPassword: false,
|
||||||
isHovering: false,
|
isHovering: false,
|
||||||
isFocused: false,
|
isFocused: false,
|
||||||
hasCopied: false
|
hasCopied: false,
|
||||||
|
isInvalidDate: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -84,6 +67,10 @@ export default {
|
|||||||
if (this.noSpinner) _list.push('no-spinner')
|
if (this.noSpinner) _list.push('no-spinner')
|
||||||
if (this.textCenter) _list.push('text-center')
|
if (this.textCenter) _list.push('text-center')
|
||||||
if (this.customInputClass) _list.push(this.customInputClass)
|
if (this.customInputClass) _list.push(this.customInputClass)
|
||||||
|
|
||||||
|
if (this.isInvalidDate) _list.push('border-error')
|
||||||
|
else _list.push('focus:border-gray-300 border-gray-600')
|
||||||
|
|
||||||
return _list.join(' ')
|
return _list.join(' ')
|
||||||
},
|
},
|
||||||
actualType() {
|
actualType() {
|
||||||
@@ -118,6 +105,14 @@ export default {
|
|||||||
},
|
},
|
||||||
keyup(e) {
|
keyup(e) {
|
||||||
this.$emit('keyup', e)
|
this.$emit('keyup', e)
|
||||||
|
|
||||||
|
if (this.type === 'datetime-local') {
|
||||||
|
if (e.target.validity?.badInput) {
|
||||||
|
this.isInvalidDate = true
|
||||||
|
} else {
|
||||||
|
this.isInvalidDate = false
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
blur() {
|
blur() {
|
||||||
if (this.$refs.input) this.$refs.input.blur()
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<slot>
|
<slot>
|
||||||
<label :for="identifier" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }"
|
<label :for="identifier" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||||
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
|
{{ label }}
|
||||||
>
|
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||||
|
</label>
|
||||||
</slot>
|
</slot>
|
||||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -249,11 +249,33 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return target
|
return target
|
||||||
|
},
|
||||||
|
enableBreakParagraphOnReturn() {
|
||||||
|
// Trix works with divs by default, we want paragraphs instead
|
||||||
|
Trix.config.blockAttributes.default.tagName = 'p'
|
||||||
|
// Enable break paragraph on Enter (Shift + Enter will still create a line break)
|
||||||
|
Trix.config.blockAttributes.default.breakOnReturn = true
|
||||||
|
|
||||||
|
// Hack to fix buggy paragraph breaks
|
||||||
|
// Copied from https://github.com/basecamp/trix/issues/680#issuecomment-735742942
|
||||||
|
Trix.Block.prototype.breaksOnReturn = function () {
|
||||||
|
const attr = this.getLastAttribute()
|
||||||
|
const config = Trix.getBlockConfig(attr ? attr : 'default')
|
||||||
|
return config ? config.breakOnReturn : false
|
||||||
|
}
|
||||||
|
Trix.LineBreakInsertion.prototype.shouldInsertBlockBreak = function () {
|
||||||
|
if (this.block.hasAttributes() && this.block.isListItem() && !this.block.isEmpty()) {
|
||||||
|
return this.startLocation.offset > 0
|
||||||
|
} else {
|
||||||
|
return !this.shouldBreakFormattedBlock() ? this.breaksOnReturn : false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
/** Override editor configuration */
|
/** Override editor configuration */
|
||||||
this.overrideConfig(this.config)
|
this.overrideConfig(this.config)
|
||||||
|
this.enableBreakParagraphOnReturn()
|
||||||
/** Check if editor read-only mode is required */
|
/** Check if editor read-only mode is required */
|
||||||
this.decorateDisabledEditor(this.disabledEditor)
|
this.decorateDisabledEditor(this.disabledEditor)
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import '@/plugins/utils'
|
||||||
|
|
||||||
|
// This is the actual function that is being tested
|
||||||
|
const elapsedPrettyExtended = Vue.prototype.$elapsedPrettyExtended
|
||||||
|
|
||||||
|
// Helper function to convert days, hours, minutes, seconds to total seconds
|
||||||
|
function DHMStoSeconds(days, hours, minutes, seconds) {
|
||||||
|
return seconds + minutes * 60 + hours * 3600 + days * 86400
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('$elapsedPrettyExtended', () => {
|
||||||
|
describe('function is on the Vue Prototype', () => {
|
||||||
|
it('exists as a function on Vue.prototype', () => {
|
||||||
|
expect(Vue.prototype.$elapsedPrettyExtended).to.exist
|
||||||
|
expect(Vue.prototype.$elapsedPrettyExtended).to.be.a('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('param default values', () => {
|
||||||
|
const testSeconds = DHMStoSeconds(0, 25, 1, 5) // 25h 1m 5s = 90065 seconds
|
||||||
|
|
||||||
|
it('uses useDays=true showSeconds=true by default', () => {
|
||||||
|
expect(elapsedPrettyExtended(testSeconds)).to.equal('1d 1h 1m 5s')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only useDays=false overrides useDays but keeps showSeconds=true', () => {
|
||||||
|
expect(elapsedPrettyExtended(testSeconds, false)).to.equal('25h 1m 5s')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('explicit useDays=false showSeconds=false overrides both', () => {
|
||||||
|
expect(elapsedPrettyExtended(testSeconds, false, false)).to.equal('25h 1m')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useDays=false showSeconds=true', () => {
|
||||||
|
const useDaysFalse = false
|
||||||
|
const showSecondsTrue = true
|
||||||
|
const testCases = [
|
||||||
|
[[0, 0, 0, 0], '', '0s -> ""'],
|
||||||
|
[[0, 1, 0, 1], '1h 1s', '1h 1s -> 1h 1s'],
|
||||||
|
[[0, 25, 0, 1], '25h 1s', '25h 1s -> 25h 1s']
|
||||||
|
]
|
||||||
|
|
||||||
|
testCases.forEach(([dhms, expected, description]) => {
|
||||||
|
it(description, () => {
|
||||||
|
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysFalse, showSecondsTrue)).to.equal(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useDays=true showSeconds=true', () => {
|
||||||
|
const useDaysTrue = true
|
||||||
|
const showSecondsTrue = true
|
||||||
|
const testCases = [
|
||||||
|
[[0, 0, 0, 0], '', '0s -> ""'],
|
||||||
|
[[0, 1, 0, 1], '1h 1s', '1h 1s -> 1h 1s'],
|
||||||
|
[[0, 25, 0, 1], '1d 1h 1s', '25h 1s -> 1d 1h 1s']
|
||||||
|
]
|
||||||
|
|
||||||
|
testCases.forEach(([dhms, expected, description]) => {
|
||||||
|
it(description, () => {
|
||||||
|
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsTrue)).to.equal(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useDays=true showSeconds=false', () => {
|
||||||
|
const useDaysTrue = true
|
||||||
|
const showSecondsFalse = false
|
||||||
|
const testCases = [
|
||||||
|
[[0, 0, 0, 0], '', '0s -> ""'],
|
||||||
|
[[0, 1, 0, 0], '1h', '1h -> 1h'],
|
||||||
|
[[0, 1, 0, 1], '1h', '1h 1s -> 1h'],
|
||||||
|
[[0, 1, 1, 0], '1h 1m', '1h 1m -> 1h 1m'],
|
||||||
|
[[0, 25, 0, 0], '1d 1h', '25h -> 1d 1h'],
|
||||||
|
[[0, 25, 0, 1], '1d 1h', '25h 1s -> 1d 1h'],
|
||||||
|
[[2, 0, 0, 0], '2d', '2d -> 2d']
|
||||||
|
]
|
||||||
|
|
||||||
|
testCases.forEach(([dhms, expected, description]) => {
|
||||||
|
it(description, () => {
|
||||||
|
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsFalse)).to.equal(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rounding useDays=true showSeconds=true', () => {
|
||||||
|
const useDaysTrue = true
|
||||||
|
const showSecondsTrue = true
|
||||||
|
const testCases = [
|
||||||
|
// Seconds rounding
|
||||||
|
[[0, 0, 0, 1], '1s', '1s -> 1s'],
|
||||||
|
[[0, 0, 0, 29.9], '30s', '29.9s -> 30s'],
|
||||||
|
[[0, 0, 0, 30], '30s', '30s -> 30s'],
|
||||||
|
[[0, 0, 0, 30.1], '30s', '30.1s -> 30s'],
|
||||||
|
[[0, 0, 0, 59.4], '59s', '59.4s -> 59s'],
|
||||||
|
[[0, 0, 0, 59.5], '1m', '59.5s -> 1m'],
|
||||||
|
|
||||||
|
// Minutes rounding
|
||||||
|
[[0, 0, 59, 29], '59m 29s', '59m 29s -> 59m 29s'],
|
||||||
|
[[0, 0, 59, 30], '59m 30s', '59m 30s -> 59m 30s'],
|
||||||
|
[[0, 0, 59, 59.5], '1h', '59m 59.5s -> 1h'],
|
||||||
|
|
||||||
|
// Hours rounding
|
||||||
|
[[0, 23, 59, 29], '23h 59m 29s', '23h 59m 29s -> 23h 59m 29s'],
|
||||||
|
[[0, 23, 59, 30], '23h 59m 30s', '23h 59m 30s -> 23h 59m 30s'],
|
||||||
|
[[0, 23, 59, 59.5], '1d', '23h 59m 59.5s -> 1d'],
|
||||||
|
|
||||||
|
// The actual bug case
|
||||||
|
[[44, 23, 59, 30], '44d 23h 59m 30s', '44d 23h 59m 30s -> 44d 23h 59m 30s']
|
||||||
|
]
|
||||||
|
|
||||||
|
testCases.forEach(([dhms, expected, description]) => {
|
||||||
|
it(description, () => {
|
||||||
|
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsTrue)).to.equal(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rounding useDays=true showSeconds=false', () => {
|
||||||
|
const useDaysTrue = true
|
||||||
|
const showSecondsFalse = false
|
||||||
|
const testCases = [
|
||||||
|
// Seconds rounding - these cases changed behavior from original
|
||||||
|
[[0, 0, 0, 1], '', '1s -> ""'],
|
||||||
|
[[0, 0, 0, 29.9], '', '29.9s -> ""'],
|
||||||
|
[[0, 0, 0, 30], '', '30s -> ""'],
|
||||||
|
[[0, 0, 0, 30.1], '', '30.1s -> ""'],
|
||||||
|
[[0, 0, 0, 59.4], '', '59.4s -> ""'],
|
||||||
|
[[0, 0, 0, 59.5], '1m', '59.5s -> 1m'],
|
||||||
|
// This is unexpected behavior, but it's consistent with the original behavior
|
||||||
|
// We preserved the test case, to document the current behavior
|
||||||
|
// - with showSeconds=false,
|
||||||
|
// one might expect: 1m 29.5s --round(1.4901m)-> 1m
|
||||||
|
// actual implementation: 1h 29.5s --roundSeconds-> 1h 30s --roundMinutes-> 2m
|
||||||
|
// So because of the separate rounding of seconds, and then minutes, it returns 2m
|
||||||
|
[[0, 0, 1, 29.5], '2m', '1m 29.5s -> 2m'],
|
||||||
|
|
||||||
|
// Minutes carry - actual bug fixes below
|
||||||
|
[[0, 0, 59, 29], '59m', '59m 29s -> 59m'],
|
||||||
|
[[0, 0, 59, 30], '1h', '59m 30s -> 1h'], // This was an actual bug, used to return 60m
|
||||||
|
[[0, 0, 59, 59.5], '1h', '59m 59.5s -> 1h'],
|
||||||
|
|
||||||
|
// Hours carry
|
||||||
|
[[0, 23, 59, 29], '23h 59m', '23h 59m 29s -> 23h 59m'],
|
||||||
|
[[0, 23, 59, 30], '1d', '23h 59m 30s -> 1d'], // This was an actual bug, used to return 23h 60m
|
||||||
|
[[0, 23, 59, 59.5], '1d', '23h 59m 59.5s -> 1d'],
|
||||||
|
|
||||||
|
// The actual bug case
|
||||||
|
[[44, 23, 59, 30], '45d', '44d 23h 59m 30s -> 45d'] // This was an actual bug, used to return 44d 23h 60m
|
||||||
|
]
|
||||||
|
|
||||||
|
testCases.forEach(([dhms, expected, description]) => {
|
||||||
|
it(description, () => {
|
||||||
|
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsFalse)).to.equal(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('empty values', () => {
|
||||||
|
const paramCombos = [
|
||||||
|
// useDays, showSeconds, description
|
||||||
|
[true, true, 'with days and seconds'],
|
||||||
|
[true, false, 'with days, no seconds'],
|
||||||
|
[false, true, 'no days, with seconds'],
|
||||||
|
[false, false, 'no days, no seconds']
|
||||||
|
]
|
||||||
|
|
||||||
|
const emptyInputs = [
|
||||||
|
// input, description
|
||||||
|
[null, 'null input'],
|
||||||
|
[undefined, 'undefined input'],
|
||||||
|
[0, 'zero'],
|
||||||
|
[0.49, 'rounds to zero'] // Just under rounding threshold
|
||||||
|
]
|
||||||
|
|
||||||
|
paramCombos.forEach(([useDays, showSeconds, paramDesc]) => {
|
||||||
|
describe(paramDesc, () => {
|
||||||
|
emptyInputs.forEach(([input, desc]) => {
|
||||||
|
it(desc, () => {
|
||||||
|
expect(elapsedPrettyExtended(input, useDays, showSeconds)).to.equal('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :playback-rate="1" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -69,17 +69,22 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = t
|
|||||||
let hours = Math.floor(minutes / 60)
|
let hours = Math.floor(minutes / 60)
|
||||||
minutes -= hours * 60
|
minutes -= hours * 60
|
||||||
|
|
||||||
|
// Handle rollovers before days calculation
|
||||||
|
if (minutes && seconds && !showSeconds) {
|
||||||
|
if (seconds >= 30) minutes++
|
||||||
|
if (minutes >= 60) {
|
||||||
|
hours++ // Increment hours if minutes roll over
|
||||||
|
minutes -= 60 // adjust minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now calculate days with the final hours value
|
||||||
let days = 0
|
let days = 0
|
||||||
if (useDays || Math.floor(hours / 24) >= 100) {
|
if (useDays || Math.floor(hours / 24) >= 100) {
|
||||||
days = Math.floor(hours / 24)
|
days = Math.floor(hours / 24)
|
||||||
hours -= days * 24
|
hours -= days * 24
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not showing seconds then round minutes up
|
|
||||||
if (minutes && seconds && !showSeconds) {
|
|
||||||
if (seconds >= 30) minutes++
|
|
||||||
}
|
|
||||||
|
|
||||||
const strs = []
|
const strs = []
|
||||||
if (days) strs.push(`${days}d`)
|
if (days) strs.push(`${days}d`)
|
||||||
if (hours) strs.push(`${hours}h`)
|
if (hours) strs.push(`${hours}h`)
|
||||||
|
|||||||
+117
-1
@@ -1 +1,117 @@
|
|||||||
{}
|
{
|
||||||
|
"ButtonAdd": "Дадаць",
|
||||||
|
"ButtonAddChapters": "Дадаць раздзелы",
|
||||||
|
"ButtonAddDevice": "Дадаць прыладу",
|
||||||
|
"ButtonAddLibrary": "Дадаць бібліятэку",
|
||||||
|
"ButtonAddPodcasts": "Дадаць падкасты",
|
||||||
|
"ButtonAddUser": "Дадаць карыстальніка",
|
||||||
|
"ButtonAddYourFirstLibrary": "Дадайце сваю першую бібліятэку",
|
||||||
|
"ButtonApply": "Ужыць",
|
||||||
|
"ButtonApplyChapters": "Ужыць раздзелы",
|
||||||
|
"ButtonAuthors": "Аўтары",
|
||||||
|
"ButtonBack": "Назад",
|
||||||
|
"ButtonBrowseForFolder": "Знайсці тэчку",
|
||||||
|
"ButtonCancel": "Адмяніць",
|
||||||
|
"ButtonCancelEncode": "Адмяніць кадзіраванне",
|
||||||
|
"ButtonChangeRootPassword": "Зменіце Root пароль",
|
||||||
|
"ButtonCheckAndDownloadNewEpisodes": "Праверыць і спампаваць новыя эпізоды",
|
||||||
|
"ButtonChooseAFolder": "Выбраць тэчку",
|
||||||
|
"ButtonChooseFiles": "Выбраць файлы",
|
||||||
|
"ButtonClearFilter": "Ачысціць фільтр",
|
||||||
|
"ButtonCloseFeed": "Закрыць стужку",
|
||||||
|
"ButtonCloseSession": "Закрыць адкрыты сеанс",
|
||||||
|
"ButtonCollections": "Калекцыі",
|
||||||
|
"ButtonConfigureScanner": "Наладзіць сканер",
|
||||||
|
"ButtonCreate": "Ствараць",
|
||||||
|
"ButtonCreateBackup": "Стварыць рэзервовую копію",
|
||||||
|
"ButtonDelete": "Выдаліць",
|
||||||
|
"ButtonDownloadQueue": "Чарга",
|
||||||
|
"ButtonEdit": "Рэдагаваць",
|
||||||
|
"ButtonEditChapters": "Рэдагаваць раздзелы",
|
||||||
|
"ButtonEditPodcast": "Рэдагаваць падкаст",
|
||||||
|
"ButtonEnable": "Уключыць",
|
||||||
|
"ButtonFireAndFail": "Агонь і няўдача",
|
||||||
|
"ButtonFireOnTest": "Тэст на вогнеўстойлівасць",
|
||||||
|
"ButtonForceReScan": "Прымусовае паўторнае сканаванне",
|
||||||
|
"ButtonFullPath": "Поўны шлях",
|
||||||
|
"ButtonHide": "Схаваць",
|
||||||
|
"ButtonIssues": "Праблемы",
|
||||||
|
"ButtonJumpBackward": "Перайсці назад",
|
||||||
|
"ButtonJumpForward": "Перайсці наперад",
|
||||||
|
"ButtonLibrary": "Бібліятэка",
|
||||||
|
"ButtonLogout": "Выйсці",
|
||||||
|
"ButtonLookup": "",
|
||||||
|
"ButtonMapChapterTitles": "Супаставіць назвы раздзелаў",
|
||||||
|
"ButtonMatchAllAuthors": "Супадзенне ўсіх аўтараў",
|
||||||
|
"ButtonNevermind": "Няважна",
|
||||||
|
"ButtonNext": "Далей",
|
||||||
|
"ButtonNextChapter": "Наступны раздзел",
|
||||||
|
"ButtonNextItemInQueue": "Наступны элемент у чарзе",
|
||||||
|
"ButtonOk": "Добра",
|
||||||
|
"ButtonOpenFeed": "Адкрыць стужку",
|
||||||
|
"ButtonOpenManager": "Адкрыць менеджар",
|
||||||
|
"ButtonPause": "Паўза",
|
||||||
|
"ButtonPlay": "Прайграць",
|
||||||
|
"ButtonPlayAll": "Прайграць усё",
|
||||||
|
"ButtonPlaying": "Прайграваецца",
|
||||||
|
"ButtonPlaylists": "Плэйлісты",
|
||||||
|
"ButtonPrevious": "Папярэдні",
|
||||||
|
"ButtonPreviousChapter": "Папярэдні раздзел",
|
||||||
|
"ButtonProbeAudioFile": "Праверыць аўдыяфайл",
|
||||||
|
"ButtonPurgeAllCache": "Ачысціць увесь кэш",
|
||||||
|
"ButtonPurgeItemsCache": "Ачысціць кэш элементаў",
|
||||||
|
"ButtonQueueAddItem": "Дадаць у чаргу",
|
||||||
|
"ButtonQueueRemoveItem": "Выдаліць з чаргі",
|
||||||
|
"ButtonQuickEmbed": "Хуткае ўбудаванне",
|
||||||
|
"ButtonQuickEmbedMetadata": "Хуткае ўбудаванне метаданых",
|
||||||
|
"ButtonQuickMatch": "Хуткі пошук",
|
||||||
|
"ButtonReScan": "Паўторнае сканаванне",
|
||||||
|
"ButtonRead": "Чытаць",
|
||||||
|
"ButtonRefresh": "Абнавіць",
|
||||||
|
"ButtonRemove": "Выдаліць",
|
||||||
|
"ButtonRemoveAll": "Выдаліць усе",
|
||||||
|
"ButtonRemoveAllLibraryItems": "Выдаліць усе элементы бібліятэкі",
|
||||||
|
"ButtonReset": "Скінуць",
|
||||||
|
"ButtonResetToDefault": "Скінуць па змаўчанні",
|
||||||
|
"ButtonRestore": "Аднавіць",
|
||||||
|
"ButtonSave": "Захаваць",
|
||||||
|
"ButtonSaveAndClose": "Захаваць і зачыніць",
|
||||||
|
"ButtonSaveTracklist": "Захаваць спіс трэкаў",
|
||||||
|
"ButtonScan": "Сканаваць",
|
||||||
|
"ButtonScanLibrary": "Сканіраваць бібліятэку",
|
||||||
|
"ButtonScrollLeft": "Пракруціць улева",
|
||||||
|
"ButtonScrollRight": "Пракруціць направа",
|
||||||
|
"ButtonSearch": "Пошук",
|
||||||
|
"ButtonSelectFolderPath": "Выбраць шлях да тэчкі",
|
||||||
|
"ButtonSeries": "Серыі",
|
||||||
|
"ButtonSetChaptersFromTracks": "Усталяваць раздзелы з трэкаў",
|
||||||
|
"ButtonShare": "Падзяліцца",
|
||||||
|
"ButtonStartM4BEncode": "Пачаць кадзіраванне ў M4B",
|
||||||
|
"ButtonStartMetadataEmbed": "Пачаць убудаванне метаданых",
|
||||||
|
"ButtonStats": "Статыстыка",
|
||||||
|
"ButtonSubmit": "Адправіць",
|
||||||
|
"ButtonTest": "Тэст",
|
||||||
|
"ButtonUnlinkOpenId": "Адвязаць OpenID",
|
||||||
|
"ButtonUpload": "Загрузіць",
|
||||||
|
"ButtonUploadBackup": "Загрузіць рэзервовую копію",
|
||||||
|
"ButtonUploadCover": "Загрузіць вокладку",
|
||||||
|
"ButtonUploadOPMLFile": "Загрузіць OPML файл",
|
||||||
|
"ButtonUserDelete": "Выдаліць карыстальніка {0}",
|
||||||
|
"ButtonUserEdit": "Рэдагаваць карыстальніка {0}",
|
||||||
|
"ButtonViewAll": "Прагледзець усе",
|
||||||
|
"ButtonYes": "Так",
|
||||||
|
"HeaderAccount": "Уліковы запіс",
|
||||||
|
"HeaderAddCustomMetadataProvider": "Дадаць карыстальніцкага пастаўшчыка метаданных",
|
||||||
|
"HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise",
|
||||||
|
"HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг",
|
||||||
|
"HeaderAuthentication": "Аўтэнтыфікацыя",
|
||||||
|
"HeaderBackups": "Рэзервовыя копіі",
|
||||||
|
"HeaderChangePassword": "Змяніць пароль",
|
||||||
|
"HeaderChapters": "Раздзелы",
|
||||||
|
"HeaderChooseAFolder": "Выбраць тэчку",
|
||||||
|
"HeaderCollection": "Калекцыя",
|
||||||
|
"HeaderCollectionItems": "Элементы калекцыі",
|
||||||
|
"HeaderCover": "Вокладка",
|
||||||
|
"HeaderCurrentDownloads": "Бягучыя загрузкі",
|
||||||
|
"HeaderCustomMessageOnLogin": "Карыстальніцкае паведамленне пры ўваходзе"
|
||||||
|
}
|
||||||
|
|||||||
@@ -725,7 +725,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
|
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
|
||||||
"ToastBookmarkCreateSuccess": "Отметката е създадена",
|
"ToastBookmarkCreateSuccess": "Отметката е създадена",
|
||||||
"ToastBookmarkRemoveSuccess": "Отметката е премахната",
|
"ToastBookmarkRemoveSuccess": "Отметката е премахната",
|
||||||
"ToastBookmarkUpdateSuccess": "Отметката е обновена",
|
|
||||||
"ToastChaptersHaveErrors": "Главите имат грешки",
|
"ToastChaptersHaveErrors": "Главите имат грешки",
|
||||||
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
||||||
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
||||||
|
|||||||
@@ -952,7 +952,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
|
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
|
||||||
"ToastBookmarkCreateSuccess": "বুকমার্ক যোগ করা হয়েছে",
|
"ToastBookmarkCreateSuccess": "বুকমার্ক যোগ করা হয়েছে",
|
||||||
"ToastBookmarkRemoveSuccess": "বুকমার্ক সরানো হয়েছে",
|
"ToastBookmarkRemoveSuccess": "বুকমার্ক সরানো হয়েছে",
|
||||||
"ToastBookmarkUpdateSuccess": "বুকমার্ক আপডেট করা হয়েছে",
|
|
||||||
"ToastCachePurgeFailed": "ক্যাশে পরিষ্কার করতে ব্যর্থ হয়েছে",
|
"ToastCachePurgeFailed": "ক্যাশে পরিষ্কার করতে ব্যর্থ হয়েছে",
|
||||||
"ToastCachePurgeSuccess": "ক্যাশে সফলভাবে পরিষ্কার করা হয়েছে",
|
"ToastCachePurgeSuccess": "ক্যাশে সফলভাবে পরিষ্কার করা হয়েছে",
|
||||||
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
|
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
|
||||||
|
|||||||
@@ -88,6 +88,8 @@
|
|||||||
"ButtonSaveTracklist": "Desa Pistes",
|
"ButtonSaveTracklist": "Desa Pistes",
|
||||||
"ButtonScan": "Escaneja",
|
"ButtonScan": "Escaneja",
|
||||||
"ButtonScanLibrary": "Escaneja Biblioteca",
|
"ButtonScanLibrary": "Escaneja Biblioteca",
|
||||||
|
"ButtonScrollLeft": "Mou a l'esquerra",
|
||||||
|
"ButtonScrollRight": "Mou a la dreta",
|
||||||
"ButtonSearch": "Cerca",
|
"ButtonSearch": "Cerca",
|
||||||
"ButtonSelectFolderPath": "Selecciona Ruta de Carpeta",
|
"ButtonSelectFolderPath": "Selecciona Ruta de Carpeta",
|
||||||
"ButtonSeries": "Sèries",
|
"ButtonSeries": "Sèries",
|
||||||
@@ -896,7 +898,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Error en crear marcador",
|
"ToastBookmarkCreateFailed": "Error en crear marcador",
|
||||||
"ToastBookmarkCreateSuccess": "Marcador afegit",
|
"ToastBookmarkCreateSuccess": "Marcador afegit",
|
||||||
"ToastBookmarkRemoveSuccess": "Marcador eliminat",
|
"ToastBookmarkRemoveSuccess": "Marcador eliminat",
|
||||||
"ToastBookmarkUpdateSuccess": "Marcador actualitzat",
|
|
||||||
"ToastCachePurgeFailed": "Error en purgar la memòria cau",
|
"ToastCachePurgeFailed": "Error en purgar la memòria cau",
|
||||||
"ToastCachePurgeSuccess": "Memòria cau purgada amb èxit",
|
"ToastCachePurgeSuccess": "Memòria cau purgada amb èxit",
|
||||||
"ToastChaptersHaveErrors": "Els capítols tenen errors",
|
"ToastChaptersHaveErrors": "Els capítols tenen errors",
|
||||||
|
|||||||
+12
-2
@@ -572,7 +572,7 @@
|
|||||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Označit položku médií jako dokončenou, když",
|
"LabelSettingsLibraryMarkAsFinishedWhen": "Označit položku médií jako dokončenou, když",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Přeskočit předchozí knihy v pokračování série",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Přeskočit předchozí knihy v pokračování série",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polička Pokračovat v sérii na domovské stránce zobrazuje první nezačatou knihu v sériích, které mají alespoň jednu knihu dokončenou a žádnou rozečtenou. Povolením tohoto nastavení budou série pokračovat od poslední dokončené knihy namísto první nezačaté knihy.",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polička Pokračovat v sérii na domovské stránce zobrazuje první nezačatou knihu v sériích, které mají alespoň jednu knihu dokončenou a žádnou rozečtenou. Povolením tohoto nastavení budou série pokračovat od poslední dokončené knihy namísto první nezačaté knihy.",
|
||||||
"LabelSettingsParseSubtitles": "Analzyovat podtitul",
|
"LabelSettingsParseSubtitles": "Analyzovat podtitul",
|
||||||
"LabelSettingsParseSubtitlesHelp": "Rozparsovat podtitul z názvů složek audioknih.<br>Podtiul musí být oddělen znakem \" - \"<br>tj. \"Název knihy - Zde Podtitul\" má podtitul \"Zde podtitul\"",
|
"LabelSettingsParseSubtitlesHelp": "Rozparsovat podtitul z názvů složek audioknih.<br>Podtiul musí být oddělen znakem \" - \"<br>tj. \"Název knihy - Zde Podtitul\" má podtitul \"Zde podtitul\"",
|
||||||
"LabelSettingsPreferMatchedMetadata": "Preferovat spárovaná metadata",
|
"LabelSettingsPreferMatchedMetadata": "Preferovat spárovaná metadata",
|
||||||
"LabelSettingsPreferMatchedMetadataHelp": "Spárovaná data budou mít při použití funkce Rychlé párování přednost před údaji o položce. Ve výchozím nastavení funkce Rychlé párování pouze doplní chybějící údaje.",
|
"LabelSettingsPreferMatchedMetadataHelp": "Spárovaná data budou mít při použití funkce Rychlé párování přednost před údaji o položce. Ve výchozím nastavení funkce Rychlé párování pouze doplní chybějící údaje.",
|
||||||
@@ -756,6 +756,7 @@
|
|||||||
"MessageConfirmResetProgress": "Opravdu chcete zahodit svůj pokrok?",
|
"MessageConfirmResetProgress": "Opravdu chcete zahodit svůj pokrok?",
|
||||||
"MessageConfirmSendEbookToDevice": "Opravdu chcete odeslat e-knihu {0} {1}\" do zařízení \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "Opravdu chcete odeslat e-knihu {0} {1}\" do zařízení \"{2}\"?",
|
||||||
"MessageConfirmUnlinkOpenId": "Opravdu chcete odpojit tohoto uživatele z OpenID?",
|
"MessageConfirmUnlinkOpenId": "Opravdu chcete odpojit tohoto uživatele z OpenID?",
|
||||||
|
"MessageDaysListenedInTheLastYear": "{0} poslechových dní v minulém roce",
|
||||||
"MessageDownloadingEpisode": "Stahuji epizodu",
|
"MessageDownloadingEpisode": "Stahuji epizodu",
|
||||||
"MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop",
|
"MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop",
|
||||||
"MessageEmbedFailed": "Vložení selhalo!",
|
"MessageEmbedFailed": "Vložení selhalo!",
|
||||||
@@ -866,6 +867,8 @@
|
|||||||
"MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje",
|
"MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje",
|
||||||
"MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo",
|
"MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo",
|
||||||
"MessageTaskOpmlImportFinished": "Přidáno {0} podcastů",
|
"MessageTaskOpmlImportFinished": "Přidáno {0} podcastů",
|
||||||
|
"MessageTaskOpmlParseFailed": "Selhalo parsování OPML souboru",
|
||||||
|
"MessageTaskOpmlParseFastFail": "Neplatný OPML soubor <opml> tag nenalezen NEBO <outline> tag nenalezen",
|
||||||
"MessageTaskScanItemsAdded": "{0} přidáno",
|
"MessageTaskScanItemsAdded": "{0} přidáno",
|
||||||
"MessageTaskScanItemsMissing": "{0} chybí",
|
"MessageTaskScanItemsMissing": "{0} chybí",
|
||||||
"MessageTaskScanItemsUpdated": "{0} aktualizováno",
|
"MessageTaskScanItemsUpdated": "{0} aktualizováno",
|
||||||
@@ -890,6 +893,10 @@
|
|||||||
"NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.",
|
"NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.",
|
||||||
"NoteUploaderOnlyAudioFiles": "Pokud nahráváte pouze zvukové soubory, bude s každým zvukovým souborem zacházeno jako se samostatnou audioknihou.",
|
"NoteUploaderOnlyAudioFiles": "Pokud nahráváte pouze zvukové soubory, bude s každým zvukovým souborem zacházeno jako se samostatnou audioknihou.",
|
||||||
"NoteUploaderUnsupportedFiles": "Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce položek, ignorovány.",
|
"NoteUploaderUnsupportedFiles": "Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce položek, ignorovány.",
|
||||||
|
"NotificationOnBackupCompletedDescription": "Spuštěno po dokončení zálohování",
|
||||||
|
"NotificationOnBackupFailedDescription": "Spuštěno pokud zálohování selže",
|
||||||
|
"NotificationOnEpisodeDownloadedDescription": "Spuštěno při automatickém stažení epizody podcastu",
|
||||||
|
"NotificationOnTestDescription": "Akce pro otestování upozorňovacího systému",
|
||||||
"PlaceholderNewCollection": "Nový název kolekce",
|
"PlaceholderNewCollection": "Nový název kolekce",
|
||||||
"PlaceholderNewFolderPath": "Nová cesta ke složce",
|
"PlaceholderNewFolderPath": "Nová cesta ke složce",
|
||||||
"PlaceholderNewPlaylist": "Nový název seznamu přehrávání",
|
"PlaceholderNewPlaylist": "Nový název seznamu přehrávání",
|
||||||
@@ -900,6 +907,7 @@
|
|||||||
"StatsBooksAdditional": "Některé další zahrnují…",
|
"StatsBooksAdditional": "Některé další zahrnují…",
|
||||||
"StatsBooksFinished": "dokončené knihy",
|
"StatsBooksFinished": "dokončené knihy",
|
||||||
"StatsBooksFinishedThisYear": "Některé knihy dokončené tento rok…",
|
"StatsBooksFinishedThisYear": "Některé knihy dokončené tento rok…",
|
||||||
|
"StatsBooksListenedTo": "knih poslechnuto",
|
||||||
"StatsCollectionGrewTo": "Vaše kolekce knih se rozrostla na…",
|
"StatsCollectionGrewTo": "Vaše kolekce knih se rozrostla na…",
|
||||||
"StatsSessions": "sezení",
|
"StatsSessions": "sezení",
|
||||||
"StatsSpentListening": "stráveno posloucháním",
|
"StatsSpentListening": "stráveno posloucháním",
|
||||||
@@ -908,10 +916,13 @@
|
|||||||
"StatsTopGenre": "TOP ŽÁNR",
|
"StatsTopGenre": "TOP ŽÁNR",
|
||||||
"StatsTopGenres": "TOP ŽÁNRY",
|
"StatsTopGenres": "TOP ŽÁNRY",
|
||||||
"StatsTopMonth": "TOP MĚSÍC",
|
"StatsTopMonth": "TOP MĚSÍC",
|
||||||
|
"StatsTopNarrator": "NEJLEPŠÍ VYPRAVĚČ",
|
||||||
|
"StatsTopNarrators": "NEJLEPŠÍ VYPRAVĚČI",
|
||||||
"StatsTotalDuration": "S celkovou dobou…",
|
"StatsTotalDuration": "S celkovou dobou…",
|
||||||
"StatsYearInReview": "ROK V PŘEHLEDU",
|
"StatsYearInReview": "ROK V PŘEHLEDU",
|
||||||
"ToastAccountUpdateSuccess": "Účet aktualizován",
|
"ToastAccountUpdateSuccess": "Účet aktualizován",
|
||||||
"ToastAppriseUrlRequired": "Je nutné zadat Apprise URL",
|
"ToastAppriseUrlRequired": "Je nutné zadat Apprise URL",
|
||||||
|
"ToastAsinRequired": "ASIN vyžadován",
|
||||||
"ToastAuthorImageRemoveSuccess": "Obrázek autora odstraněn",
|
"ToastAuthorImageRemoveSuccess": "Obrázek autora odstraněn",
|
||||||
"ToastAuthorNotFound": "Author \"{0}\" nenalezen",
|
"ToastAuthorNotFound": "Author \"{0}\" nenalezen",
|
||||||
"ToastAuthorRemoveSuccess": "Autor odstraněn",
|
"ToastAuthorRemoveSuccess": "Autor odstraněn",
|
||||||
@@ -936,7 +947,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo",
|
"ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo",
|
||||||
"ToastBookmarkCreateSuccess": "Přidána záložka",
|
"ToastBookmarkCreateSuccess": "Přidána záložka",
|
||||||
"ToastBookmarkRemoveSuccess": "Záložka odstraněna",
|
"ToastBookmarkRemoveSuccess": "Záložka odstraněna",
|
||||||
"ToastBookmarkUpdateSuccess": "Záložka aktualizována",
|
|
||||||
"ToastCachePurgeFailed": "Nepodařilo se vyčistit mezipaměť",
|
"ToastCachePurgeFailed": "Nepodařilo se vyčistit mezipaměť",
|
||||||
"ToastCachePurgeSuccess": "Vyrovnávací paměť úspěšně vyčištěna",
|
"ToastCachePurgeSuccess": "Vyrovnávací paměť úspěšně vyčištěna",
|
||||||
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
|
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
|
||||||
|
|||||||
@@ -636,7 +636,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Mislykkedes oprettelse af bogmærke",
|
"ToastBookmarkCreateFailed": "Mislykkedes oprettelse af bogmærke",
|
||||||
"ToastBookmarkCreateSuccess": "Bogmærke tilføjet",
|
"ToastBookmarkCreateSuccess": "Bogmærke tilføjet",
|
||||||
"ToastBookmarkRemoveSuccess": "Bogmærke fjernet",
|
"ToastBookmarkRemoveSuccess": "Bogmærke fjernet",
|
||||||
"ToastBookmarkUpdateSuccess": "Bogmærke opdateret",
|
|
||||||
"ToastChaptersHaveErrors": "Kapitler har fejl",
|
"ToastChaptersHaveErrors": "Kapitler har fejl",
|
||||||
"ToastChaptersMustHaveTitles": "Kapitler skal have titler",
|
"ToastChaptersMustHaveTitles": "Kapitler skal have titler",
|
||||||
"ToastCollectionRemoveSuccess": "Samling fjernet",
|
"ToastCollectionRemoveSuccess": "Samling fjernet",
|
||||||
|
|||||||
@@ -300,6 +300,7 @@
|
|||||||
"LabelDiscover": "Entdecken",
|
"LabelDiscover": "Entdecken",
|
||||||
"LabelDownload": "Herunterladen",
|
"LabelDownload": "Herunterladen",
|
||||||
"LabelDownloadNEpisodes": "Download {0} Episoden",
|
"LabelDownloadNEpisodes": "Download {0} Episoden",
|
||||||
|
"LabelDownloadable": "Herunterladbar",
|
||||||
"LabelDuration": "Laufzeit",
|
"LabelDuration": "Laufzeit",
|
||||||
"LabelDurationComparisonExactMatch": "(genauer Treffer)",
|
"LabelDurationComparisonExactMatch": "(genauer Treffer)",
|
||||||
"LabelDurationComparisonLonger": "({0} länger)",
|
"LabelDurationComparisonLonger": "({0} länger)",
|
||||||
@@ -588,6 +589,7 @@
|
|||||||
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
|
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
|
||||||
"LabelSettingsTimeFormat": "Zeitformat",
|
"LabelSettingsTimeFormat": "Zeitformat",
|
||||||
"LabelShare": "Freigeben",
|
"LabelShare": "Freigeben",
|
||||||
|
"LabelShareDownloadableHelp": "Erlaubt es einem Nutzer, mit dem Link, die Dateien des Mediums als ZIP herunterzuladen.",
|
||||||
"LabelShareOpen": "Freigeben",
|
"LabelShareOpen": "Freigeben",
|
||||||
"LabelShareURL": "Freigabe URL",
|
"LabelShareURL": "Freigabe URL",
|
||||||
"LabelShowAll": "Alles anzeigen",
|
"LabelShowAll": "Alles anzeigen",
|
||||||
@@ -756,6 +758,7 @@
|
|||||||
"MessageConfirmResetProgress": "Möchtest du Ihren Fortschritt wirklich zurücksetzen?",
|
"MessageConfirmResetProgress": "Möchtest du Ihren Fortschritt wirklich zurücksetzen?",
|
||||||
"MessageConfirmSendEbookToDevice": "{0} E-Buch „{1}“ wird auf das Gerät „{2}“ gesendet! Bist du dir sicher?",
|
"MessageConfirmSendEbookToDevice": "{0} E-Buch „{1}“ wird auf das Gerät „{2}“ gesendet! Bist du dir sicher?",
|
||||||
"MessageConfirmUnlinkOpenId": "Möchtest du die Verknüpfung dieses Benutzers mit OpenID wirklich löschen?",
|
"MessageConfirmUnlinkOpenId": "Möchtest du die Verknüpfung dieses Benutzers mit OpenID wirklich löschen?",
|
||||||
|
"MessageDaysListenedInTheLastYear": "{0} Tage in dem letzten Jahr gehört",
|
||||||
"MessageDownloadingEpisode": "Episode wird heruntergeladen",
|
"MessageDownloadingEpisode": "Episode wird heruntergeladen",
|
||||||
"MessageDragFilesIntoTrackOrder": "Verschiebe die Dateien in die richtige Reihenfolge",
|
"MessageDragFilesIntoTrackOrder": "Verschiebe die Dateien in die richtige Reihenfolge",
|
||||||
"MessageEmbedFailed": "Einbetten fehlgeschlagen!",
|
"MessageEmbedFailed": "Einbetten fehlgeschlagen!",
|
||||||
@@ -950,7 +953,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
|
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
|
||||||
"ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt",
|
"ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt",
|
||||||
"ToastBookmarkRemoveSuccess": "Lesezeichen entfernt",
|
"ToastBookmarkRemoveSuccess": "Lesezeichen entfernt",
|
||||||
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
|
|
||||||
"ToastCachePurgeFailed": "Cache leeren fehlgeschlagen",
|
"ToastCachePurgeFailed": "Cache leeren fehlgeschlagen",
|
||||||
"ToastCachePurgeSuccess": "Cache geleert",
|
"ToastCachePurgeSuccess": "Cache geleert",
|
||||||
"ToastChaptersHaveErrors": "Kapitel sind fehlerhaft",
|
"ToastChaptersHaveErrors": "Kapitel sind fehlerhaft",
|
||||||
@@ -961,6 +963,7 @@
|
|||||||
"ToastCollectionRemoveSuccess": "Sammlung entfernt",
|
"ToastCollectionRemoveSuccess": "Sammlung entfernt",
|
||||||
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
|
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
|
||||||
"ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen",
|
"ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen",
|
||||||
|
"ToastDateTimeInvalidOrIncomplete": "Datum und Zeit ist ungültig oder unvollständig",
|
||||||
"ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden",
|
"ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden",
|
||||||
"ToastDeleteFileSuccess": "Datei gelöscht",
|
"ToastDeleteFileSuccess": "Datei gelöscht",
|
||||||
"ToastDeviceAddFailed": "Gerät konnte nicht hinzugefügt werden",
|
"ToastDeviceAddFailed": "Gerät konnte nicht hinzugefügt werden",
|
||||||
|
|||||||
@@ -837,6 +837,7 @@
|
|||||||
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
|
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
|
||||||
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
||||||
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
||||||
|
"MessageScheduleLibraryScanNote": "For most users, it is recommended to leave this feature disabled and keep the folder watcher setting enabled. The folder watcher will automatically detect changes in your library folders. The folder watcher doesn't work for every file system (like NFS) so scheduled library scans can be used instead.",
|
||||||
"MessageSearchResultsFor": "Search results for",
|
"MessageSearchResultsFor": "Search results for",
|
||||||
"MessageSelected": "{0} selected",
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Server could not be reached",
|
"MessageServerCouldNotBeReached": "Server could not be reached",
|
||||||
@@ -953,7 +954,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Failed to create bookmark",
|
"ToastBookmarkCreateFailed": "Failed to create bookmark",
|
||||||
"ToastBookmarkCreateSuccess": "Bookmark added",
|
"ToastBookmarkCreateSuccess": "Bookmark added",
|
||||||
"ToastBookmarkRemoveSuccess": "Bookmark removed",
|
"ToastBookmarkRemoveSuccess": "Bookmark removed",
|
||||||
"ToastBookmarkUpdateSuccess": "Bookmark updated",
|
|
||||||
"ToastCachePurgeFailed": "Failed to purge cache",
|
"ToastCachePurgeFailed": "Failed to purge cache",
|
||||||
"ToastCachePurgeSuccess": "Cache purged successfully",
|
"ToastCachePurgeSuccess": "Cache purged successfully",
|
||||||
"ToastChaptersHaveErrors": "Chapters have errors",
|
"ToastChaptersHaveErrors": "Chapters have errors",
|
||||||
@@ -964,6 +964,7 @@
|
|||||||
"ToastCollectionRemoveSuccess": "Collection removed",
|
"ToastCollectionRemoveSuccess": "Collection removed",
|
||||||
"ToastCollectionUpdateSuccess": "Collection updated",
|
"ToastCollectionUpdateSuccess": "Collection updated",
|
||||||
"ToastCoverUpdateFailed": "Cover update failed",
|
"ToastCoverUpdateFailed": "Cover update failed",
|
||||||
|
"ToastDateTimeInvalidOrIncomplete": "Date and time is invalid or incomplete",
|
||||||
"ToastDeleteFileFailed": "Failed to delete file",
|
"ToastDeleteFileFailed": "Failed to delete file",
|
||||||
"ToastDeleteFileSuccess": "File deleted",
|
"ToastDeleteFileSuccess": "File deleted",
|
||||||
"ToastDeviceAddFailed": "Failed to add device",
|
"ToastDeviceAddFailed": "Failed to add device",
|
||||||
@@ -1016,6 +1017,7 @@
|
|||||||
"ToastNewUserTagError": "Must select at least one tag",
|
"ToastNewUserTagError": "Must select at least one tag",
|
||||||
"ToastNewUserUsernameError": "Enter a username",
|
"ToastNewUserUsernameError": "Enter a username",
|
||||||
"ToastNoNewEpisodesFound": "No new episodes found",
|
"ToastNoNewEpisodesFound": "No new episodes found",
|
||||||
|
"ToastNoRSSFeed": "Podcast does not have an RSS Feed",
|
||||||
"ToastNoUpdatesNecessary": "No updates necessary",
|
"ToastNoUpdatesNecessary": "No updates necessary",
|
||||||
"ToastNotificationCreateFailed": "Failed to create notification",
|
"ToastNotificationCreateFailed": "Failed to create notification",
|
||||||
"ToastNotificationDeleteFailed": "Failed to delete notification",
|
"ToastNotificationDeleteFailed": "Failed to delete notification",
|
||||||
|
|||||||
@@ -300,6 +300,7 @@
|
|||||||
"LabelDiscover": "Descubrir",
|
"LabelDiscover": "Descubrir",
|
||||||
"LabelDownload": "Descargar",
|
"LabelDownload": "Descargar",
|
||||||
"LabelDownloadNEpisodes": "Descargar {0} episodios",
|
"LabelDownloadNEpisodes": "Descargar {0} episodios",
|
||||||
|
"LabelDownloadable": "Descarregable",
|
||||||
"LabelDuration": "Duración",
|
"LabelDuration": "Duración",
|
||||||
"LabelDurationComparisonExactMatch": "(coincidencia exacta)",
|
"LabelDurationComparisonExactMatch": "(coincidencia exacta)",
|
||||||
"LabelDurationComparisonLonger": "({0} más largo)",
|
"LabelDurationComparisonLonger": "({0} más largo)",
|
||||||
@@ -588,6 +589,7 @@
|
|||||||
"LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca",
|
"LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca",
|
||||||
"LabelSettingsTimeFormat": "Formato de Tiempo",
|
"LabelSettingsTimeFormat": "Formato de Tiempo",
|
||||||
"LabelShare": "Compartir",
|
"LabelShare": "Compartir",
|
||||||
|
"LabelShareDownloadableHelp": "Permet als usuaris amb l'enllaç compartit descarregar un arxiu zip amb l'item de la llibreria.",
|
||||||
"LabelShareOpen": "abrir un recurso compartido",
|
"LabelShareOpen": "abrir un recurso compartido",
|
||||||
"LabelShareURL": "Compartir la URL",
|
"LabelShareURL": "Compartir la URL",
|
||||||
"LabelShowAll": "Mostrar Todos",
|
"LabelShowAll": "Mostrar Todos",
|
||||||
@@ -756,6 +758,7 @@
|
|||||||
"MessageConfirmResetProgress": "¿Estás seguro de que quieres reiniciar tu progreso?",
|
"MessageConfirmResetProgress": "¿Estás seguro de que quieres reiniciar tu progreso?",
|
||||||
"MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?",
|
||||||
"MessageConfirmUnlinkOpenId": "¿Estás seguro de que deseas desvincular este usuario de OpenID?",
|
"MessageConfirmUnlinkOpenId": "¿Estás seguro de que deseas desvincular este usuario de OpenID?",
|
||||||
|
"MessageDaysListenedInTheLastYear": "{0} dies escoltats en l'últim any",
|
||||||
"MessageDownloadingEpisode": "Descargando Capitulo",
|
"MessageDownloadingEpisode": "Descargando Capitulo",
|
||||||
"MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas",
|
"MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas",
|
||||||
"MessageEmbedFailed": "¡Error al insertar!",
|
"MessageEmbedFailed": "¡Error al insertar!",
|
||||||
@@ -950,7 +953,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Error al crear marcador",
|
"ToastBookmarkCreateFailed": "Error al crear marcador",
|
||||||
"ToastBookmarkCreateSuccess": "Marcador Agregado",
|
"ToastBookmarkCreateSuccess": "Marcador Agregado",
|
||||||
"ToastBookmarkRemoveSuccess": "Marcador eliminado",
|
"ToastBookmarkRemoveSuccess": "Marcador eliminado",
|
||||||
"ToastBookmarkUpdateSuccess": "Marcador actualizado",
|
|
||||||
"ToastCachePurgeFailed": "Error al purgar el caché",
|
"ToastCachePurgeFailed": "Error al purgar el caché",
|
||||||
"ToastCachePurgeSuccess": "Caché purgado de manera exitosa",
|
"ToastCachePurgeSuccess": "Caché purgado de manera exitosa",
|
||||||
"ToastChaptersHaveErrors": "Los capítulos tienen errores",
|
"ToastChaptersHaveErrors": "Los capítulos tienen errores",
|
||||||
@@ -997,7 +999,7 @@
|
|||||||
"ToastLibraryScanFailedToStart": "Error al iniciar el escaneo",
|
"ToastLibraryScanFailedToStart": "Error al iniciar el escaneo",
|
||||||
"ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca",
|
"ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca",
|
||||||
"ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada",
|
"ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada",
|
||||||
"ToastMatchAllAuthorsFailed": "No coincide con todos los autores",
|
"ToastMatchAllAuthorsFailed": "No se pudo encontrar a todos los autores",
|
||||||
"ToastMetadataFilesRemovedError": "Error al eliminar metadatos de {0} archivo(s)",
|
"ToastMetadataFilesRemovedError": "Error al eliminar metadatos de {0} archivo(s)",
|
||||||
"ToastMetadataFilesRemovedNoneFound": "No hay metadatos.{0} archivo(s) encontrado(s) en la biblioteca",
|
"ToastMetadataFilesRemovedNoneFound": "No hay metadatos.{0} archivo(s) encontrado(s) en la biblioteca",
|
||||||
"ToastMetadataFilesRemovedNoneRemoved": "Sin metadatos.{0} archivo(s) eliminado(s)",
|
"ToastMetadataFilesRemovedNoneRemoved": "Sin metadatos.{0} archivo(s) eliminado(s)",
|
||||||
|
|||||||
@@ -709,7 +709,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Järjehoidja loomine ebaõnnestus",
|
"ToastBookmarkCreateFailed": "Järjehoidja loomine ebaõnnestus",
|
||||||
"ToastBookmarkCreateSuccess": "Järjehoidja lisatud",
|
"ToastBookmarkCreateSuccess": "Järjehoidja lisatud",
|
||||||
"ToastBookmarkRemoveSuccess": "Järjehoidja eemaldatud",
|
"ToastBookmarkRemoveSuccess": "Järjehoidja eemaldatud",
|
||||||
"ToastBookmarkUpdateSuccess": "Järjehoidja värskendatud",
|
|
||||||
"ToastChaptersHaveErrors": "Peatükkidel on vigu",
|
"ToastChaptersHaveErrors": "Peatükkidel on vigu",
|
||||||
"ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri",
|
"ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri",
|
||||||
"ToastCollectionRemoveSuccess": "Kogum eemaldatud",
|
"ToastCollectionRemoveSuccess": "Kogum eemaldatud",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"ButtonPurgeItemsCache": "Tyhjennä kohteiden välimuisti",
|
"ButtonPurgeItemsCache": "Tyhjennä kohteiden välimuisti",
|
||||||
"ButtonQueueAddItem": "Lisää jonoon",
|
"ButtonQueueAddItem": "Lisää jonoon",
|
||||||
"ButtonQueueRemoveItem": "Poista jonosta",
|
"ButtonQueueRemoveItem": "Poista jonosta",
|
||||||
|
"ButtonQuickEmbed": "Pikaupota",
|
||||||
"ButtonQuickMatch": "Pikatäsmää",
|
"ButtonQuickMatch": "Pikatäsmää",
|
||||||
"ButtonReScan": "Uudelleenskannaa",
|
"ButtonReScan": "Uudelleenskannaa",
|
||||||
"ButtonRead": "Lue",
|
"ButtonRead": "Lue",
|
||||||
@@ -85,6 +86,8 @@
|
|||||||
"ButtonSaveTracklist": "Tallenna raitalista",
|
"ButtonSaveTracklist": "Tallenna raitalista",
|
||||||
"ButtonScan": "Skannaa",
|
"ButtonScan": "Skannaa",
|
||||||
"ButtonScanLibrary": "Skannaa kirjasto",
|
"ButtonScanLibrary": "Skannaa kirjasto",
|
||||||
|
"ButtonScrollLeft": "Vieritä vasemmalle",
|
||||||
|
"ButtonScrollRight": "Vieritä oikealle",
|
||||||
"ButtonSearch": "Etsi",
|
"ButtonSearch": "Etsi",
|
||||||
"ButtonSelectFolderPath": "Valitse kansiopolku",
|
"ButtonSelectFolderPath": "Valitse kansiopolku",
|
||||||
"ButtonSeries": "Sarjat",
|
"ButtonSeries": "Sarjat",
|
||||||
@@ -148,6 +151,7 @@
|
|||||||
"HeaderLogs": "Lokit",
|
"HeaderLogs": "Lokit",
|
||||||
"HeaderManageGenres": "Hallitse lajityyppejä",
|
"HeaderManageGenres": "Hallitse lajityyppejä",
|
||||||
"HeaderManageTags": "Hallitse tageja",
|
"HeaderManageTags": "Hallitse tageja",
|
||||||
|
"HeaderMetadataOrderOfPrecedence": "Metadatan tärkeysjärjestys",
|
||||||
"HeaderMetadataToEmbed": "Sisällytettävä metadata",
|
"HeaderMetadataToEmbed": "Sisällytettävä metadata",
|
||||||
"HeaderNewAccount": "Uusi tili",
|
"HeaderNewAccount": "Uusi tili",
|
||||||
"HeaderNewLibrary": "Uusi kirjasto",
|
"HeaderNewLibrary": "Uusi kirjasto",
|
||||||
@@ -156,6 +160,7 @@
|
|||||||
"HeaderNotifications": "Ilmoitukset",
|
"HeaderNotifications": "Ilmoitukset",
|
||||||
"HeaderOpenRSSFeed": "Avaa RSS-syöte",
|
"HeaderOpenRSSFeed": "Avaa RSS-syöte",
|
||||||
"HeaderOtherFiles": "Muut tiedostot",
|
"HeaderOtherFiles": "Muut tiedostot",
|
||||||
|
"HeaderPasswordAuthentication": "Salasanan todentaminen",
|
||||||
"HeaderPermissions": "Käyttöoikeudet",
|
"HeaderPermissions": "Käyttöoikeudet",
|
||||||
"HeaderPlayerQueue": "Soittimen jono",
|
"HeaderPlayerQueue": "Soittimen jono",
|
||||||
"HeaderPlayerSettings": "Soittimen asetukset",
|
"HeaderPlayerSettings": "Soittimen asetukset",
|
||||||
@@ -169,22 +174,28 @@
|
|||||||
"HeaderRemoveEpisode": "Poista jakso",
|
"HeaderRemoveEpisode": "Poista jakso",
|
||||||
"HeaderRemoveEpisodes": "Poista {0} jaksoa",
|
"HeaderRemoveEpisodes": "Poista {0} jaksoa",
|
||||||
"HeaderSchedule": "Ajoita",
|
"HeaderSchedule": "Ajoita",
|
||||||
|
"HeaderScheduleEpisodeDownloads": "Ajoita automaattiset jaksolataukset",
|
||||||
"HeaderScheduleLibraryScans": "Ajoita automaattiset kirjastoskannaukset",
|
"HeaderScheduleLibraryScans": "Ajoita automaattiset kirjastoskannaukset",
|
||||||
"HeaderSession": "Istunto",
|
"HeaderSession": "Istunto",
|
||||||
"HeaderSetBackupSchedule": "Aseta varmuuskopiointiaikataulu",
|
"HeaderSetBackupSchedule": "Aseta varmuuskopiointiaikataulu",
|
||||||
"HeaderSettings": "Asetukset",
|
"HeaderSettings": "Asetukset",
|
||||||
|
"HeaderSettingsDisplay": "Näyttö",
|
||||||
"HeaderSettingsExperimental": "Kokeelliset ominaisuudet",
|
"HeaderSettingsExperimental": "Kokeelliset ominaisuudet",
|
||||||
"HeaderSettingsGeneral": "Yleiset",
|
"HeaderSettingsGeneral": "Yleiset",
|
||||||
|
"HeaderSettingsScanner": "Skannaaja",
|
||||||
"HeaderSleepTimer": "Uniajastin",
|
"HeaderSleepTimer": "Uniajastin",
|
||||||
"HeaderStatsMinutesListeningChart": "Kuunteluminuutit (viim. 7 pv)",
|
"HeaderStatsMinutesListeningChart": "Kuunteluminuutit (viim. 7 pv)",
|
||||||
"HeaderStatsRecentSessions": "Viimeaikaiset istunnot",
|
"HeaderStatsRecentSessions": "Viimeaikaiset istunnot",
|
||||||
|
"HeaderStatsTop10Authors": "Top 10 kirjailijat",
|
||||||
"HeaderStatsTop5Genres": "Top 5 lajityypit",
|
"HeaderStatsTop5Genres": "Top 5 lajityypit",
|
||||||
"HeaderTableOfContents": "Sisällysluettelo",
|
"HeaderTableOfContents": "Sisällysluettelo",
|
||||||
"HeaderTools": "Työkalut",
|
"HeaderTools": "Työkalut",
|
||||||
"HeaderUpdateAccount": "Päivitä tili",
|
"HeaderUpdateAccount": "Päivitä tili",
|
||||||
"HeaderUpdateAuthor": "Päivitä kirjailija",
|
"HeaderUpdateAuthor": "Päivitä kirjailija",
|
||||||
|
"HeaderUpdateDetails": "Päivitä yksityiskohdat",
|
||||||
"HeaderUpdateLibrary": "Päivitä kirjasto",
|
"HeaderUpdateLibrary": "Päivitä kirjasto",
|
||||||
"HeaderUsers": "Käyttäjät",
|
"HeaderUsers": "Käyttäjät",
|
||||||
|
"HeaderYearReview": "Vuosi {0} tarkasteltuna",
|
||||||
"HeaderYourStats": "Tilastosi",
|
"HeaderYourStats": "Tilastosi",
|
||||||
"LabelAbridged": "Lyhennetty",
|
"LabelAbridged": "Lyhennetty",
|
||||||
"LabelAccountType": "Tilin tyyppi",
|
"LabelAccountType": "Tilin tyyppi",
|
||||||
@@ -204,11 +215,15 @@
|
|||||||
"LabelAllUsersExcludingGuests": "Kaikki käyttäjät vieraita lukuun ottamatta",
|
"LabelAllUsersExcludingGuests": "Kaikki käyttäjät vieraita lukuun ottamatta",
|
||||||
"LabelAllUsersIncludingGuests": "Kaikki käyttäjät mukaan lukien vieraat",
|
"LabelAllUsersIncludingGuests": "Kaikki käyttäjät mukaan lukien vieraat",
|
||||||
"LabelAlreadyInYourLibrary": "Jo kirjastossasi",
|
"LabelAlreadyInYourLibrary": "Jo kirjastossasi",
|
||||||
|
"LabelAudioBitrate": "Äänen bittinopeus (esim. 128k)",
|
||||||
|
"LabelAudioChannels": "Äänikanavat (1 tai 2)",
|
||||||
|
"LabelAudioCodec": "Äänikoodekki",
|
||||||
"LabelAuthor": "Tekijä",
|
"LabelAuthor": "Tekijä",
|
||||||
"LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)",
|
"LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)",
|
||||||
"LabelAuthorLastFirst": "Tekijä (Sukunimi, Etunimi)",
|
"LabelAuthorLastFirst": "Tekijä (Sukunimi, Etunimi)",
|
||||||
"LabelAuthors": "Tekijät",
|
"LabelAuthors": "Tekijät",
|
||||||
"LabelAutoDownloadEpisodes": "Lataa jaksot automaattisesti",
|
"LabelAutoDownloadEpisodes": "Lataa jaksot automaattisesti",
|
||||||
|
"LabelAutoFetchMetadata": "Etsi metadata automaattisesti",
|
||||||
"LabelBackToUser": "Takaisin käyttäjään",
|
"LabelBackToUser": "Takaisin käyttäjään",
|
||||||
"LabelBackupLocation": "Varmuuskopiointipaikka",
|
"LabelBackupLocation": "Varmuuskopiointipaikka",
|
||||||
"LabelBackupsEnableAutomaticBackups": "Ota automaattinen varmuuskopiointi käyttöön",
|
"LabelBackupsEnableAutomaticBackups": "Ota automaattinen varmuuskopiointi käyttöön",
|
||||||
@@ -238,24 +253,41 @@
|
|||||||
"LabelCurrent": "Nykyinen",
|
"LabelCurrent": "Nykyinen",
|
||||||
"LabelDays": "Päivää",
|
"LabelDays": "Päivää",
|
||||||
"LabelDescription": "Kuvaus",
|
"LabelDescription": "Kuvaus",
|
||||||
|
"LabelDeselectAll": "Poista valinta kaikista",
|
||||||
"LabelDevice": "Laite",
|
"LabelDevice": "Laite",
|
||||||
"LabelDeviceInfo": "Laitteen tiedot",
|
"LabelDeviceInfo": "Laitteen tiedot",
|
||||||
|
"LabelDeviceIsAvailableTo": "Laite on saatavilla...",
|
||||||
|
"LabelDirectory": "Kansio",
|
||||||
"LabelDiscover": "Löydä",
|
"LabelDiscover": "Löydä",
|
||||||
"LabelDownload": "Lataa",
|
"LabelDownload": "Lataa",
|
||||||
"LabelDownloadNEpisodes": "Lataa {0} jaksoa",
|
"LabelDownloadNEpisodes": "Lataa {0} jaksoa",
|
||||||
|
"LabelDownloadable": "Ladattavissa",
|
||||||
"LabelDuration": "Kesto",
|
"LabelDuration": "Kesto",
|
||||||
"LabelDurationComparisonLonger": "({0} pidempi)",
|
"LabelDurationComparisonLonger": "({0} pidempi)",
|
||||||
"LabelDurationComparisonShorter": "({0} lyhyempi)",
|
"LabelDurationComparisonShorter": "({0} lyhyempi)",
|
||||||
|
"LabelDurationFound": "Kesto löydetty:",
|
||||||
"LabelEbook": "E-kirja",
|
"LabelEbook": "E-kirja",
|
||||||
"LabelEbooks": "E-kirjat",
|
"LabelEbooks": "E-kirjat",
|
||||||
"LabelEdit": "Muokkaa",
|
"LabelEdit": "Muokkaa",
|
||||||
"LabelEmail": "Sähköposti",
|
"LabelEmail": "Sähköposti",
|
||||||
|
"LabelEmailSettingsFromAddress": "Osoitteesta",
|
||||||
|
"LabelEmailSettingsRejectUnauthorizedHelp": "SSL-sertifikaatin varmentamisen käytöstä poistaminen saattaa vaarantaa yhteytesti turvallisuusriskeihin, kuten man-in-the-middle hyökkäyksiin. Poista käytöstä vain jos ymmärrät vaaran ja luotat yhdistämääsi sähköpostipalvelimeen.",
|
||||||
|
"LabelEmailSettingsSecure": "Turvallinen",
|
||||||
"LabelEmailSettingsTestAddress": "Testiosoite",
|
"LabelEmailSettingsTestAddress": "Testiosoite",
|
||||||
"LabelEmbeddedCover": "Upotettu kansikuva",
|
"LabelEmbeddedCover": "Upotettu kansikuva",
|
||||||
"LabelEnable": "Ota käyttöön",
|
"LabelEnable": "Ota käyttöön",
|
||||||
|
"LabelEncodingBackupLocation": "Alkuperäisistä audiotiedostoistasi tallennetaan varmuuskopio osoitteessa:",
|
||||||
|
"LabelEncodingStartedNavigation": "Voit poistua sivulta kun tehtävä on aloitettu.",
|
||||||
|
"LabelEncodingTimeWarning": "Koodaus saattaa kestää 30 minuuttiin asti.",
|
||||||
|
"LabelEncodingWarningAdvancedSettings": "Varoitus: Älä päivitä näitä asetuksia ellet ymmärrä ffmpeg-koodausasetuksia.",
|
||||||
"LabelEnd": "Loppu",
|
"LabelEnd": "Loppu",
|
||||||
"LabelEndOfChapter": "Luvun loppu",
|
"LabelEndOfChapter": "Luvun loppu",
|
||||||
"LabelEpisode": "Jakso",
|
"LabelEpisode": "Jakso",
|
||||||
|
"LabelEpisodeNotLinkedToRssFeed": "Jakso ei yhdistetty RSS-syötteeseen",
|
||||||
|
"LabelEpisodeNumber": "Jakso #{0}",
|
||||||
|
"LabelEpisodeTitle": "Jakson nimi",
|
||||||
|
"LabelEpisodeType": "Jakson tyyppi",
|
||||||
|
"LabelEpisodeUrlFromRssFeed": "Jakson URL RSS-syötteestä",
|
||||||
"LabelEpisodes": "Jaksot",
|
"LabelEpisodes": "Jaksot",
|
||||||
"LabelExample": "Esimerkki",
|
"LabelExample": "Esimerkki",
|
||||||
"LabelFeedURL": "Syötteen URL",
|
"LabelFeedURL": "Syötteen URL",
|
||||||
@@ -287,12 +319,20 @@
|
|||||||
"LabelLanguageDefaultServer": "Palvelimen oletuskieli",
|
"LabelLanguageDefaultServer": "Palvelimen oletuskieli",
|
||||||
"LabelLanguages": "Kielet",
|
"LabelLanguages": "Kielet",
|
||||||
"LabelLastBookAdded": "Viimeisin lisätty kirja",
|
"LabelLastBookAdded": "Viimeisin lisätty kirja",
|
||||||
|
"LabelLastBookUpdated": "Viimeisin päivitetty kirja",
|
||||||
|
"LabelLastUpdate": "Viimeisin päivitys",
|
||||||
|
"LabelLibrariesAccessibleToUser": "Käyttäjälle saatavilla olevat kirjastot",
|
||||||
"LabelLibrary": "Kirjasto",
|
"LabelLibrary": "Kirjasto",
|
||||||
|
"LabelLibraryName": "Kirjaston nimi",
|
||||||
"LabelLineSpacing": "Riviväli",
|
"LabelLineSpacing": "Riviväli",
|
||||||
"LabelListenAgain": "Kuuntele uudelleen",
|
"LabelListenAgain": "Kuuntele uudelleen",
|
||||||
|
"LabelLookForNewEpisodesAfterDate": "Etsi uusia jaksoja tämän päivämäärän jälkeen",
|
||||||
|
"LabelMaxEpisodesToDownload": "Jaksojen maksimilatausmäärä. 0 poistaa rajoituksen.",
|
||||||
|
"LabelMediaPlayer": "Mediasoitin",
|
||||||
"LabelMediaType": "Mediatyyppi",
|
"LabelMediaType": "Mediatyyppi",
|
||||||
"LabelMinute": "Minuutti",
|
"LabelMinute": "Minuutti",
|
||||||
"LabelMinutes": "Minuutit",
|
"LabelMinutes": "Minuutit",
|
||||||
|
"LabelMissingEbook": "Ei e-kirjaa",
|
||||||
"LabelMore": "Lisää",
|
"LabelMore": "Lisää",
|
||||||
"LabelMoreInfo": "Lisätietoja",
|
"LabelMoreInfo": "Lisätietoja",
|
||||||
"LabelName": "Nimi",
|
"LabelName": "Nimi",
|
||||||
@@ -302,6 +342,7 @@
|
|||||||
"LabelNewPassword": "Uusi salasana",
|
"LabelNewPassword": "Uusi salasana",
|
||||||
"LabelNewestAuthors": "Uusimmat kirjailijat",
|
"LabelNewestAuthors": "Uusimmat kirjailijat",
|
||||||
"LabelNewestEpisodes": "Uusimmat jaksot",
|
"LabelNewestEpisodes": "Uusimmat jaksot",
|
||||||
|
"LabelNextBackupDate": "Seuraava varmuuskopiointipäivämäärä",
|
||||||
"LabelNotStarted": "Ei aloitettu",
|
"LabelNotStarted": "Ei aloitettu",
|
||||||
"LabelPassword": "Salasana",
|
"LabelPassword": "Salasana",
|
||||||
"LabelPath": "Polku",
|
"LabelPath": "Polku",
|
||||||
|
|||||||
@@ -944,7 +944,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Échec de la création de signet",
|
"ToastBookmarkCreateFailed": "Échec de la création de signet",
|
||||||
"ToastBookmarkCreateSuccess": "Signet ajouté",
|
"ToastBookmarkCreateSuccess": "Signet ajouté",
|
||||||
"ToastBookmarkRemoveSuccess": "Signet supprimé",
|
"ToastBookmarkRemoveSuccess": "Signet supprimé",
|
||||||
"ToastBookmarkUpdateSuccess": "Signet mis à jour",
|
|
||||||
"ToastCachePurgeFailed": "Échec de la purge du cache",
|
"ToastCachePurgeFailed": "Échec de la purge du cache",
|
||||||
"ToastCachePurgeSuccess": "Cache purgé avec succès",
|
"ToastCachePurgeSuccess": "Cache purgé avec succès",
|
||||||
"ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs",
|
"ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs",
|
||||||
|
|||||||
@@ -740,7 +740,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "יצירת סימניה נכשלה",
|
"ToastBookmarkCreateFailed": "יצירת סימניה נכשלה",
|
||||||
"ToastBookmarkCreateSuccess": "הסימניה נוספה בהצלחה",
|
"ToastBookmarkCreateSuccess": "הסימניה נוספה בהצלחה",
|
||||||
"ToastBookmarkRemoveSuccess": "הסימניה הוסרה בהצלחה",
|
"ToastBookmarkRemoveSuccess": "הסימניה הוסרה בהצלחה",
|
||||||
"ToastBookmarkUpdateSuccess": "הסימניה עודכנה בהצלחה",
|
|
||||||
"ToastChaptersHaveErrors": "פרקים מכילים שגיאות",
|
"ToastChaptersHaveErrors": "פרקים מכילים שגיאות",
|
||||||
"ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות",
|
"ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות",
|
||||||
"ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה",
|
"ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה",
|
||||||
|
|||||||
@@ -300,6 +300,7 @@
|
|||||||
"LabelDiscover": "Otkrij",
|
"LabelDiscover": "Otkrij",
|
||||||
"LabelDownload": "Preuzmi",
|
"LabelDownload": "Preuzmi",
|
||||||
"LabelDownloadNEpisodes": "Preuzmi {0} nastavak/a",
|
"LabelDownloadNEpisodes": "Preuzmi {0} nastavak/a",
|
||||||
|
"LabelDownloadable": "Moguće preuzimanje",
|
||||||
"LabelDuration": "Trajanje",
|
"LabelDuration": "Trajanje",
|
||||||
"LabelDurationComparisonExactMatch": "(točno podudaranje)",
|
"LabelDurationComparisonExactMatch": "(točno podudaranje)",
|
||||||
"LabelDurationComparisonLonger": "({0} duže)",
|
"LabelDurationComparisonLonger": "({0} duže)",
|
||||||
@@ -588,6 +589,7 @@
|
|||||||
"LabelSettingsStoreMetadataWithItemHelp": "Meta-podatci se obično spremaju u /metadata/items; ako uključite ovu postavku meta-podatci će se čuvati u mapama knjižničkih stavki",
|
"LabelSettingsStoreMetadataWithItemHelp": "Meta-podatci se obično spremaju u /metadata/items; ako uključite ovu postavku meta-podatci će se čuvati u mapama knjižničkih stavki",
|
||||||
"LabelSettingsTimeFormat": "Format vremena",
|
"LabelSettingsTimeFormat": "Format vremena",
|
||||||
"LabelShare": "Podijeli",
|
"LabelShare": "Podijeli",
|
||||||
|
"LabelShareDownloadableHelp": "Korisnicima s poveznicom za dijeljenje omogućuje preuzimanje stavke.",
|
||||||
"LabelShareOpen": "Dijeljenje otvoreno",
|
"LabelShareOpen": "Dijeljenje otvoreno",
|
||||||
"LabelShareURL": "URL za dijeljenje",
|
"LabelShareURL": "URL za dijeljenje",
|
||||||
"LabelShowAll": "Prikaži sve",
|
"LabelShowAll": "Prikaži sve",
|
||||||
@@ -756,6 +758,7 @@
|
|||||||
"MessageConfirmResetProgress": "Sigurno želite resetirati napredak?",
|
"MessageConfirmResetProgress": "Sigurno želite resetirati napredak?",
|
||||||
"MessageConfirmSendEbookToDevice": "Sigurno želite poslati {0} e-knjiga/u \"{1}\" na uređaj \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "Sigurno želite poslati {0} e-knjiga/u \"{1}\" na uređaj \"{2}\"?",
|
||||||
"MessageConfirmUnlinkOpenId": "Sigurno želite odspojiti ovog korisnika s OpenID-ja?",
|
"MessageConfirmUnlinkOpenId": "Sigurno želite odspojiti ovog korisnika s OpenID-ja?",
|
||||||
|
"MessageDaysListenedInTheLastYear": "{0} dana slušanja u posljednjih godinu dana",
|
||||||
"MessageDownloadingEpisode": "Preuzimam nastavak",
|
"MessageDownloadingEpisode": "Preuzimam nastavak",
|
||||||
"MessageDragFilesIntoTrackOrder": "Prevlačenjem datoteka složite pravilan redoslijed",
|
"MessageDragFilesIntoTrackOrder": "Prevlačenjem datoteka složite pravilan redoslijed",
|
||||||
"MessageEmbedFailed": "Ugrađivanje nije uspjelo!",
|
"MessageEmbedFailed": "Ugrađivanje nije uspjelo!",
|
||||||
@@ -950,7 +953,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Izrada knjižne oznake nije uspjela",
|
"ToastBookmarkCreateFailed": "Izrada knjižne oznake nije uspjela",
|
||||||
"ToastBookmarkCreateSuccess": "Knjižna oznaka dodana",
|
"ToastBookmarkCreateSuccess": "Knjižna oznaka dodana",
|
||||||
"ToastBookmarkRemoveSuccess": "Knjižna oznaka uklonjena",
|
"ToastBookmarkRemoveSuccess": "Knjižna oznaka uklonjena",
|
||||||
"ToastBookmarkUpdateSuccess": "Knjižna oznaka ažurirana",
|
|
||||||
"ToastCachePurgeFailed": "Čišćenje predmemorije nije uspjelo",
|
"ToastCachePurgeFailed": "Čišćenje predmemorije nije uspjelo",
|
||||||
"ToastCachePurgeSuccess": "Predmemorija uspješno očišćena",
|
"ToastCachePurgeSuccess": "Predmemorija uspješno očišćena",
|
||||||
"ToastChaptersHaveErrors": "Poglavlja imaju pogreške",
|
"ToastChaptersHaveErrors": "Poglavlja imaju pogreške",
|
||||||
|
|||||||
+47
-15
@@ -100,7 +100,7 @@
|
|||||||
"ButtonStartM4BEncode": "M4B kódolás indítása",
|
"ButtonStartM4BEncode": "M4B kódolás indítása",
|
||||||
"ButtonStartMetadataEmbed": "Metaadatok beágyazásának indítása",
|
"ButtonStartMetadataEmbed": "Metaadatok beágyazásának indítása",
|
||||||
"ButtonStats": "Statisztikák",
|
"ButtonStats": "Statisztikák",
|
||||||
"ButtonSubmit": "Beküldés",
|
"ButtonSubmit": "Küldés",
|
||||||
"ButtonTest": "Teszt",
|
"ButtonTest": "Teszt",
|
||||||
"ButtonUnlinkOpenId": "OpenID szétkapcsolása",
|
"ButtonUnlinkOpenId": "OpenID szétkapcsolása",
|
||||||
"ButtonUpload": "Feltöltés",
|
"ButtonUpload": "Feltöltés",
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
"HeaderFindChapters": "Fejezetek keresése",
|
"HeaderFindChapters": "Fejezetek keresése",
|
||||||
"HeaderIgnoredFiles": "Figyelmen kívül hagyott fájlok",
|
"HeaderIgnoredFiles": "Figyelmen kívül hagyott fájlok",
|
||||||
"HeaderItemFiles": "Elemfájlok",
|
"HeaderItemFiles": "Elemfájlok",
|
||||||
"HeaderItemMetadataUtils": "Elem metaadat eszközök",
|
"HeaderItemMetadataUtils": "Metaadatok eszközei",
|
||||||
"HeaderLastListeningSession": "Utolsó hallgatási munkamenet",
|
"HeaderLastListeningSession": "Utolsó hallgatási munkamenet",
|
||||||
"HeaderLatestEpisodes": "Legújabb epizódok",
|
"HeaderLatestEpisodes": "Legújabb epizódok",
|
||||||
"HeaderLibraries": "Könyvtárak",
|
"HeaderLibraries": "Könyvtárak",
|
||||||
@@ -165,6 +165,7 @@
|
|||||||
"HeaderNotificationUpdate": "Értesítés frissítése",
|
"HeaderNotificationUpdate": "Értesítés frissítése",
|
||||||
"HeaderNotifications": "Értesítések",
|
"HeaderNotifications": "Értesítések",
|
||||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect hitelesítés",
|
"HeaderOpenIDConnectAuthentication": "OpenID Connect hitelesítés",
|
||||||
|
"HeaderOpenListeningSessions": "Hallgatási menetek megnyitása",
|
||||||
"HeaderOpenRSSFeed": "RSS hírcsatorna megnyitása",
|
"HeaderOpenRSSFeed": "RSS hírcsatorna megnyitása",
|
||||||
"HeaderOtherFiles": "Egyéb fájlok",
|
"HeaderOtherFiles": "Egyéb fájlok",
|
||||||
"HeaderPasswordAuthentication": "Jelszó hitelesítés",
|
"HeaderPasswordAuthentication": "Jelszó hitelesítés",
|
||||||
@@ -194,7 +195,7 @@
|
|||||||
"HeaderSettingsWebClient": "Webkliens",
|
"HeaderSettingsWebClient": "Webkliens",
|
||||||
"HeaderSleepTimer": "Alvásidőzítő",
|
"HeaderSleepTimer": "Alvásidőzítő",
|
||||||
"HeaderStatsLargestItems": "Legnagyobb elemek",
|
"HeaderStatsLargestItems": "Legnagyobb elemek",
|
||||||
"HeaderStatsLongestItems": "Leghosszabb elemek (órákban)",
|
"HeaderStatsLongestItems": "Leghosszabb elemek (órában)",
|
||||||
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)",
|
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)",
|
||||||
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
|
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
|
||||||
"HeaderStatsTop10Authors": "Top 10 szerző",
|
"HeaderStatsTop10Authors": "Top 10 szerző",
|
||||||
@@ -206,7 +207,7 @@
|
|||||||
"HeaderUpdateDetails": "Részletek frissítése",
|
"HeaderUpdateDetails": "Részletek frissítése",
|
||||||
"HeaderUpdateLibrary": "Könyvtár frissítése",
|
"HeaderUpdateLibrary": "Könyvtár frissítése",
|
||||||
"HeaderUsers": "Felhasználók",
|
"HeaderUsers": "Felhasználók",
|
||||||
"HeaderYearReview": "{0} év visszatekintése",
|
"HeaderYearReview": "Visszatekintés {0} -ra/re",
|
||||||
"HeaderYourStats": "Saját statisztikák",
|
"HeaderYourStats": "Saját statisztikák",
|
||||||
"LabelAbridged": "Tömörített",
|
"LabelAbridged": "Tömörített",
|
||||||
"LabelAbridgedChecked": "Rövidített (ellenőrizve)",
|
"LabelAbridgedChecked": "Rövidített (ellenőrizve)",
|
||||||
@@ -237,7 +238,7 @@
|
|||||||
"LabelAuthor": "Szerző",
|
"LabelAuthor": "Szerző",
|
||||||
"LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)",
|
"LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)",
|
||||||
"LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)",
|
"LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)",
|
||||||
"LabelAuthors": "Szerzők",
|
"LabelAuthors": "Szerző",
|
||||||
"LabelAutoDownloadEpisodes": "Epizódok automatikus letöltése",
|
"LabelAutoDownloadEpisodes": "Epizódok automatikus letöltése",
|
||||||
"LabelAutoFetchMetadata": "Metaadatok automatikus lekérése",
|
"LabelAutoFetchMetadata": "Metaadatok automatikus lekérése",
|
||||||
"LabelAutoFetchMetadataHelp": "Cím, szerző és sorozat metaadatok automatikus lekérése a feltöltés megkönnyítése érdekében. További metaadatok egyeztetése szükséges lehet a feltöltés után.",
|
"LabelAutoFetchMetadataHelp": "Cím, szerző és sorozat metaadatok automatikus lekérése a feltöltés megkönnyítése érdekében. További metaadatok egyeztetése szükséges lehet a feltöltés után.",
|
||||||
@@ -272,7 +273,7 @@
|
|||||||
"LabelCollapseSeries": "Sorozat összecsukása",
|
"LabelCollapseSeries": "Sorozat összecsukása",
|
||||||
"LabelCollapseSubSeries": "Alszéria összecsukása",
|
"LabelCollapseSubSeries": "Alszéria összecsukása",
|
||||||
"LabelCollection": "Gyűjtemény",
|
"LabelCollection": "Gyűjtemény",
|
||||||
"LabelCollections": "Gyűjtemények",
|
"LabelCollections": "Gyűjtemény",
|
||||||
"LabelComplete": "Kész",
|
"LabelComplete": "Kész",
|
||||||
"LabelConfirmPassword": "Jelszó megerősítése",
|
"LabelConfirmPassword": "Jelszó megerősítése",
|
||||||
"LabelContinueListening": "Hallgatás folytatása",
|
"LabelContinueListening": "Hallgatás folytatása",
|
||||||
@@ -299,6 +300,7 @@
|
|||||||
"LabelDiscover": "Felfedezés",
|
"LabelDiscover": "Felfedezés",
|
||||||
"LabelDownload": "Letöltés",
|
"LabelDownload": "Letöltés",
|
||||||
"LabelDownloadNEpisodes": "{0} epizód letöltése",
|
"LabelDownloadNEpisodes": "{0} epizód letöltése",
|
||||||
|
"LabelDownloadable": "Letölthető",
|
||||||
"LabelDuration": "Időtartam",
|
"LabelDuration": "Időtartam",
|
||||||
"LabelDurationComparisonExactMatch": "(pontos egyezés)",
|
"LabelDurationComparisonExactMatch": "(pontos egyezés)",
|
||||||
"LabelDurationComparisonLonger": "({0}-val hosszabb)",
|
"LabelDurationComparisonLonger": "({0}-val hosszabb)",
|
||||||
@@ -320,6 +322,7 @@
|
|||||||
"LabelEncodingChaptersNotEmbedded": "A fejezetek nincsenek beágyazva a többsávos hangoskönyvekbe.",
|
"LabelEncodingChaptersNotEmbedded": "A fejezetek nincsenek beágyazva a többsávos hangoskönyvekbe.",
|
||||||
"LabelEncodingClearItemCache": "Győződjön meg róla, hogy rendszeresen tisztítja az elemek gyorsítótárát.",
|
"LabelEncodingClearItemCache": "Győződjön meg róla, hogy rendszeresen tisztítja az elemek gyorsítótárát.",
|
||||||
"LabelEncodingFinishedM4B": "A kész M4B a hangoskönyv mappádba kerül:",
|
"LabelEncodingFinishedM4B": "A kész M4B a hangoskönyv mappádba kerül:",
|
||||||
|
"LabelEncodingInfoEmbedded": "A metaadatok beépülnek a hangsávokba a hangoskönyv mappáján belül.",
|
||||||
"LabelEncodingStartedNavigation": "Ha a feladat elindult, el lehet navigálni erről az oldalról.",
|
"LabelEncodingStartedNavigation": "Ha a feladat elindult, el lehet navigálni erről az oldalról.",
|
||||||
"LabelEncodingTimeWarning": "A kódolás akár 30 percet is igénybe vehet.",
|
"LabelEncodingTimeWarning": "A kódolás akár 30 percet is igénybe vehet.",
|
||||||
"LabelEncodingWarningAdvancedSettings": "Figyelmeztetés: Ne frissítse ezeket a beállításokat, hacsak nem ismeri az ffmpeg kódolási beállításait.",
|
"LabelEncodingWarningAdvancedSettings": "Figyelmeztetés: Ne frissítse ezeket a beállításokat, hacsak nem ismeri az ffmpeg kódolási beállításait.",
|
||||||
@@ -441,7 +444,7 @@
|
|||||||
"LabelNarrators": "Előadók",
|
"LabelNarrators": "Előadók",
|
||||||
"LabelNew": "Új",
|
"LabelNew": "Új",
|
||||||
"LabelNewPassword": "Új jelszó",
|
"LabelNewPassword": "Új jelszó",
|
||||||
"LabelNewestAuthors": "Legújabb szerzők",
|
"LabelNewestAuthors": "A legújabb szerzők",
|
||||||
"LabelNewestEpisodes": "Legújabb epizódok",
|
"LabelNewestEpisodes": "Legújabb epizódok",
|
||||||
"LabelNextBackupDate": "Következő biztonsági másolat dátuma",
|
"LabelNextBackupDate": "Következő biztonsági másolat dátuma",
|
||||||
"LabelNextScheduledRun": "Következő ütemezett futtatás",
|
"LabelNextScheduledRun": "Következő ütemezett futtatás",
|
||||||
@@ -478,7 +481,7 @@
|
|||||||
"LabelPermissionsDownload": "Letölthet",
|
"LabelPermissionsDownload": "Letölthet",
|
||||||
"LabelPermissionsUpdate": "Frissíthet",
|
"LabelPermissionsUpdate": "Frissíthet",
|
||||||
"LabelPermissionsUpload": "Feltölthet",
|
"LabelPermissionsUpload": "Feltölthet",
|
||||||
"LabelPersonalYearReview": "Az évvisszatekintésed ({0})",
|
"LabelPersonalYearReview": "Az éved összefoglalása ({0})",
|
||||||
"LabelPhotoPathURL": "Fénykép útvonal/URL",
|
"LabelPhotoPathURL": "Fénykép útvonal/URL",
|
||||||
"LabelPlayMethod": "Lejátszási módszer",
|
"LabelPlayMethod": "Lejátszási módszer",
|
||||||
"LabelPlayerChapterNumberMarker": "{0} a {1} -ből",
|
"LabelPlayerChapterNumberMarker": "{0} a {1} -ből",
|
||||||
@@ -535,11 +538,12 @@
|
|||||||
"LabelSelectUsers": "Felhasználók kiválasztása",
|
"LabelSelectUsers": "Felhasználók kiválasztása",
|
||||||
"LabelSendEbookToDevice": "E-könyv küldése...",
|
"LabelSendEbookToDevice": "E-könyv küldése...",
|
||||||
"LabelSequence": "Sorozat",
|
"LabelSequence": "Sorozat",
|
||||||
|
"LabelSerial": "Sorozat",
|
||||||
"LabelSeries": "Sorozat",
|
"LabelSeries": "Sorozat",
|
||||||
"LabelSeriesName": "Sorozat neve",
|
"LabelSeriesName": "Sorozat neve",
|
||||||
"LabelSeriesProgress": "Sorozat haladása",
|
"LabelSeriesProgress": "Sorozat haladása",
|
||||||
"LabelServerLogLevel": "Kiszolgáló naplózási szint",
|
"LabelServerLogLevel": "Kiszolgáló naplózási szint",
|
||||||
"LabelServerYearReview": "Szerver évvisszatekintés ({0})",
|
"LabelServerYearReview": "Szerver éves visszatekintése ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Beállítás elsődlegesként",
|
"LabelSetEbookAsPrimary": "Beállítás elsődlegesként",
|
||||||
"LabelSetEbookAsSupplementary": "Beállítás kiegészítőként",
|
"LabelSetEbookAsSupplementary": "Beállítás kiegészítőként",
|
||||||
"LabelSettingsAllowIframe": "A beágyazás engedélyezése egy iframe-be",
|
"LabelSettingsAllowIframe": "A beágyazás engedélyezése egy iframe-be",
|
||||||
@@ -585,7 +589,11 @@
|
|||||||
"LabelSettingsStoreMetadataWithItemHelp": "Alapértelmezés szerint a metaadatfájlok a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a metaadatfájlokat a könyvtári elem mappáiban tárolja",
|
"LabelSettingsStoreMetadataWithItemHelp": "Alapértelmezés szerint a metaadatfájlok a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a metaadatfájlokat a könyvtári elem mappáiban tárolja",
|
||||||
"LabelSettingsTimeFormat": "Időformátum",
|
"LabelSettingsTimeFormat": "Időformátum",
|
||||||
"LabelShare": "Megosztás",
|
"LabelShare": "Megosztás",
|
||||||
|
"LabelShareDownloadableHelp": "Lehetővé teszi a megosztási linket birtokló felhasználók számára, hogy letöltsék a könyvtári elem zip-fájlját.",
|
||||||
|
"LabelShareOpen": "Megosztás megnyitása",
|
||||||
|
"LabelShareURL": "URL megosztása",
|
||||||
"LabelShowAll": "Mindent mutat",
|
"LabelShowAll": "Mindent mutat",
|
||||||
|
"LabelShowSeconds": "Másodperc megjelenítése",
|
||||||
"LabelShowSubtitles": "Felirat megjelenítése",
|
"LabelShowSubtitles": "Felirat megjelenítése",
|
||||||
"LabelSize": "Méret",
|
"LabelSize": "Méret",
|
||||||
"LabelSleepTimer": "Alvásidőzítő",
|
"LabelSleepTimer": "Alvásidőzítő",
|
||||||
@@ -596,8 +604,8 @@
|
|||||||
"LabelStartTime": "Kezdési idő",
|
"LabelStartTime": "Kezdési idő",
|
||||||
"LabelStarted": "Elkezdődött",
|
"LabelStarted": "Elkezdődött",
|
||||||
"LabelStartedAt": "Kezdés ideje",
|
"LabelStartedAt": "Kezdés ideje",
|
||||||
"LabelStatsAudioTracks": "Audiósávok",
|
"LabelStatsAudioTracks": "Audiósáv",
|
||||||
"LabelStatsAuthors": "Szerzők",
|
"LabelStatsAuthors": "Szerző",
|
||||||
"LabelStatsBestDay": "Legjobb nap",
|
"LabelStatsBestDay": "Legjobb nap",
|
||||||
"LabelStatsDailyAverage": "Napi átlag",
|
"LabelStatsDailyAverage": "Napi átlag",
|
||||||
"LabelStatsDays": "Napok",
|
"LabelStatsDays": "Napok",
|
||||||
@@ -605,7 +613,7 @@
|
|||||||
"LabelStatsHours": "Órák",
|
"LabelStatsHours": "Órák",
|
||||||
"LabelStatsInARow": "egymás után",
|
"LabelStatsInARow": "egymás után",
|
||||||
"LabelStatsItemsFinished": "Befejezett elem",
|
"LabelStatsItemsFinished": "Befejezett elem",
|
||||||
"LabelStatsItemsInLibrary": "Elemek a könyvtárban",
|
"LabelStatsItemsInLibrary": "Elem a könyvtárban",
|
||||||
"LabelStatsMinutes": "perc",
|
"LabelStatsMinutes": "perc",
|
||||||
"LabelStatsMinutesListening": "Hallgatási perc",
|
"LabelStatsMinutesListening": "Hallgatási perc",
|
||||||
"LabelStatsOverallDays": "Összes nap",
|
"LabelStatsOverallDays": "Összes nap",
|
||||||
@@ -684,8 +692,8 @@
|
|||||||
"LabelWeekdaysToRun": "Futás napjai",
|
"LabelWeekdaysToRun": "Futás napjai",
|
||||||
"LabelXBooks": "{0} könyv",
|
"LabelXBooks": "{0} könyv",
|
||||||
"LabelXItems": "{0} elem",
|
"LabelXItems": "{0} elem",
|
||||||
"LabelYearReviewHide": "Az évvisszatekintés elrejtése",
|
"LabelYearReviewHide": "Visszatekintés az évre elrejtése",
|
||||||
"LabelYearReviewShow": "Évvisszatekintés megtekintése",
|
"LabelYearReviewShow": "Visszatekintés az évre megtekintése",
|
||||||
"LabelYourAudiobookDuration": "Hangoskönyv időtartama",
|
"LabelYourAudiobookDuration": "Hangoskönyv időtartama",
|
||||||
"LabelYourBookmarks": "Könyvjelzőid",
|
"LabelYourBookmarks": "Könyvjelzőid",
|
||||||
"LabelYourPlaylists": "Lejátszási listáid",
|
"LabelYourPlaylists": "Lejátszási listáid",
|
||||||
@@ -750,10 +758,12 @@
|
|||||||
"MessageConfirmResetProgress": "Biztos, hogy vissza akarja állítani a haladási folyamatát?",
|
"MessageConfirmResetProgress": "Biztos, hogy vissza akarja állítani a haladási folyamatát?",
|
||||||
"MessageConfirmSendEbookToDevice": "Biztosan el szeretné küldeni a(z) {0} e-könyvet a(z) \"{1}\" eszközre?",
|
"MessageConfirmSendEbookToDevice": "Biztosan el szeretné küldeni a(z) {0} e-könyvet a(z) \"{1}\" eszközre?",
|
||||||
"MessageConfirmUnlinkOpenId": "Biztos, hogy el akarja távolítani ezt a felhasználót az OpenID-ból?",
|
"MessageConfirmUnlinkOpenId": "Biztos, hogy el akarja távolítani ezt a felhasználót az OpenID-ból?",
|
||||||
|
"MessageDaysListenedInTheLastYear": "{0} napot hallgatott az elmúlt évben",
|
||||||
"MessageDownloadingEpisode": "Epizód letöltése",
|
"MessageDownloadingEpisode": "Epizód letöltése",
|
||||||
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
|
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
|
||||||
"MessageEmbedFailed": "A beágyazás sikertelen!",
|
"MessageEmbedFailed": "A beágyazás sikertelen!",
|
||||||
"MessageEmbedFinished": "Beágyazás befejeződött!",
|
"MessageEmbedFinished": "Beágyazás befejeződött!",
|
||||||
|
"MessageEmbedQueue": "Metaadatok beágyazására várakozik ({0} a sorban)",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} epizód letöltésre vár",
|
"MessageEpisodesQueuedForDownload": "{0} epizód letöltésre vár",
|
||||||
"MessageEreaderDevices": "Az e-könyvek kézbesítésének biztosítása érdekében a fenti e-mail címet az alább felsorolt minden egyes eszközhöz, mint érvényes feladót kell hozzáadnia.",
|
"MessageEreaderDevices": "Az e-könyvek kézbesítésének biztosítása érdekében a fenti e-mail címet az alább felsorolt minden egyes eszközhöz, mint érvényes feladót kell hozzáadnia.",
|
||||||
"MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz",
|
"MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz",
|
||||||
@@ -816,6 +826,7 @@
|
|||||||
"MessagePodcastHasNoRSSFeedForMatching": "A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez",
|
"MessagePodcastHasNoRSSFeedForMatching": "A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez",
|
||||||
"MessagePodcastSearchField": "Adja meg a keresési kifejezést vagy az RSS hírcsatorna URL-címét",
|
"MessagePodcastSearchField": "Adja meg a keresési kifejezést vagy az RSS hírcsatorna URL-címét",
|
||||||
"MessageQuickEmbedInProgress": "Gyors beágyazás folyamatban",
|
"MessageQuickEmbedInProgress": "Gyors beágyazás folyamatban",
|
||||||
|
"MessageQuickEmbedQueue": "Gyors beágyazásra várakozik ({0} a sorban)",
|
||||||
"MessageQuickMatchAllEpisodes": "Minden epizód gyors egyeztetése",
|
"MessageQuickMatchAllEpisodes": "Minden epizód gyors egyeztetése",
|
||||||
"MessageQuickMatchDescription": "Üres elem részletek és borító feltöltése az első találati eredménnyel a(z) '{0}'-ból. Nem írja felül a részleteket, kivéve, ha a 'Preferált egyeztetett metaadatok' szerverbeállítás engedélyezve van.",
|
"MessageQuickMatchDescription": "Üres elem részletek és borító feltöltése az első találati eredménnyel a(z) '{0}'-ból. Nem írja felül a részleteket, kivéve, ha a 'Preferált egyeztetett metaadatok' szerverbeállítás engedélyezve van.",
|
||||||
"MessageRemoveChapter": "Fejezet eltávolítása",
|
"MessageRemoveChapter": "Fejezet eltávolítása",
|
||||||
@@ -832,6 +843,7 @@
|
|||||||
"MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként",
|
"MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként",
|
||||||
"MessageShareExpirationWillBe": "A lejárat: <strong>{0}</strong>",
|
"MessageShareExpirationWillBe": "A lejárat: <strong>{0}</strong>",
|
||||||
"MessageShareExpiresIn": "{0} múlva jár le",
|
"MessageShareExpiresIn": "{0} múlva jár le",
|
||||||
|
"MessageShareURLWillBe": "A megosztási URL <strong>{0}</strong> lesz",
|
||||||
"MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?",
|
"MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?",
|
||||||
"MessageTaskAudioFileNotWritable": "A/Az „{0}” hangfájl nem írható",
|
"MessageTaskAudioFileNotWritable": "A/Az „{0}” hangfájl nem írható",
|
||||||
"MessageTaskCanceledByUser": "Felhasználó törölte a feladatot",
|
"MessageTaskCanceledByUser": "Felhasználó törölte a feladatot",
|
||||||
@@ -937,7 +949,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
|
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
|
||||||
"ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva",
|
"ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva",
|
||||||
"ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva",
|
"ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva",
|
||||||
"ToastBookmarkUpdateSuccess": "Könyvjelző frissítve",
|
|
||||||
"ToastCachePurgeFailed": "A gyorsítótár törlése sikertelen",
|
"ToastCachePurgeFailed": "A gyorsítótár törlése sikertelen",
|
||||||
"ToastCachePurgeSuccess": "A gyorsítótár sikeresen törölve",
|
"ToastCachePurgeSuccess": "A gyorsítótár sikeresen törölve",
|
||||||
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
|
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
|
||||||
@@ -954,9 +965,11 @@
|
|||||||
"ToastDeviceTestEmailFailed": "Teszt email küldése sikertelen",
|
"ToastDeviceTestEmailFailed": "Teszt email küldése sikertelen",
|
||||||
"ToastDeviceTestEmailSuccess": "Teszt email elküldve",
|
"ToastDeviceTestEmailSuccess": "Teszt email elküldve",
|
||||||
"ToastEmailSettingsUpdateSuccess": "Email beállítások frissítve",
|
"ToastEmailSettingsUpdateSuccess": "Email beállítások frissítve",
|
||||||
|
"ToastEncodeCancelFailed": "A kódolás törlése sikertelen volt",
|
||||||
"ToastEncodeCancelSucces": "Kódolás törölve",
|
"ToastEncodeCancelSucces": "Kódolás törölve",
|
||||||
"ToastEpisodeDownloadQueueClearFailed": "Nem sikerült törölni a várólistát",
|
"ToastEpisodeDownloadQueueClearFailed": "Nem sikerült törölni a várólistát",
|
||||||
"ToastEpisodeUpdateSuccess": "{0} epizód frissítve",
|
"ToastEpisodeUpdateSuccess": "{0} epizód frissítve",
|
||||||
|
"ToastErrorCannotShare": "Ezen az eszközön nem lehet natívan megosztani",
|
||||||
"ToastFailedToLoadData": "Sikertelen adatbetöltés",
|
"ToastFailedToLoadData": "Sikertelen adatbetöltés",
|
||||||
"ToastFailedToMatch": "Nem sikerült egyezőséget találni",
|
"ToastFailedToMatch": "Nem sikerült egyezőséget találni",
|
||||||
"ToastFailedToShare": "Nem sikerült megosztani",
|
"ToastFailedToShare": "Nem sikerült megosztani",
|
||||||
@@ -992,10 +1005,14 @@
|
|||||||
"ToastNewUserCreatedSuccess": "Új fiók létrehozva",
|
"ToastNewUserCreatedSuccess": "Új fiók létrehozva",
|
||||||
"ToastNewUserLibraryError": "Legalább egy könyvtárat ki kell választani",
|
"ToastNewUserLibraryError": "Legalább egy könyvtárat ki kell választani",
|
||||||
"ToastNewUserPasswordError": "Kötelező a jelszó, csak a root felhasználónak lehet üres jelszava",
|
"ToastNewUserPasswordError": "Kötelező a jelszó, csak a root felhasználónak lehet üres jelszava",
|
||||||
|
"ToastNewUserTagError": "Legalább egy címkét ki kell választania",
|
||||||
"ToastNewUserUsernameError": "Adjon meg egy felhasználónevet",
|
"ToastNewUserUsernameError": "Adjon meg egy felhasználónevet",
|
||||||
"ToastNoNewEpisodesFound": "Nincs új epizód",
|
"ToastNoNewEpisodesFound": "Nincs új epizód",
|
||||||
"ToastNoUpdatesNecessary": "Nincs szükség frissítésre",
|
"ToastNoUpdatesNecessary": "Nincs szükség frissítésre",
|
||||||
|
"ToastNotificationCreateFailed": "Értesítés létrehozása sikertelen",
|
||||||
|
"ToastNotificationDeleteFailed": "Értesítés törlése sikertelen",
|
||||||
"ToastNotificationSettingsUpdateSuccess": "Értesítési beállítások frissítve",
|
"ToastNotificationSettingsUpdateSuccess": "Értesítési beállítások frissítve",
|
||||||
|
"ToastNotificationTestTriggerFailed": "Nem sikerült a tesztértesítést elindítani",
|
||||||
"ToastNotificationUpdateSuccess": "Értesítés frissítve",
|
"ToastNotificationUpdateSuccess": "Értesítés frissítve",
|
||||||
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
|
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
|
||||||
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
|
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
|
||||||
@@ -1005,22 +1022,37 @@
|
|||||||
"ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva",
|
"ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva",
|
||||||
"ToastPodcastNoEpisodesInFeed": "Nincsenek epizódok az RSS hírcsatornában",
|
"ToastPodcastNoEpisodesInFeed": "Nincsenek epizódok az RSS hírcsatornában",
|
||||||
"ToastPodcastNoRssFeed": "A podcastnak nincs RSS-hírcsatornája",
|
"ToastPodcastNoRssFeed": "A podcastnak nincs RSS-hírcsatornája",
|
||||||
|
"ToastProgressIsNotBeingSynced": "Az előrehaladás nem szinkronizálódik, a lejátszás újraindul",
|
||||||
|
"ToastProviderCreatedFailed": "Hiba a szolgáltató hozzáadásakor",
|
||||||
|
"ToastProviderCreatedSuccess": "Új szolgáltató hozzáadva",
|
||||||
|
"ToastProviderNameAndUrlRequired": "Név és Url kötelező",
|
||||||
|
"ToastProviderRemoveSuccess": "Szolgáltató eltávolítva",
|
||||||
"ToastRSSFeedCloseFailed": "Az RSS hírcsatorna bezárása sikertelen",
|
"ToastRSSFeedCloseFailed": "Az RSS hírcsatorna bezárása sikertelen",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS hírfolyam leállítva",
|
"ToastRSSFeedCloseSuccess": "RSS hírfolyam leállítva",
|
||||||
|
"ToastRemoveFailed": "Sikertelen eltávolítás",
|
||||||
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
|
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
|
||||||
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
|
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
|
||||||
|
"ToastRenameFailed": "Sikertelen átnevezés",
|
||||||
|
"ToastSelectAtLeastOneUser": "Válasszon legalább egy felhasználót",
|
||||||
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
|
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
|
||||||
"ToastSendEbookToDeviceSuccess": "E-könyv elküldve az eszközre \"{0}\"",
|
"ToastSendEbookToDeviceSuccess": "E-könyv elküldve az eszközre \"{0}\"",
|
||||||
"ToastSeriesUpdateFailed": "Sorozat frissítése sikertelen",
|
"ToastSeriesUpdateFailed": "Sorozat frissítése sikertelen",
|
||||||
"ToastSeriesUpdateSuccess": "Sorozat frissítése sikeres",
|
"ToastSeriesUpdateSuccess": "Sorozat frissítése sikeres",
|
||||||
|
"ToastServerSettingsUpdateSuccess": "Szerver beállítások frissítve",
|
||||||
|
"ToastSessionCloseFailed": "A munkamenet bezárása sikertelen",
|
||||||
"ToastSessionDeleteFailed": "Munkamenet törlése sikertelen",
|
"ToastSessionDeleteFailed": "Munkamenet törlése sikertelen",
|
||||||
"ToastSessionDeleteSuccess": "Munkamenet törölve",
|
"ToastSessionDeleteSuccess": "Munkamenet törölve",
|
||||||
|
"ToastSleepTimerDone": "Alvásidőzítő kész... zZzzZZz",
|
||||||
"ToastSocketConnected": "Socket csatlakoztatva",
|
"ToastSocketConnected": "Socket csatlakoztatva",
|
||||||
"ToastSocketDisconnected": "Socket lecsatlakoztatva",
|
"ToastSocketDisconnected": "Socket lecsatlakoztatva",
|
||||||
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
|
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
|
||||||
|
"ToastSortingPrefixesEmptyError": "Legalább 1 rendezési előtaggal kell rendelkeznie",
|
||||||
|
"ToastSortingPrefixesUpdateSuccess": "Rendezési előtagok frissítése ({0} elem)",
|
||||||
|
"ToastTitleRequired": "A cím kötelező",
|
||||||
"ToastUnknownError": "Ismeretlen hiba",
|
"ToastUnknownError": "Ismeretlen hiba",
|
||||||
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
|
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
|
||||||
"ToastUserDeleteSuccess": "Felhasználó törölve",
|
"ToastUserDeleteSuccess": "Felhasználó törölve",
|
||||||
"ToastUserPasswordChangeSuccess": "Jelszó sikeresen megváltoztatva",
|
"ToastUserPasswordChangeSuccess": "Jelszó sikeresen megváltoztatva",
|
||||||
|
"ToastUserPasswordMustChange": "Az új jelszó nem egyezik a régi jelszóval",
|
||||||
"ToastUserRootRequireName": "Egy root felhasználónevet kell megadnia"
|
"ToastUserRootRequireName": "Egy root felhasználónevet kell megadnia"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -941,7 +941,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Creazione segnalibro fallita",
|
"ToastBookmarkCreateFailed": "Creazione segnalibro fallita",
|
||||||
"ToastBookmarkCreateSuccess": "Segnalibro creato",
|
"ToastBookmarkCreateSuccess": "Segnalibro creato",
|
||||||
"ToastBookmarkRemoveSuccess": "Segnalibro Rimosso",
|
"ToastBookmarkRemoveSuccess": "Segnalibro Rimosso",
|
||||||
"ToastBookmarkUpdateSuccess": "Segnalibro aggiornato",
|
|
||||||
"ToastCachePurgeFailed": "Impossibile eliminare la cache",
|
"ToastCachePurgeFailed": "Impossibile eliminare la cache",
|
||||||
"ToastCachePurgeSuccess": "Cache eliminata correttamente",
|
"ToastCachePurgeSuccess": "Cache eliminata correttamente",
|
||||||
"ToastChaptersHaveErrors": "I capitoli contengono errori",
|
"ToastChaptersHaveErrors": "I capitoli contengono errori",
|
||||||
|
|||||||
@@ -660,7 +660,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Žymos sukurti nepavyko",
|
"ToastBookmarkCreateFailed": "Žymos sukurti nepavyko",
|
||||||
"ToastBookmarkCreateSuccess": "Žyma pridėta",
|
"ToastBookmarkCreateSuccess": "Žyma pridėta",
|
||||||
"ToastBookmarkRemoveSuccess": "Žyma pašalinta",
|
"ToastBookmarkRemoveSuccess": "Žyma pašalinta",
|
||||||
"ToastBookmarkUpdateSuccess": "Žyma atnaujinta",
|
|
||||||
"ToastChaptersHaveErrors": "Skyriai turi klaidų",
|
"ToastChaptersHaveErrors": "Skyriai turi klaidų",
|
||||||
"ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus",
|
"ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus",
|
||||||
"ToastChaptersRemoved": "Skyriai pašalinti",
|
"ToastChaptersRemoved": "Skyriai pašalinti",
|
||||||
|
|||||||
@@ -937,7 +937,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt",
|
"ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt",
|
||||||
"ToastBookmarkCreateSuccess": "boekwijzer toegevoegd",
|
"ToastBookmarkCreateSuccess": "boekwijzer toegevoegd",
|
||||||
"ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd",
|
"ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd",
|
||||||
"ToastBookmarkUpdateSuccess": "Boekwijzer bijgewerkt",
|
|
||||||
"ToastCachePurgeFailed": "Cache wissen is mislukt",
|
"ToastCachePurgeFailed": "Cache wissen is mislukt",
|
||||||
"ToastCachePurgeSuccess": "Cache succesvol verwijderd",
|
"ToastCachePurgeSuccess": "Cache succesvol verwijderd",
|
||||||
"ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten",
|
"ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten",
|
||||||
|
|||||||
+43
-13
@@ -12,7 +12,7 @@
|
|||||||
"ButtonBack": "Tilbake",
|
"ButtonBack": "Tilbake",
|
||||||
"ButtonBrowseForFolder": "Bla gjennom mappe",
|
"ButtonBrowseForFolder": "Bla gjennom mappe",
|
||||||
"ButtonCancel": "Avbryt",
|
"ButtonCancel": "Avbryt",
|
||||||
"ButtonCancelEncode": "Avbryt Encode",
|
"ButtonCancelEncode": "Avbryt konvertering",
|
||||||
"ButtonChangeRootPassword": "Bytt Root-bruker passord",
|
"ButtonChangeRootPassword": "Bytt Root-bruker passord",
|
||||||
"ButtonCheckAndDownloadNewEpisodes": "Sjekk og last ned nye episoder",
|
"ButtonCheckAndDownloadNewEpisodes": "Sjekk og last ned nye episoder",
|
||||||
"ButtonChooseAFolder": "Velg mappe",
|
"ButtonChooseAFolder": "Velg mappe",
|
||||||
@@ -97,10 +97,10 @@
|
|||||||
"ButtonShare": "Del",
|
"ButtonShare": "Del",
|
||||||
"ButtonShiftTimes": "Forskyv tider",
|
"ButtonShiftTimes": "Forskyv tider",
|
||||||
"ButtonShow": "Vis",
|
"ButtonShow": "Vis",
|
||||||
"ButtonStartM4BEncode": "Start M4B Koding",
|
"ButtonStartM4BEncode": "Start konvertering til M4B",
|
||||||
"ButtonStartMetadataEmbed": "Start Metadata innbaking",
|
"ButtonStartMetadataEmbed": "Start Metadata innbaking",
|
||||||
"ButtonStats": "Statistikk",
|
"ButtonStats": "Statistikk",
|
||||||
"ButtonSubmit": "Send inn",
|
"ButtonSubmit": "Lagre",
|
||||||
"ButtonTest": "Test",
|
"ButtonTest": "Test",
|
||||||
"ButtonUnlinkOpenId": "Koble fra OpenID",
|
"ButtonUnlinkOpenId": "Koble fra OpenID",
|
||||||
"ButtonUpload": "Last opp",
|
"ButtonUpload": "Last opp",
|
||||||
@@ -143,12 +143,12 @@
|
|||||||
"HeaderFindChapters": "Finn Kapittel",
|
"HeaderFindChapters": "Finn Kapittel",
|
||||||
"HeaderIgnoredFiles": "Ignorerte filer",
|
"HeaderIgnoredFiles": "Ignorerte filer",
|
||||||
"HeaderItemFiles": "Elementfiler",
|
"HeaderItemFiles": "Elementfiler",
|
||||||
"HeaderItemMetadataUtils": "Enhet Metadata verktøy",
|
"HeaderItemMetadataUtils": "Element Metadata verktøy",
|
||||||
"HeaderLastListeningSession": "Siste lyttesesjon",
|
"HeaderLastListeningSession": "Siste lyttesesjon",
|
||||||
"HeaderLatestEpisodes": "Siste episoder",
|
"HeaderLatestEpisodes": "Siste episoder",
|
||||||
"HeaderLibraries": "Biblioteker",
|
"HeaderLibraries": "Biblioteker",
|
||||||
"HeaderLibraryFiles": "Bibliotek filer",
|
"HeaderLibraryFiles": "Bibliotek filer",
|
||||||
"HeaderLibraryStats": "Bibliotek statistikk",
|
"HeaderLibraryStats": "Bibliotekstatistikk",
|
||||||
"HeaderListeningSessions": "Lyttesesjoner",
|
"HeaderListeningSessions": "Lyttesesjoner",
|
||||||
"HeaderListeningStats": "Lyttestatistikk",
|
"HeaderListeningStats": "Lyttestatistikk",
|
||||||
"HeaderLogin": "Logg inn",
|
"HeaderLogin": "Logg inn",
|
||||||
@@ -300,6 +300,7 @@
|
|||||||
"LabelDiscover": "Oppdag",
|
"LabelDiscover": "Oppdag",
|
||||||
"LabelDownload": "Last ned",
|
"LabelDownload": "Last ned",
|
||||||
"LabelDownloadNEpisodes": "Last ned {0} episoder",
|
"LabelDownloadNEpisodes": "Last ned {0} episoder",
|
||||||
|
"LabelDownloadable": "Nedlastbar",
|
||||||
"LabelDuration": "Varighet",
|
"LabelDuration": "Varighet",
|
||||||
"LabelDurationComparisonExactMatch": "(nøyaktig treff)",
|
"LabelDurationComparisonExactMatch": "(nøyaktig treff)",
|
||||||
"LabelDurationComparisonLonger": "({0} lenger)",
|
"LabelDurationComparisonLonger": "({0} lenger)",
|
||||||
@@ -365,11 +366,11 @@
|
|||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelFull": "Full",
|
"LabelFull": "Full",
|
||||||
"LabelGenre": "Sjanger",
|
"LabelGenre": "Sjanger",
|
||||||
"LabelGenres": "Sjangers",
|
"LabelGenres": "Sjangre",
|
||||||
"LabelHardDeleteFile": "Tving sletting av fil",
|
"LabelHardDeleteFile": "Tving sletting av fil",
|
||||||
"LabelHasEbook": "Har e-bok",
|
"LabelHasEbook": "Har e-bok",
|
||||||
"LabelHasSupplementaryEbook": "Har komplimentær e-bok",
|
"LabelHasSupplementaryEbook": "Har komplimentær e-bok",
|
||||||
"LabelHideSubtitles": "Skjul undertekster",
|
"LabelHideSubtitles": "Skjul undertitler",
|
||||||
"LabelHighestPriority": "Høyeste prioritet",
|
"LabelHighestPriority": "Høyeste prioritet",
|
||||||
"LabelHost": "Tjener",
|
"LabelHost": "Tjener",
|
||||||
"LabelHour": "Time",
|
"LabelHour": "Time",
|
||||||
@@ -406,7 +407,7 @@
|
|||||||
"LabelLess": "Mindre",
|
"LabelLess": "Mindre",
|
||||||
"LabelLibrariesAccessibleToUser": "Biblioteker tilgjengelig for bruker",
|
"LabelLibrariesAccessibleToUser": "Biblioteker tilgjengelig for bruker",
|
||||||
"LabelLibrary": "Bibliotek",
|
"LabelLibrary": "Bibliotek",
|
||||||
"LabelLibraryFilterSublistEmpty": "",
|
"LabelLibraryFilterSublistEmpty": "Ingen {0}",
|
||||||
"LabelLibraryItem": "Bibliotek enhet",
|
"LabelLibraryItem": "Bibliotek enhet",
|
||||||
"LabelLibraryName": "Bibliotek navn",
|
"LabelLibraryName": "Bibliotek navn",
|
||||||
"LabelLimit": "Begrensning",
|
"LabelLimit": "Begrensning",
|
||||||
@@ -570,7 +571,7 @@
|
|||||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Marker som ferdig når",
|
"LabelSettingsLibraryMarkAsFinishedWhen": "Marker som ferdig når",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hopp over tidligere bøker i \"Fortsett serien\"",
|
"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.",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "\"Fortsett serie\"-siden viser første bok som ikke er påbegynt i serier der en bok er lest og ingen bøker leses nå. Ved å slå på denne innstillingen så vil man fortsette på serien etter siste leste bok, fremfor første bok som ikke er startet på i en serie.",
|
||||||
"LabelSettingsParseSubtitles": "Analyser undertekster",
|
"LabelSettingsParseSubtitles": "Analyser undertitler",
|
||||||
"LabelSettingsParseSubtitlesHelp": "Hent undertittel fra lydbokens mappenavn.<br>Undertittel må være separert med \" - \"<br>f.eks. \"Boktittel - En lengre tittel\" har undertittel \"En lengre tittel\".",
|
"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",
|
"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.",
|
"LabelSettingsPreferMatchedMetadataHelp": "Funnet data vil overskrive enhetens detaljene når man bruker Kjapt søk. Som standard vil Kjapt søk kun fylle inn manglende detaljer.",
|
||||||
@@ -586,6 +587,7 @@
|
|||||||
"LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden",
|
"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",
|
"LabelSettingsTimeFormat": "Tid format",
|
||||||
"LabelShare": "Dele",
|
"LabelShare": "Dele",
|
||||||
|
"LabelShareDownloadableHelp": "Tillat brukere med en delt link å laste ned en zip-fil av elementet.",
|
||||||
"LabelShareOpen": "Åpne deling",
|
"LabelShareOpen": "Åpne deling",
|
||||||
"LabelShareURL": "Dele URL",
|
"LabelShareURL": "Dele URL",
|
||||||
"LabelShowAll": "Vis alle",
|
"LabelShowAll": "Vis alle",
|
||||||
@@ -615,7 +617,7 @@
|
|||||||
"LabelStatsOverallDays": "Totale dager",
|
"LabelStatsOverallDays": "Totale dager",
|
||||||
"LabelStatsOverallHours": "Totale timer",
|
"LabelStatsOverallHours": "Totale timer",
|
||||||
"LabelStatsWeekListening": "Uker lyttet",
|
"LabelStatsWeekListening": "Uker lyttet",
|
||||||
"LabelSubtitle": "undertekster",
|
"LabelSubtitle": "Undertittel",
|
||||||
"LabelSupportedFileTypes": "Støttede filtyper",
|
"LabelSupportedFileTypes": "Støttede filtyper",
|
||||||
"LabelTag": "Tag",
|
"LabelTag": "Tag",
|
||||||
"LabelTags": "Tagger",
|
"LabelTags": "Tagger",
|
||||||
@@ -640,11 +642,11 @@
|
|||||||
"LabelTimeRemaining": "{0} gjennstående",
|
"LabelTimeRemaining": "{0} gjennstående",
|
||||||
"LabelTimeToShift": "Tid å forflytte i sekunder",
|
"LabelTimeToShift": "Tid å forflytte i sekunder",
|
||||||
"LabelTitle": "Tittel",
|
"LabelTitle": "Tittel",
|
||||||
"LabelToolsEmbedMetadata": "Bak inn metadata",
|
"LabelToolsEmbedMetadata": "Bygg inn metadata",
|
||||||
"LabelToolsEmbedMetadataDescription": "Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.",
|
"LabelToolsEmbedMetadataDescription": "Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.",
|
||||||
"LabelToolsM4bEncoder": "M4B enkoder",
|
"LabelToolsM4bEncoder": "M4B enkoder",
|
||||||
"LabelToolsMakeM4b": "Lag M4B Lydbokfil",
|
"LabelToolsMakeM4b": "Lag M4B Lydbokfil",
|
||||||
"LabelToolsMakeM4bDescription": "Lager en.M4B lydbokfil med innbakte omslagsbilde og kapitler.",
|
"LabelToolsMakeM4bDescription": "Lager en M4B lydbokfil med innbakt omslagsbilde og kapitler.",
|
||||||
"LabelToolsSplitM4b": "Del M4B inn i MP3er",
|
"LabelToolsSplitM4b": "Del M4B inn i MP3er",
|
||||||
"LabelToolsSplitM4bDescription": "Lager MP3er fra en M4B inndelt i kapitler med innbakt metadata, omslagsbilde og kapitler.",
|
"LabelToolsSplitM4bDescription": "Lager MP3er fra en M4B inndelt i kapitler med innbakt metadata, omslagsbilde og kapitler.",
|
||||||
"LabelTotalDuration": "Total lengde",
|
"LabelTotalDuration": "Total lengde",
|
||||||
@@ -754,6 +756,7 @@
|
|||||||
"MessageConfirmResetProgress": "Er du sikkert på at du vil tilbakestille fremgangen?",
|
"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}\"?",
|
"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?",
|
"MessageConfirmUnlinkOpenId": "Er du sikker på at du vil koble denne brukeren fra OpenID?",
|
||||||
|
"MessageDaysListenedInTheLastYear": "{0} dager med lytting siste året",
|
||||||
"MessageDownloadingEpisode": "Laster ned episode",
|
"MessageDownloadingEpisode": "Laster ned episode",
|
||||||
"MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge",
|
"MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge",
|
||||||
"MessageEmbedFailed": "Innbygging feilet!",
|
"MessageEmbedFailed": "Innbygging feilet!",
|
||||||
@@ -771,6 +774,7 @@
|
|||||||
"MessageJoinUsOn": "Følg oss nå",
|
"MessageJoinUsOn": "Følg oss nå",
|
||||||
"MessageLoading": "Laster...",
|
"MessageLoading": "Laster...",
|
||||||
"MessageLoadingFolders": "Laster mapper...",
|
"MessageLoadingFolders": "Laster mapper...",
|
||||||
|
"MessageLogsDescription": "Logger lagres i <code>/metadata/logs</code> som JSON-filer. Krasjlogger lagres i <code>/metadata/logs/crash_logs.txt</code>.",
|
||||||
"MessageM4BFailed": "M4B mislykkes!",
|
"MessageM4BFailed": "M4B mislykkes!",
|
||||||
"MessageM4BFinished": "M4B fullført!",
|
"MessageM4BFinished": "M4B fullført!",
|
||||||
"MessageMapChapterTitles": "Bruk kapittel titler fra din eksisterende lydbok kapitler uten å justere tidsstempel",
|
"MessageMapChapterTitles": "Bruk kapittel titler fra din eksisterende lydbok kapitler uten å justere tidsstempel",
|
||||||
@@ -787,6 +791,7 @@
|
|||||||
"MessageNoCollections": "Ingen samlinger",
|
"MessageNoCollections": "Ingen samlinger",
|
||||||
"MessageNoCoversFound": "Ingen omslagsbilde funnet",
|
"MessageNoCoversFound": "Ingen omslagsbilde funnet",
|
||||||
"MessageNoDescription": "Ingen beskrivelse",
|
"MessageNoDescription": "Ingen beskrivelse",
|
||||||
|
"MessageNoDevices": "Ingen enheter",
|
||||||
"MessageNoDownloadsInProgress": "Ingen aktive nedlastinger",
|
"MessageNoDownloadsInProgress": "Ingen aktive nedlastinger",
|
||||||
"MessageNoDownloadsQueued": "Ingen nedlastinger i kø",
|
"MessageNoDownloadsQueued": "Ingen nedlastinger i kø",
|
||||||
"MessageNoEpisodeMatchesFound": "Ingen lik episode funnet",
|
"MessageNoEpisodeMatchesFound": "Ingen lik episode funnet",
|
||||||
@@ -800,6 +805,7 @@
|
|||||||
"MessageNoLogs": "Ingen logger",
|
"MessageNoLogs": "Ingen logger",
|
||||||
"MessageNoMediaProgress": "Ingen mediefremgang",
|
"MessageNoMediaProgress": "Ingen mediefremgang",
|
||||||
"MessageNoNotifications": "Ingen varslinger",
|
"MessageNoNotifications": "Ingen varslinger",
|
||||||
|
"MessageNoPodcastFeed": "Ugyldig podcast: Ingen feed",
|
||||||
"MessageNoPodcastsFound": "Ingen podcaster funnet",
|
"MessageNoPodcastsFound": "Ingen podcaster funnet",
|
||||||
"MessageNoResults": "Ingen resultat",
|
"MessageNoResults": "Ingen resultat",
|
||||||
"MessageNoSearchResultsFor": "Ingen søkeresultat for \"{0}\"",
|
"MessageNoSearchResultsFor": "Ingen søkeresultat for \"{0}\"",
|
||||||
@@ -809,11 +815,17 @@
|
|||||||
"MessageNoUpdatesWereNecessary": "Ingen oppdatering var nødvendig",
|
"MessageNoUpdatesWereNecessary": "Ingen oppdatering var nødvendig",
|
||||||
"MessageNoUserPlaylists": "Du har ingen spillelister",
|
"MessageNoUserPlaylists": "Du har ingen spillelister",
|
||||||
"MessageNotYetImplemented": "Ikke implementert ennå",
|
"MessageNotYetImplemented": "Ikke implementert ennå",
|
||||||
|
"MessageOpmlPreviewNote": "PS: Dette er en forhåndvisning av en OPML-fil. Den faktiske podcast-tittelen hentes direkte fra RSS-feeden.",
|
||||||
"MessageOr": "eller",
|
"MessageOr": "eller",
|
||||||
"MessagePauseChapter": "Pause avspilling av kapittel",
|
"MessagePauseChapter": "Pause avspilling av kapittel",
|
||||||
"MessagePlayChapter": "Lytter på begynnelsen av kapittel",
|
"MessagePlayChapter": "Lytter på begynnelsen av kapittel",
|
||||||
"MessagePlaylistCreateFromCollection": "Lag spilleliste fra samling",
|
"MessagePlaylistCreateFromCollection": "Lag spilleliste fra samling",
|
||||||
|
"MessagePleaseWait": "Vennligst vent...",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast har ingen RSS feed url til bruk av sammenligning",
|
"MessagePodcastHasNoRSSFeedForMatching": "Podcast har ingen RSS feed url til bruk av sammenligning",
|
||||||
|
"MessagePodcastSearchField": "Skriv inn søkeord eller RSS-feed URL",
|
||||||
|
"MessageQuickEmbedInProgress": "Hurtiginnbygging pågår",
|
||||||
|
"MessageQuickEmbedQueue": "Kø for hurtiginnbygging ({0} i kø)",
|
||||||
|
"MessageQuickMatchAllEpisodes": "Kjapp matching av alle episoder",
|
||||||
"MessageQuickMatchDescription": "Fyll inn tomme gjenstandsdetaljer og omslagsbilde med første resultat fra '{0}'. Overskriver ikke detaljene utenom 'Foretrekk funnet metadata' tjenerinstilling er aktivert.",
|
"MessageQuickMatchDescription": "Fyll inn tomme gjenstandsdetaljer og omslagsbilde med første resultat fra '{0}'. Overskriver ikke detaljene utenom 'Foretrekk funnet metadata' tjenerinstilling er aktivert.",
|
||||||
"MessageRemoveChapter": "fjerne kapittel",
|
"MessageRemoveChapter": "fjerne kapittel",
|
||||||
"MessageRemoveEpisodes": "fjerne {0} kapitler",
|
"MessageRemoveEpisodes": "fjerne {0} kapitler",
|
||||||
@@ -823,10 +835,29 @@
|
|||||||
"MessageResetChaptersConfirm": "Er du sikker på at du vil nullstille kapitler og angre endringene du har gjort?",
|
"MessageResetChaptersConfirm": "Er du sikker på at du vil nullstille kapitler og angre endringene du har gjort?",
|
||||||
"MessageRestoreBackupConfirm": "Er du sikker på at du vil gjenopprette sikkerhetskopien som var laget",
|
"MessageRestoreBackupConfirm": "Er du sikker på at du vil gjenopprette sikkerhetskopien som var laget",
|
||||||
"MessageRestoreBackupWarning": "gjenoppretting av sikkerhetskopi vil overskrive hele databasen under /config og omslagsbilde under /metadata/items og /metadata/authors.<br /><br />Sikkerhetskopier endrer ikke noen filer under dine bibliotekmapper. Hvis du har aktivert tjenerinstillingen for å lagre omslagsbilder og metadata i bibliotekmapper så vil ikke de filene bli tatt sikkerhetskopi eller overskrevet.<br /><br />Alle klientene som bruker din tjener vil bli fornyet automatisk.",
|
"MessageRestoreBackupWarning": "gjenoppretting av sikkerhetskopi vil overskrive hele databasen under /config og omslagsbilde under /metadata/items og /metadata/authors.<br /><br />Sikkerhetskopier endrer ikke noen filer under dine bibliotekmapper. Hvis du har aktivert tjenerinstillingen for å lagre omslagsbilder og metadata i bibliotekmapper så vil ikke de filene bli tatt sikkerhetskopi eller overskrevet.<br /><br />Alle klientene som bruker din tjener vil bli fornyet automatisk.",
|
||||||
|
"MessageScheduleLibraryScanNote": "For de fleste brukere er det anbefalt å la denne funksjonen være slått av, og la mappeovervåkeren stå på. Mappeovervåkeren oppdager automatisk endringer i biblioteksmappene. Mappeovervåkeren fungerer ikke med alle filsystemer (f.eks. NFS) og da kan planlagt skanning av bibliotekene brukes i steden for.",
|
||||||
"MessageSearchResultsFor": "Søk resultat for",
|
"MessageSearchResultsFor": "Søk resultat for",
|
||||||
|
"MessageSelected": "{0} valgt",
|
||||||
"MessageServerCouldNotBeReached": "Tjener kunne ikke bli nådd",
|
"MessageServerCouldNotBeReached": "Tjener kunne ikke bli nådd",
|
||||||
"MessageSetChaptersFromTracksDescription": "Sett kapitler ved å bruke hver lydfil som kapittel og kapitteltittel som lydfilnavnet",
|
"MessageSetChaptersFromTracksDescription": "Sett kapitler ved å bruke hver lydfil som kapittel og kapitteltittel som lydfilnavnet",
|
||||||
|
"MessageShareExpirationWillBe": "Utløp vil være <strong>{0}</strong>",
|
||||||
|
"MessageShareExpiresIn": "Utløper om {0}",
|
||||||
|
"MessageShareURLWillBe": "URL for deling blir <strong>{0}</strong>",
|
||||||
"MessageStartPlaybackAtTime": "Start avspilling av \"{0}\" ved {1}?",
|
"MessageStartPlaybackAtTime": "Start avspilling av \"{0}\" ved {1}?",
|
||||||
|
"MessageTaskAudioFileNotWritable": "Lydfilen \"{0}\" kan ikke skrives til",
|
||||||
|
"MessageTaskCanceledByUser": "Oppgave kansellert av bruker",
|
||||||
|
"MessageTaskDownloadingEpisodeDescription": "Laster ned episode \"{0}\"",
|
||||||
|
"MessageTaskEmbeddingMetadata": "Bygger inn metadata",
|
||||||
|
"MessageTaskEmbeddingMetadataDescription": "Bygger inn metadata i lydboken \"{0}\"",
|
||||||
|
"MessageTaskEncodingM4b": "Konverterer til M4B",
|
||||||
|
"MessageTaskEncodingM4bDescription": "Konverterer lydboken \"{0}\" til én M4B-fil",
|
||||||
|
"MessageTaskFailed": "Feilet",
|
||||||
|
"MessageTaskFailedToBackupAudioFile": "Feil ved sikkerhetskopiering av lydfilen \"{0}\"",
|
||||||
|
"MessageTaskFailedToCreateCacheDirectory": "Kunne ikke opprette mappe for mellomlagring (cache)",
|
||||||
|
"MessageTaskFailedToEmbedMetadataInFile": "Kunne ikke bygge inn metadata i filen \"{0}\"",
|
||||||
|
"MessageTaskFailedToMergeAudioFiles": "Kunne ikke slå sammen lydfiler",
|
||||||
|
"MessageTaskFailedToMoveM4bFile": "Kunne ikke flytte M4B-fil",
|
||||||
|
"MessageTaskFailedToWriteMetadataFile": "Kunne ikke lagre metadata-fil",
|
||||||
"MessageThinking": "Tenker...",
|
"MessageThinking": "Tenker...",
|
||||||
"MessageUploaderItemFailed": "Opplastning mislykkes",
|
"MessageUploaderItemFailed": "Opplastning mislykkes",
|
||||||
"MessageUploaderItemSuccess": "Opplastning fullført!",
|
"MessageUploaderItemSuccess": "Opplastning fullført!",
|
||||||
@@ -873,7 +904,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke",
|
"ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke",
|
||||||
"ToastBookmarkCreateSuccess": "Bokmerke lagt til",
|
"ToastBookmarkCreateSuccess": "Bokmerke lagt til",
|
||||||
"ToastBookmarkRemoveSuccess": "Bokmerke fjernet",
|
"ToastBookmarkRemoveSuccess": "Bokmerke fjernet",
|
||||||
"ToastBookmarkUpdateSuccess": "Bokmerke oppdatert",
|
|
||||||
"ToastCachePurgeFailed": "Kunne ikke å slette mellomlager",
|
"ToastCachePurgeFailed": "Kunne ikke å slette mellomlager",
|
||||||
"ToastCachePurgeSuccess": "Mellomlager slettet",
|
"ToastCachePurgeSuccess": "Mellomlager slettet",
|
||||||
"ToastChaptersHaveErrors": "Kapittel har feil",
|
"ToastChaptersHaveErrors": "Kapittel har feil",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"ButtonEditChapters": "Edytuj rozdziały",
|
"ButtonEditChapters": "Edytuj rozdziały",
|
||||||
"ButtonEditPodcast": "Edytuj podcast",
|
"ButtonEditPodcast": "Edytuj podcast",
|
||||||
"ButtonEnable": "Włącz",
|
"ButtonEnable": "Włącz",
|
||||||
|
"ButtonFireAndFail": "Fail start",
|
||||||
"ButtonForceReScan": "Wymuś ponowne skanowanie",
|
"ButtonForceReScan": "Wymuś ponowne skanowanie",
|
||||||
"ButtonFullPath": "Pełna ścieżka",
|
"ButtonFullPath": "Pełna ścieżka",
|
||||||
"ButtonHide": "Ukryj",
|
"ButtonHide": "Ukryj",
|
||||||
@@ -770,7 +771,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki",
|
"ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki",
|
||||||
"ToastBookmarkCreateSuccess": "Dodano zakładkę",
|
"ToastBookmarkCreateSuccess": "Dodano zakładkę",
|
||||||
"ToastBookmarkRemoveSuccess": "Zakładka została usunięta",
|
"ToastBookmarkRemoveSuccess": "Zakładka została usunięta",
|
||||||
"ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę",
|
|
||||||
"ToastCollectionRemoveSuccess": "Kolekcja usunięta",
|
"ToastCollectionRemoveSuccess": "Kolekcja usunięta",
|
||||||
"ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję",
|
"ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję",
|
||||||
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
|
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
|
||||||
|
|||||||
@@ -729,7 +729,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Falha ao criar marcador",
|
"ToastBookmarkCreateFailed": "Falha ao criar marcador",
|
||||||
"ToastBookmarkCreateSuccess": "Marcador adicionado",
|
"ToastBookmarkCreateSuccess": "Marcador adicionado",
|
||||||
"ToastBookmarkRemoveSuccess": "Marcador removido",
|
"ToastBookmarkRemoveSuccess": "Marcador removido",
|
||||||
"ToastBookmarkUpdateSuccess": "Marcador atualizado",
|
|
||||||
"ToastCachePurgeFailed": "Falha ao apagar o cache",
|
"ToastCachePurgeFailed": "Falha ao apagar o cache",
|
||||||
"ToastCachePurgeSuccess": "Cache apagado com sucesso",
|
"ToastCachePurgeSuccess": "Cache apagado com sucesso",
|
||||||
"ToastChaptersHaveErrors": "Capítulos com erro",
|
"ToastChaptersHaveErrors": "Capítulos com erro",
|
||||||
|
|||||||
@@ -348,7 +348,7 @@
|
|||||||
"LabelFetchingMetadata": "Извлечение метаданных",
|
"LabelFetchingMetadata": "Извлечение метаданных",
|
||||||
"LabelFile": "Файл",
|
"LabelFile": "Файл",
|
||||||
"LabelFileBirthtime": "Дата создания",
|
"LabelFileBirthtime": "Дата создания",
|
||||||
"LabelFileBornDate": "Родился {0}",
|
"LabelFileBornDate": "Создан {0}",
|
||||||
"LabelFileModified": "Дата модификации",
|
"LabelFileModified": "Дата модификации",
|
||||||
"LabelFileModifiedDate": "Изменено {0}",
|
"LabelFileModifiedDate": "Изменено {0}",
|
||||||
"LabelFilename": "Имя файла",
|
"LabelFilename": "Имя файла",
|
||||||
@@ -758,6 +758,7 @@
|
|||||||
"MessageConfirmResetProgress": "Вы уверены, что хотите сбросить свой прогресс?",
|
"MessageConfirmResetProgress": "Вы уверены, что хотите сбросить свой прогресс?",
|
||||||
"MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?",
|
||||||
"MessageConfirmUnlinkOpenId": "Вы уверены, что хотите отвязать этого пользователя от OpenID?",
|
"MessageConfirmUnlinkOpenId": "Вы уверены, что хотите отвязать этого пользователя от OpenID?",
|
||||||
|
"MessageDaysListenedInTheLastYear": "{0} дней прослушивания за последний год",
|
||||||
"MessageDownloadingEpisode": "Эпизод скачивается",
|
"MessageDownloadingEpisode": "Эпизод скачивается",
|
||||||
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
|
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
|
||||||
"MessageEmbedFailed": "Вставка не удалась!",
|
"MessageEmbedFailed": "Вставка не удалась!",
|
||||||
@@ -836,6 +837,7 @@
|
|||||||
"MessageResetChaptersConfirm": "Вы уверены, что хотите сбросить главы и отменить внесенные изменения?",
|
"MessageResetChaptersConfirm": "Вы уверены, что хотите сбросить главы и отменить внесенные изменения?",
|
||||||
"MessageRestoreBackupConfirm": "Вы уверены, что хотите восстановить резервную копию, созданную",
|
"MessageRestoreBackupConfirm": "Вы уверены, что хотите восстановить резервную копию, созданную",
|
||||||
"MessageRestoreBackupWarning": "Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.",
|
"MessageRestoreBackupWarning": "Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.",
|
||||||
|
"MessageScheduleLibraryScanNote": "Большинству пользователей рекомендуется отключить эту функцию и включить функцию просмотра папок. Программа просмотра папок автоматически обнаружит изменения в папках вашей библиотеки. Программа просмотра папок работает не для каждой файловой системы (например, NFS), поэтому вместо этого можно использовать запланированные проверки библиотеки.",
|
||||||
"MessageSearchResultsFor": "Результаты поиска для",
|
"MessageSearchResultsFor": "Результаты поиска для",
|
||||||
"MessageSelected": "{0} выбрано",
|
"MessageSelected": "{0} выбрано",
|
||||||
"MessageServerCouldNotBeReached": "Не удалось связаться с сервером",
|
"MessageServerCouldNotBeReached": "Не удалось связаться с сервером",
|
||||||
@@ -952,7 +954,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Не удалось создать закладку",
|
"ToastBookmarkCreateFailed": "Не удалось создать закладку",
|
||||||
"ToastBookmarkCreateSuccess": "Добавлена закладка",
|
"ToastBookmarkCreateSuccess": "Добавлена закладка",
|
||||||
"ToastBookmarkRemoveSuccess": "Закладка удалена",
|
"ToastBookmarkRemoveSuccess": "Закладка удалена",
|
||||||
"ToastBookmarkUpdateSuccess": "Закладка обновлена",
|
|
||||||
"ToastCachePurgeFailed": "Не удалось очистить кэш",
|
"ToastCachePurgeFailed": "Не удалось очистить кэш",
|
||||||
"ToastCachePurgeSuccess": "Кэш успешно очищен",
|
"ToastCachePurgeSuccess": "Кэш успешно очищен",
|
||||||
"ToastChaptersHaveErrors": "Главы имеют ошибки",
|
"ToastChaptersHaveErrors": "Главы имеют ошибки",
|
||||||
@@ -963,6 +964,7 @@
|
|||||||
"ToastCollectionRemoveSuccess": "Коллекция удалена",
|
"ToastCollectionRemoveSuccess": "Коллекция удалена",
|
||||||
"ToastCollectionUpdateSuccess": "Коллекция обновлена",
|
"ToastCollectionUpdateSuccess": "Коллекция обновлена",
|
||||||
"ToastCoverUpdateFailed": "Не удалось обновить обложку",
|
"ToastCoverUpdateFailed": "Не удалось обновить обложку",
|
||||||
|
"ToastDateTimeInvalidOrIncomplete": "Дата и время указаны неверно или не до конца",
|
||||||
"ToastDeleteFileFailed": "Не удалось удалить файл",
|
"ToastDeleteFileFailed": "Не удалось удалить файл",
|
||||||
"ToastDeleteFileSuccess": "Файл удален",
|
"ToastDeleteFileSuccess": "Файл удален",
|
||||||
"ToastDeviceAddFailed": "Не удалось добавить устройство",
|
"ToastDeviceAddFailed": "Не удалось добавить устройство",
|
||||||
@@ -1015,6 +1017,7 @@
|
|||||||
"ToastNewUserTagError": "Необходимо выбрать хотя бы один тег",
|
"ToastNewUserTagError": "Необходимо выбрать хотя бы один тег",
|
||||||
"ToastNewUserUsernameError": "Введите имя пользователя",
|
"ToastNewUserUsernameError": "Введите имя пользователя",
|
||||||
"ToastNoNewEpisodesFound": "Новых эпизодов не найдено",
|
"ToastNoNewEpisodesFound": "Новых эпизодов не найдено",
|
||||||
|
"ToastNoRSSFeed": "У подкаста нет RSS-канала",
|
||||||
"ToastNoUpdatesNecessary": "Обновления не требуются",
|
"ToastNoUpdatesNecessary": "Обновления не требуются",
|
||||||
"ToastNotificationCreateFailed": "Не удалось создать уведомление",
|
"ToastNotificationCreateFailed": "Не удалось создать уведомление",
|
||||||
"ToastNotificationDeleteFailed": "Не удалось удалить уведомление",
|
"ToastNotificationDeleteFailed": "Не удалось удалить уведомление",
|
||||||
|
|||||||
@@ -758,6 +758,7 @@
|
|||||||
"MessageConfirmResetProgress": "Ali ste prepričani, da želite ponastaviti svoj napredek?",
|
"MessageConfirmResetProgress": "Ali ste prepričani, da želite ponastaviti svoj napredek?",
|
||||||
"MessageConfirmSendEbookToDevice": "Ali ste prepričani, da želite poslati {0} e-knjigo \"{1}\" v napravo \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "Ali ste prepričani, da želite poslati {0} e-knjigo \"{1}\" v napravo \"{2}\"?",
|
||||||
"MessageConfirmUnlinkOpenId": "Ali ste prepričani, da želite prekiniti povezavo tega uporabnika z OpenID?",
|
"MessageConfirmUnlinkOpenId": "Ali ste prepričani, da želite prekiniti povezavo tega uporabnika z OpenID?",
|
||||||
|
"MessageDaysListenedInTheLastYear": "{0} dni poslušanja v zadnjem letu",
|
||||||
"MessageDownloadingEpisode": "Prenašam epizodo",
|
"MessageDownloadingEpisode": "Prenašam epizodo",
|
||||||
"MessageDragFilesIntoTrackOrder": "Povlecite datoteke v pravilen vrstni red posnetkov",
|
"MessageDragFilesIntoTrackOrder": "Povlecite datoteke v pravilen vrstni red posnetkov",
|
||||||
"MessageEmbedFailed": "Vdelava ni uspela!",
|
"MessageEmbedFailed": "Vdelava ni uspela!",
|
||||||
@@ -836,6 +837,7 @@
|
|||||||
"MessageResetChaptersConfirm": "Ali ste prepričani, da želite ponastaviti poglavja in razveljaviti spremembe, ki ste jih naredili?",
|
"MessageResetChaptersConfirm": "Ali ste prepričani, da želite ponastaviti poglavja in razveljaviti spremembe, ki ste jih naredili?",
|
||||||
"MessageRestoreBackupConfirm": "Ali ste prepričani, da želite obnoviti varnostno kopijo, ustvarjeno ob",
|
"MessageRestoreBackupConfirm": "Ali ste prepričani, da želite obnoviti varnostno kopijo, ustvarjeno ob",
|
||||||
"MessageRestoreBackupWarning": "Obnovitev varnostne kopije bo prepisala celotno zbirko podatkov, ki se nahaja v /config, in zajema slike v /metadata/items in /metadata/authors.<br /><br />Varnostne kopije ne spreminjajo nobenih datotek v mapah vaše knjižnice. Če ste omogočili nastavitve strežnika za shranjevanje naslovnic in metapodatkov v mapah vaše knjižnice, potem ti niso varnostno kopirani ali prepisani.<br /><br />Vsi odjemalci, ki uporabljajo vaš strežnik, bodo samodejno osveženi.",
|
"MessageRestoreBackupWarning": "Obnovitev varnostne kopije bo prepisala celotno zbirko podatkov, ki se nahaja v /config, in zajema slike v /metadata/items in /metadata/authors.<br /><br />Varnostne kopije ne spreminjajo nobenih datotek v mapah vaše knjižnice. Če ste omogočili nastavitve strežnika za shranjevanje naslovnic in metapodatkov v mapah vaše knjižnice, potem ti niso varnostno kopirani ali prepisani.<br /><br />Vsi odjemalci, ki uporabljajo vaš strežnik, bodo samodejno osveženi.",
|
||||||
|
"MessageScheduleLibraryScanNote": "Za večino uporabnikov je priporočljivo, da to funkcijo pustite onemogočeno in ohranite nastavitev pregledovalnika map omogočeno. Pregledovalnik map bo samodejno zaznal spremembe v mapah vaše knjižnice. Pregledovalnik map ne deluje za vse datotečne sisteme (na primer NFS), zato lahko namesto tega uporabite načrtovane preglede knjižnic.",
|
||||||
"MessageSearchResultsFor": "Rezultati iskanja za",
|
"MessageSearchResultsFor": "Rezultati iskanja za",
|
||||||
"MessageSelected": "{0} izbrano",
|
"MessageSelected": "{0} izbrano",
|
||||||
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
|
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
|
||||||
@@ -952,7 +954,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Zaznamka ni bilo mogoče ustvariti",
|
"ToastBookmarkCreateFailed": "Zaznamka ni bilo mogoče ustvariti",
|
||||||
"ToastBookmarkCreateSuccess": "Zaznamek dodan",
|
"ToastBookmarkCreateSuccess": "Zaznamek dodan",
|
||||||
"ToastBookmarkRemoveSuccess": "Zaznamek odstranjen",
|
"ToastBookmarkRemoveSuccess": "Zaznamek odstranjen",
|
||||||
"ToastBookmarkUpdateSuccess": "Zaznamek posodobljen",
|
|
||||||
"ToastCachePurgeFailed": "Čiščenje predpomnilnika ni uspelo",
|
"ToastCachePurgeFailed": "Čiščenje predpomnilnika ni uspelo",
|
||||||
"ToastCachePurgeSuccess": "Predpomnilnik je bil uspešno očiščen",
|
"ToastCachePurgeSuccess": "Predpomnilnik je bil uspešno očiščen",
|
||||||
"ToastChaptersHaveErrors": "Poglavja imajo napake",
|
"ToastChaptersHaveErrors": "Poglavja imajo napake",
|
||||||
@@ -963,6 +964,7 @@
|
|||||||
"ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena",
|
"ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena",
|
||||||
"ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena",
|
"ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena",
|
||||||
"ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela",
|
"ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela",
|
||||||
|
"ToastDateTimeInvalidOrIncomplete": "Datum in čas sta neveljavna ali nepopolna",
|
||||||
"ToastDeleteFileFailed": "Brisanje datoteke ni uspelo",
|
"ToastDeleteFileFailed": "Brisanje datoteke ni uspelo",
|
||||||
"ToastDeleteFileSuccess": "Datoteka je bila izbrisana",
|
"ToastDeleteFileSuccess": "Datoteka je bila izbrisana",
|
||||||
"ToastDeviceAddFailed": "Naprave ni bilo mogoče dodati",
|
"ToastDeviceAddFailed": "Naprave ni bilo mogoče dodati",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"ButtonChooseFiles": "Välj filer",
|
"ButtonChooseFiles": "Välj filer",
|
||||||
"ButtonClearFilter": "Rensa filter",
|
"ButtonClearFilter": "Rensa filter",
|
||||||
"ButtonCloseFeed": "Stäng flöde",
|
"ButtonCloseFeed": "Stäng flöde",
|
||||||
|
"ButtonCloseSession": "Stäng öppen session",
|
||||||
"ButtonCollections": "Samlingar",
|
"ButtonCollections": "Samlingar",
|
||||||
"ButtonConfigureScanner": "Konfigurera skanner",
|
"ButtonConfigureScanner": "Konfigurera skanner",
|
||||||
"ButtonCreate": "Skapa",
|
"ButtonCreate": "Skapa",
|
||||||
@@ -28,11 +29,14 @@
|
|||||||
"ButtonEdit": "Redigera",
|
"ButtonEdit": "Redigera",
|
||||||
"ButtonEditChapters": "Redigera kapitel",
|
"ButtonEditChapters": "Redigera kapitel",
|
||||||
"ButtonEditPodcast": "Redigera podcast",
|
"ButtonEditPodcast": "Redigera podcast",
|
||||||
|
"ButtonEnable": "Aktivera",
|
||||||
"ButtonForceReScan": "Tvinga omstart",
|
"ButtonForceReScan": "Tvinga omstart",
|
||||||
"ButtonFullPath": "Fullständig sökväg",
|
"ButtonFullPath": "Fullständig sökväg",
|
||||||
"ButtonHide": "Dölj",
|
"ButtonHide": "Dölj",
|
||||||
"ButtonHome": "Hem",
|
"ButtonHome": "Hem",
|
||||||
"ButtonIssues": "Problem",
|
"ButtonIssues": "Problem",
|
||||||
|
"ButtonJumpBackward": "Hoppa bakåt",
|
||||||
|
"ButtonJumpForward": "Hoppa framåt",
|
||||||
"ButtonLatest": "Senaste",
|
"ButtonLatest": "Senaste",
|
||||||
"ButtonLibrary": "Bibliotek",
|
"ButtonLibrary": "Bibliotek",
|
||||||
"ButtonLogout": "Logga ut",
|
"ButtonLogout": "Logga ut",
|
||||||
@@ -44,6 +48,7 @@
|
|||||||
"ButtonNevermind": "Glöm det",
|
"ButtonNevermind": "Glöm det",
|
||||||
"ButtonNext": "Nästa",
|
"ButtonNext": "Nästa",
|
||||||
"ButtonNextChapter": "Nästa kapitel",
|
"ButtonNextChapter": "Nästa kapitel",
|
||||||
|
"ButtonNextItemInQueue": "Nästa objekt i Kö",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Öppna flöde",
|
"ButtonOpenFeed": "Öppna flöde",
|
||||||
"ButtonOpenManager": "Öppna Manager",
|
"ButtonOpenManager": "Öppna Manager",
|
||||||
@@ -54,6 +59,7 @@
|
|||||||
"ButtonPlaylists": "Spellistor",
|
"ButtonPlaylists": "Spellistor",
|
||||||
"ButtonPrevious": "Föregående",
|
"ButtonPrevious": "Föregående",
|
||||||
"ButtonPreviousChapter": "Föregående kapitel",
|
"ButtonPreviousChapter": "Föregående kapitel",
|
||||||
|
"ButtonProbeAudioFile": "Analysera ljudfil",
|
||||||
"ButtonPurgeAllCache": "Rensa all cache",
|
"ButtonPurgeAllCache": "Rensa all cache",
|
||||||
"ButtonPurgeItemsCache": "Rensa föremåls-cache",
|
"ButtonPurgeItemsCache": "Rensa föremåls-cache",
|
||||||
"ButtonQueueAddItem": "Lägg till i kön",
|
"ButtonQueueAddItem": "Lägg till i kön",
|
||||||
@@ -701,7 +707,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
|
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
|
||||||
"ToastBookmarkCreateSuccess": "Bokmärket har adderats",
|
"ToastBookmarkCreateSuccess": "Bokmärket har adderats",
|
||||||
"ToastBookmarkRemoveSuccess": "Bokmärket har raderats",
|
"ToastBookmarkRemoveSuccess": "Bokmärket har raderats",
|
||||||
"ToastBookmarkUpdateSuccess": "Bokmärket har uppdaterats",
|
|
||||||
"ToastChaptersHaveErrors": "Kapitlen har fel",
|
"ToastChaptersHaveErrors": "Kapitlen har fel",
|
||||||
"ToastChaptersMustHaveTitles": "Kapitel måste ha titlar",
|
"ToastChaptersMustHaveTitles": "Kapitel måste ha titlar",
|
||||||
"ToastCollectionRemoveSuccess": "Samlingen har raderats",
|
"ToastCollectionRemoveSuccess": "Samlingen har raderats",
|
||||||
|
|||||||
@@ -758,6 +758,7 @@
|
|||||||
"MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?",
|
"MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?",
|
||||||
"MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?",
|
"MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?",
|
||||||
"MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?",
|
"MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?",
|
||||||
|
"MessageDaysListenedInTheLastYear": "{0} днів, прослуханих за останній рік",
|
||||||
"MessageDownloadingEpisode": "Завантаження епізоду",
|
"MessageDownloadingEpisode": "Завантаження епізоду",
|
||||||
"MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку",
|
"MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку",
|
||||||
"MessageEmbedFailed": "Не вдалося вбудувати!",
|
"MessageEmbedFailed": "Не вдалося вбудувати!",
|
||||||
@@ -836,6 +837,7 @@
|
|||||||
"MessageResetChaptersConfirm": "Ви дійсно бажаєте скинути глави та скасувати внесені зміни?",
|
"MessageResetChaptersConfirm": "Ви дійсно бажаєте скинути глави та скасувати внесені зміни?",
|
||||||
"MessageRestoreBackupConfirm": "Ви дійсно бажаєте відновити резервну копію від",
|
"MessageRestoreBackupConfirm": "Ви дійсно бажаєте відновити резервну копію від",
|
||||||
"MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних, розташовану в /config, і зображення обкладинок в /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють жодних файлів у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються..<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.",
|
"MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних, розташовану в /config, і зображення обкладинок в /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють жодних файлів у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються..<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.",
|
||||||
|
"MessageScheduleLibraryScanNote": "Для більшості користувачів рекомендується залишити цю функцію вимкненою та залишити параметр перегляду папок увімкненим. Засіб спостереження за папками автоматично виявить зміни в папках вашої бібліотеки. Засіб спостереження за папками не працює для кожної файлової системи (наприклад, NFS), тому замість нього можна використовувати сканування бібліотек за розкладом.",
|
||||||
"MessageSearchResultsFor": "Результати пошуку для",
|
"MessageSearchResultsFor": "Результати пошуку для",
|
||||||
"MessageSelected": "Вибрано: {0}",
|
"MessageSelected": "Вибрано: {0}",
|
||||||
"MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера",
|
"MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера",
|
||||||
@@ -952,7 +954,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Не вдалося створити закладку",
|
"ToastBookmarkCreateFailed": "Не вдалося створити закладку",
|
||||||
"ToastBookmarkCreateSuccess": "Закладку додано",
|
"ToastBookmarkCreateSuccess": "Закладку додано",
|
||||||
"ToastBookmarkRemoveSuccess": "Закладку видалено",
|
"ToastBookmarkRemoveSuccess": "Закладку видалено",
|
||||||
"ToastBookmarkUpdateSuccess": "Закладку оновлено",
|
|
||||||
"ToastCachePurgeFailed": "Не вдалося очистити кеш",
|
"ToastCachePurgeFailed": "Не вдалося очистити кеш",
|
||||||
"ToastCachePurgeSuccess": "Кеш очищено",
|
"ToastCachePurgeSuccess": "Кеш очищено",
|
||||||
"ToastChaptersHaveErrors": "Глави містять помилки",
|
"ToastChaptersHaveErrors": "Глави містять помилки",
|
||||||
@@ -963,6 +964,7 @@
|
|||||||
"ToastCollectionRemoveSuccess": "Добірку видалено",
|
"ToastCollectionRemoveSuccess": "Добірку видалено",
|
||||||
"ToastCollectionUpdateSuccess": "Добірку оновлено",
|
"ToastCollectionUpdateSuccess": "Добірку оновлено",
|
||||||
"ToastCoverUpdateFailed": "Не вдалося оновити обкладинку",
|
"ToastCoverUpdateFailed": "Не вдалося оновити обкладинку",
|
||||||
|
"ToastDateTimeInvalidOrIncomplete": "Дата й час недійсні або неповні",
|
||||||
"ToastDeleteFileFailed": "Не вдалося видалити файл",
|
"ToastDeleteFileFailed": "Не вдалося видалити файл",
|
||||||
"ToastDeleteFileSuccess": "Файл видалено",
|
"ToastDeleteFileSuccess": "Файл видалено",
|
||||||
"ToastDeviceAddFailed": "Не вдалося додати пристрій",
|
"ToastDeviceAddFailed": "Не вдалося додати пристрій",
|
||||||
@@ -1015,6 +1017,7 @@
|
|||||||
"ToastNewUserTagError": "Потрібно вибрати хоча б один тег",
|
"ToastNewUserTagError": "Потрібно вибрати хоча б один тег",
|
||||||
"ToastNewUserUsernameError": "Введіть ім'я користувача",
|
"ToastNewUserUsernameError": "Введіть ім'я користувача",
|
||||||
"ToastNoNewEpisodesFound": "Нових епізодів не знайдено",
|
"ToastNoNewEpisodesFound": "Нових епізодів не знайдено",
|
||||||
|
"ToastNoRSSFeed": "Подкаст не має RSS-канал",
|
||||||
"ToastNoUpdatesNecessary": "Оновлення не потрібні",
|
"ToastNoUpdatesNecessary": "Оновлення не потрібні",
|
||||||
"ToastNotificationCreateFailed": "Не вдалося створити сповіщення",
|
"ToastNotificationCreateFailed": "Не вдалося створити сповіщення",
|
||||||
"ToastNotificationDeleteFailed": "Не вдалося видалити сповіщення",
|
"ToastNotificationDeleteFailed": "Не вдалося видалити сповіщення",
|
||||||
|
|||||||
@@ -679,7 +679,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "Tạo đánh dấu thất bại",
|
"ToastBookmarkCreateFailed": "Tạo đánh dấu thất bại",
|
||||||
"ToastBookmarkCreateSuccess": "Đã thêm đánh dấu",
|
"ToastBookmarkCreateSuccess": "Đã thêm đánh dấu",
|
||||||
"ToastBookmarkRemoveSuccess": "Đánh dấu đã được xóa",
|
"ToastBookmarkRemoveSuccess": "Đánh dấu đã được xóa",
|
||||||
"ToastBookmarkUpdateSuccess": "Đánh dấu đã được cập nhật",
|
|
||||||
"ToastChaptersHaveErrors": "Các chương có lỗi",
|
"ToastChaptersHaveErrors": "Các chương có lỗi",
|
||||||
"ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề",
|
"ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề",
|
||||||
"ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa",
|
"ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa",
|
||||||
|
|||||||
@@ -950,7 +950,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "创建书签失败",
|
"ToastBookmarkCreateFailed": "创建书签失败",
|
||||||
"ToastBookmarkCreateSuccess": "书签已添加",
|
"ToastBookmarkCreateSuccess": "书签已添加",
|
||||||
"ToastBookmarkRemoveSuccess": "书签已删除",
|
"ToastBookmarkRemoveSuccess": "书签已删除",
|
||||||
"ToastBookmarkUpdateSuccess": "书签已更新",
|
|
||||||
"ToastCachePurgeFailed": "清除缓存失败",
|
"ToastCachePurgeFailed": "清除缓存失败",
|
||||||
"ToastCachePurgeSuccess": "缓存清除成功",
|
"ToastCachePurgeSuccess": "缓存清除成功",
|
||||||
"ToastChaptersHaveErrors": "章节有错误",
|
"ToastChaptersHaveErrors": "章节有错误",
|
||||||
|
|||||||
@@ -723,7 +723,6 @@
|
|||||||
"ToastBookmarkCreateFailed": "創建書簽失敗",
|
"ToastBookmarkCreateFailed": "創建書簽失敗",
|
||||||
"ToastBookmarkCreateSuccess": "書籤已新增",
|
"ToastBookmarkCreateSuccess": "書籤已新增",
|
||||||
"ToastBookmarkRemoveSuccess": "書籤已刪除",
|
"ToastBookmarkRemoveSuccess": "書籤已刪除",
|
||||||
"ToastBookmarkUpdateSuccess": "書籤已更新",
|
|
||||||
"ToastChaptersHaveErrors": "章節有錯誤",
|
"ToastChaptersHaveErrors": "章節有錯誤",
|
||||||
"ToastChaptersMustHaveTitles": "章節必須有標題",
|
"ToastChaptersMustHaveTitles": "章節必須有標題",
|
||||||
"ToastCollectionRemoveSuccess": "收藏夾已刪除",
|
"ToastCollectionRemoveSuccess": "收藏夾已刪除",
|
||||||
|
|||||||
@@ -1,3 +1,18 @@
|
|||||||
|
const optionDefinitions = [
|
||||||
|
{ name: 'config', alias: 'c', type: String },
|
||||||
|
{ name: 'metadata', alias: 'm', type: String },
|
||||||
|
{ name: 'port', alias: 'p', type: String },
|
||||||
|
{ name: 'host', alias: 'h', type: String },
|
||||||
|
{ name: 'source', alias: 's', type: String },
|
||||||
|
{ name: 'dev', alias: 'd', type: Boolean }
|
||||||
|
]
|
||||||
|
|
||||||
|
const commandLineArgs = require('./server/libs/commandLineArgs')
|
||||||
|
const options = commandLineArgs(optionDefinitions)
|
||||||
|
|
||||||
|
const Path = require('path')
|
||||||
|
process.env.NODE_ENV = options.dev ? 'development' : process.env.NODE_ENV || 'production'
|
||||||
|
|
||||||
const server = require('./server/Server')
|
const server = require('./server/Server')
|
||||||
global.appRoot = __dirname
|
global.appRoot = __dirname
|
||||||
|
|
||||||
@@ -17,14 +32,19 @@ if (isDev) {
|
|||||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const PORT = process.env.PORT || 80
|
const inputConfig = options.config ? Path.resolve(options.config) : null
|
||||||
const HOST = process.env.HOST
|
const inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
|
||||||
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
|
|
||||||
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
|
const PORT = options.port || process.env.PORT || 3333
|
||||||
const SOURCE = process.env.SOURCE || 'docker'
|
const HOST = options.host || process.env.HOST
|
||||||
|
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
|
||||||
|
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
|
||||||
|
const SOURCE = options.source || process.env.SOURCE || 'debian'
|
||||||
|
|
||||||
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || ''
|
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || ''
|
||||||
|
|
||||||
console.log('Config', CONFIG_PATH, METADATA_PATH)
|
console.log(`Running in ${process.env.NODE_ENV} mode.`)
|
||||||
|
console.log(`Options: CONFIG_PATH=${CONFIG_PATH}, METADATA_PATH=${METADATA_PATH}, PORT=${PORT}, HOST=${HOST}, SOURCE=${SOURCE}, ROUTER_BASE_PATH=${ROUTER_BASE_PATH}`)
|
||||||
|
|
||||||
const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH)
|
const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH)
|
||||||
Server.start()
|
Server.start()
|
||||||
|
|||||||
Generated
+1
-1
@@ -30,7 +30,7 @@
|
|||||||
"xml2js": "^0.5.0"
|
"xml2js": "^0.5.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"audiobookshelf": "prod.js"
|
"audiobookshelf": "index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"chai": "^4.3.10",
|
"chai": "^4.3.10",
|
||||||
|
|||||||
+4
-4
@@ -5,10 +5,10 @@
|
|||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon --watch server index.js",
|
"dev": "nodemon --watch server index.js -- --dev",
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"client": "cd client && npm ci && npm run generate",
|
"client": "cd client && npm ci && npm run generate",
|
||||||
"prod": "npm run client && npm ci && node prod.js",
|
"prod": "npm run client && npm ci && node index.js",
|
||||||
"build-win": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
"build-win": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
||||||
"build-linux": "build/linuxpackager",
|
"build-linux": "build/linuxpackager",
|
||||||
"docker": "docker buildx build --platform linux/amd64,linux/arm64 --push . -t advplyr/audiobookshelf",
|
"docker": "docker buildx build --platform linux/amd64,linux/arm64 --push . -t advplyr/audiobookshelf",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"test": "mocha",
|
"test": "mocha",
|
||||||
"coverage": "nyc mocha"
|
"coverage": "nyc mocha"
|
||||||
},
|
},
|
||||||
"bin": "prod.js",
|
"bin": "index.js",
|
||||||
"pkg": {
|
"pkg": {
|
||||||
"assets": [
|
"assets": [
|
||||||
"client/dist/**/*",
|
"client/dist/**/*",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"server/migrations/*.js"
|
"server/migrations/*.js"
|
||||||
],
|
],
|
||||||
"scripts": [
|
"scripts": [
|
||||||
"prod.js",
|
"index.js",
|
||||||
"server/**/*.js"
|
"server/**/*.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
|||||||
@@ -401,23 +401,6 @@ class Database {
|
|||||||
return this.models.setting.updateSettingObj(settings.toJSON())
|
return this.models.setting.updateSettingObj(settings.toJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Save metadata file and update library item
|
|
||||||
*
|
|
||||||
* @param {import('./objects/LibraryItem')} oldLibraryItem
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
async updateLibraryItem(oldLibraryItem) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
await oldLibraryItem.saveMetadata()
|
|
||||||
const updated = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
|
|
||||||
// Clear library filter data cache
|
|
||||||
if (updated) {
|
|
||||||
delete this.libraryFilterData[oldLibraryItem.libraryId]
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
getPlaybackSessions(where = null) {
|
getPlaybackSessions(where = null) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
return this.models.playbackSession.getOldPlaybackSessions(where)
|
return this.models.playbackSession.getOldPlaybackSessions(where)
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ class Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.PODCAST_DOWNLOAD_TIMEOUT) {
|
||||||
|
global.PodcastDownloadTimeout = process.env.PODCAST_DOWNLOAD_TIMEOUT
|
||||||
|
} else {
|
||||||
|
global.PodcastDownloadTimeout = 30000
|
||||||
|
}
|
||||||
|
|
||||||
if (!fs.pathExistsSync(global.ConfigPath)) {
|
if (!fs.pathExistsSync(global.ConfigPath)) {
|
||||||
fs.mkdirSync(global.ConfigPath)
|
fs.mkdirSync(global.ConfigPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,8 +242,18 @@ class AuthorController {
|
|||||||
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load library items so that metadata file can be updated
|
||||||
|
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
|
||||||
|
allItemsWithAuthor.forEach((libraryItem) => {
|
||||||
|
libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)
|
||||||
|
})
|
||||||
|
|
||||||
await req.author.destroy()
|
await req.author.destroy()
|
||||||
|
|
||||||
|
for (const libraryItem of allItemsWithAuthor) {
|
||||||
|
await libraryItem.saveMetadataFile()
|
||||||
|
}
|
||||||
|
|
||||||
SocketAuthority.emitter('author_removed', req.author.toOldJSON())
|
SocketAuthority.emitter('author_removed', req.author.toOldJSON())
|
||||||
|
|
||||||
// Update filter data
|
// Update filter data
|
||||||
|
|||||||
@@ -170,21 +170,34 @@ class LibraryController {
|
|||||||
* GET: /api/libraries
|
* GET: /api/libraries
|
||||||
* Get all libraries
|
* Get all libraries
|
||||||
*
|
*
|
||||||
|
* ?include=stats to load library stats - used in android auto to filter out libraries with no audio
|
||||||
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async findAll(req, res) {
|
async findAll(req, res) {
|
||||||
const libraries = await Database.libraryModel.getAllWithFolders()
|
let libraries = await Database.libraryModel.getAllWithFolders()
|
||||||
|
|
||||||
const librariesAccessible = req.user.permissions?.librariesAccessible || []
|
const librariesAccessible = req.user.permissions?.librariesAccessible || []
|
||||||
if (librariesAccessible.length) {
|
if (librariesAccessible.length) {
|
||||||
return res.json({
|
libraries = libraries.filter((lib) => librariesAccessible.includes(lib.id))
|
||||||
libraries: libraries.filter((lib) => librariesAccessible.includes(lib.id)).map((lib) => lib.toOldJSON())
|
}
|
||||||
})
|
|
||||||
|
libraries = libraries.map((lib) => lib.toOldJSON())
|
||||||
|
|
||||||
|
const includeArray = (req.query.include || '').split(',')
|
||||||
|
if (includeArray.includes('stats')) {
|
||||||
|
for (const library of libraries) {
|
||||||
|
if (library.mediaType === 'book') {
|
||||||
|
library.stats = await libraryItemsBookFilters.getBookLibraryStats(library.id)
|
||||||
|
} else if (library.mediaType === 'podcast') {
|
||||||
|
library.stats = await libraryItemsPodcastFilters.getPodcastLibraryStats(library.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
libraries: libraries.map((lib) => lib.toOldJSON())
|
libraries
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,31 +81,6 @@ class LibraryItemController {
|
|||||||
res.json(req.libraryItem.toOldJSON())
|
res.json(req.libraryItem.toOldJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH: /api/items/:id
|
|
||||||
*
|
|
||||||
* @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) {
|
|
||||||
// Item has cover and update is removing cover so purge it from cache
|
|
||||||
if (req.libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
|
|
||||||
await CacheManager.purgeCoverCache(req.libraryItem.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem)
|
|
||||||
const hasUpdates = oldLibraryItem.update(req.body)
|
|
||||||
if (hasUpdates) {
|
|
||||||
Logger.debug(`[LibraryItemController] Updated now saving`)
|
|
||||||
await Database.updateLibraryItem(oldLibraryItem)
|
|
||||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
|
||||||
}
|
|
||||||
res.json(oldLibraryItem.toJSON())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE: /api/items/:id
|
* DELETE: /api/items/:id
|
||||||
* Delete library item. Will delete from database and file system if hard delete is requested.
|
* Delete library item. Will delete from database and file system if hard delete is requested.
|
||||||
@@ -219,11 +194,6 @@ class LibraryItemController {
|
|||||||
if (res.writableEnded || res.headersSent) return
|
if (res.writableEnded || res.headersSent) return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Book specific
|
|
||||||
if (req.libraryItem.isBook) {
|
|
||||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, req.libraryItem.libraryId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Podcast specific
|
// Podcast specific
|
||||||
let isPodcastAutoDownloadUpdated = false
|
let isPodcastAutoDownloadUpdated = false
|
||||||
if (req.libraryItem.isPodcast) {
|
if (req.libraryItem.isPodcast) {
|
||||||
@@ -234,41 +204,56 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Book specific - Get all series being removed from this item
|
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
|
||||||
let seriesRemoved = []
|
|
||||||
if (req.libraryItem.isBook && mediaPayload.metadata?.series) {
|
if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
|
||||||
const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || []
|
const seriesUpdateData = await req.libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, req.libraryItem.libraryId)
|
||||||
seriesRemoved = req.libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
if (seriesUpdateData?.seriesRemoved.length) {
|
||||||
|
// Check remove empty series
|
||||||
|
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
|
||||||
|
await this.checkRemoveEmptySeries(seriesUpdateData.seriesRemoved.map((se) => se.id))
|
||||||
|
}
|
||||||
|
if (seriesUpdateData?.seriesAdded.length) {
|
||||||
|
// Add series to filter data
|
||||||
|
seriesUpdateData.seriesAdded.forEach((se) => {
|
||||||
|
Database.addSeriesToFilterData(req.libraryItem.libraryId, se.name, se.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (seriesUpdateData?.hasUpdates) {
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let authorsRemoved = []
|
if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {
|
||||||
if (req.libraryItem.isBook && mediaPayload.metadata?.authors) {
|
const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)
|
||||||
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
|
const authorUpdateData = await req.libraryItem.media.updateAuthorsFromRequest(authorNames, req.libraryItem.libraryId)
|
||||||
authorsRemoved = req.libraryItem.media.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
|
if (authorUpdateData?.authorsRemoved.length) {
|
||||||
|
// Check remove empty authors
|
||||||
|
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
|
||||||
|
await this.checkRemoveAuthorsWithNoBooks(authorUpdateData.authorsRemoved.map((au) => au.id))
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
if (authorUpdateData?.authorsAdded.length) {
|
||||||
|
// Add authors to filter data
|
||||||
|
authorUpdateData.authorsAdded.forEach((au) => {
|
||||||
|
Database.addAuthorToFilterData(req.libraryItem.libraryId, au.name, au.id)
|
||||||
|
})
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
|
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
req.libraryItem.changed('updatedAt', true)
|
req.libraryItem.changed('updatedAt', true)
|
||||||
await req.libraryItem.save()
|
await req.libraryItem.save()
|
||||||
|
|
||||||
|
await req.libraryItem.saveMetadataFile()
|
||||||
|
|
||||||
if (isPodcastAutoDownloadUpdated) {
|
if (isPodcastAutoDownloadUpdated) {
|
||||||
this.cronManager.checkUpdatePodcastCron(req.libraryItem)
|
this.cronManager.checkUpdatePodcastCron(req.libraryItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`)
|
Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`)
|
||||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||||
|
|
||||||
if (authorsRemoved.length) {
|
|
||||||
// Check remove empty authors
|
|
||||||
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
|
|
||||||
await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
|
|
||||||
}
|
|
||||||
if (seriesRemoved.length) {
|
|
||||||
// Check remove empty series
|
|
||||||
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
|
|
||||||
await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
res.json({
|
res.json({
|
||||||
updated: hasUpdates,
|
updated: hasUpdates,
|
||||||
@@ -281,7 +266,7 @@ class LibraryItemController {
|
|||||||
*
|
*
|
||||||
* @param {LibraryItemControllerRequest} req
|
* @param {LibraryItemControllerRequest} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
* @param {boolean} [updateAndReturnJson=true]
|
* @param {boolean} [updateAndReturnJson=true] - Allows the function to be used for both direct API calls and internally
|
||||||
*/
|
*/
|
||||||
async uploadCover(req, res, updateAndReturnJson = true) {
|
async uploadCover(req, res, updateAndReturnJson = true) {
|
||||||
if (!req.user.canUpload) {
|
if (!req.user.canUpload) {
|
||||||
@@ -306,11 +291,11 @@ class LibraryItemController {
|
|||||||
return res.status(500).send('Unknown error occurred')
|
return res.status(500).send('Unknown error occurred')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updateAndReturnJson) {
|
req.libraryItem.media.coverPath = result.cover
|
||||||
req.libraryItem.media.coverPath = result.cover
|
req.libraryItem.media.changed('coverPath', true)
|
||||||
req.libraryItem.media.changed('coverPath', true)
|
await req.libraryItem.media.save()
|
||||||
await req.libraryItem.media.save()
|
|
||||||
|
|
||||||
|
if (updateAndReturnJson) {
|
||||||
// client uses updatedAt timestamp in URL to force refresh cover
|
// client uses updatedAt timestamp in URL to force refresh cover
|
||||||
req.libraryItem.changed('updatedAt', true)
|
req.libraryItem.changed('updatedAt', true)
|
||||||
await req.libraryItem.save()
|
await req.libraryItem.save()
|
||||||
@@ -527,8 +512,7 @@ class LibraryItemController {
|
|||||||
options.overrideDetails = !!reqBody.overrideDetails
|
options.overrideDetails = !!reqBody.overrideDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem)
|
const matchResult = await Scanner.quickMatchLibraryItem(this, req.libraryItem, options)
|
||||||
var matchResult = await Scanner.quickMatchLibraryItem(this, oldLibraryItem, options)
|
|
||||||
res.json(matchResult)
|
res.json(matchResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,26 +624,44 @@ class LibraryItemController {
|
|||||||
const mediaPayload = updatePayload.mediaPayload
|
const mediaPayload = updatePayload.mediaPayload
|
||||||
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
|
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
|
||||||
|
|
||||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
|
||||||
|
|
||||||
if (libraryItem.isBook) {
|
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
|
||||||
if (Array.isArray(mediaPayload.metadata?.series)) {
|
const seriesUpdateData = await libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, libraryItem.libraryId)
|
||||||
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
|
if (seriesUpdateData?.seriesRemoved.length) {
|
||||||
const seriesRemoved = libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
seriesIdsRemoved.push(...seriesUpdateData.seriesRemoved.map((se) => se.id))
|
||||||
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
|
|
||||||
}
|
}
|
||||||
if (Array.isArray(mediaPayload.metadata?.authors)) {
|
if (seriesUpdateData?.seriesAdded.length) {
|
||||||
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
|
seriesUpdateData.seriesAdded.forEach((se) => {
|
||||||
const authorsRemoved = libraryItem.media.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
|
Database.addSeriesToFilterData(libraryItem.libraryId, se.name, se.id)
|
||||||
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
|
})
|
||||||
|
}
|
||||||
|
if (seriesUpdateData?.hasUpdates) {
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {
|
||||||
|
const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)
|
||||||
|
const authorUpdateData = await libraryItem.media.updateAuthorsFromRequest(authorNames, libraryItem.libraryId)
|
||||||
|
if (authorUpdateData?.authorsRemoved.length) {
|
||||||
|
authorIdsRemoved.push(...authorUpdateData.authorsRemoved.map((au) => au.id))
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
if (authorUpdateData?.authorsAdded.length) {
|
||||||
|
authorUpdateData.authorsAdded.forEach((au) => {
|
||||||
|
Database.addAuthorToFilterData(libraryItem.libraryId, au.name, au.id)
|
||||||
|
})
|
||||||
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
|
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
libraryItem.changed('updatedAt', true)
|
libraryItem.changed('updatedAt', true)
|
||||||
await libraryItem.save()
|
await libraryItem.save()
|
||||||
|
|
||||||
|
await libraryItem.saveMetadataFile()
|
||||||
|
|
||||||
Logger.debug(`[LibraryItemController] Updated library item media "${libraryItem.media.title}"`)
|
Logger.debug(`[LibraryItemController] Updated library item media "${libraryItem.media.title}"`)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||||
itemsUpdated++
|
itemsUpdated++
|
||||||
@@ -739,8 +741,7 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const libraryItem of libraryItems) {
|
for (const libraryItem of libraryItems) {
|
||||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
|
||||||
const matchResult = await Scanner.quickMatchLibraryItem(this, oldLibraryItem, options)
|
|
||||||
if (matchResult.updated) {
|
if (matchResult.updated) {
|
||||||
itemsUpdated++
|
itemsUpdated++
|
||||||
} else if (matchResult.warning) {
|
} else if (matchResult.warning) {
|
||||||
@@ -891,6 +892,8 @@ class LibraryItemController {
|
|||||||
req.libraryItem.media.changed('chapters', true)
|
req.libraryItem.media.changed('chapters', true)
|
||||||
await req.libraryItem.media.save()
|
await req.libraryItem.media.save()
|
||||||
|
|
||||||
|
await req.libraryItem.saveMetadataFile()
|
||||||
|
|
||||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -375,11 +375,9 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const overrideDetails = req.query.override === '1'
|
const overrideDetails = req.query.override === '1'
|
||||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem)
|
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
|
||||||
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(oldLibraryItem, { overrideDetails })
|
|
||||||
if (episodesUpdated) {
|
if (episodesUpdated) {
|
||||||
await Database.updateLibraryItem(oldLibraryItem)
|
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -463,6 +461,9 @@ class PodcastController {
|
|||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove it from the podcastEpisodes array
|
||||||
|
req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeId)
|
||||||
|
|
||||||
if (hardDelete) {
|
if (hardDelete) {
|
||||||
const audioFile = episode.audioFile
|
const audioFile = episode.audioFile
|
||||||
// TODO: this will trigger the watcher. should maybe handle this gracefully
|
// TODO: this will trigger the watcher. should maybe handle this gracefully
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class SearchController {
|
|||||||
*/
|
*/
|
||||||
async findBooks(req, res) {
|
async findBooks(req, res) {
|
||||||
const id = req.query.id
|
const id = req.query.id
|
||||||
const libraryItem = await Database.libraryItemModel.getOldById(id)
|
const libraryItem = await Database.libraryItemModel.getExpandedById(id)
|
||||||
const provider = req.query.provider || 'google'
|
const provider = req.query.provider || 'google'
|
||||||
const title = req.query.title || ''
|
const title = req.query.title || ''
|
||||||
const author = req.query.author || ''
|
const author = req.query.author || ''
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ const Database = require('../Database')
|
|||||||
* @property {import('../models/User')} user
|
* @property {import('../models/User')} user
|
||||||
*
|
*
|
||||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||||
|
*
|
||||||
|
* @typedef RequestEntityObject
|
||||||
|
* @property {import('../models/LibraryItem')} libraryItem
|
||||||
|
*
|
||||||
|
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class ToolsController {
|
class ToolsController {
|
||||||
@@ -18,7 +23,7 @@ class ToolsController {
|
|||||||
*
|
*
|
||||||
* @this import('../routers/ApiRouter')
|
* @this import('../routers/ApiRouter')
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithLibraryItem} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async encodeM4b(req, res) {
|
async encodeM4b(req, res) {
|
||||||
@@ -27,12 +32,12 @@ class ToolsController {
|
|||||||
return res.status(404).send('Audiobook not found')
|
return res.status(404).send('Audiobook not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.libraryItem.mediaType !== 'book') {
|
if (!req.libraryItem.isBook) {
|
||||||
Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)
|
Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)
|
||||||
return res.status(400).send('Invalid library item: not a book')
|
return res.status(400).send('Invalid library item: not a book')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.libraryItem.media.tracks.length <= 0) {
|
if (!req.libraryItem.hasAudioTracks) {
|
||||||
Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)
|
Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)
|
||||||
return res.status(400).send('Invalid audiobook: no audio tracks')
|
return res.status(400).send('Invalid audiobook: no audio tracks')
|
||||||
}
|
}
|
||||||
@@ -72,11 +77,11 @@ class ToolsController {
|
|||||||
*
|
*
|
||||||
* @this import('../routers/ApiRouter')
|
* @this import('../routers/ApiRouter')
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithLibraryItem} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async embedAudioFileMetadata(req, res) {
|
async embedAudioFileMetadata(req, res) {
|
||||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioTracks || !req.libraryItem.isBook) {
|
||||||
Logger.error(`[ToolsController] Invalid library item`)
|
Logger.error(`[ToolsController] Invalid library item`)
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
@@ -111,7 +116,7 @@ class ToolsController {
|
|||||||
|
|
||||||
const libraryItems = []
|
const libraryItems = []
|
||||||
for (const libraryItemId of libraryItemIds) {
|
for (const libraryItemId of libraryItemIds) {
|
||||||
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
|
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
|
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
@@ -123,7 +128,7 @@ class ToolsController {
|
|||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItem.isMissing || !libraryItem.hasAudioFiles || !libraryItem.isBook) {
|
if (libraryItem.isMissing || !libraryItem.hasAudioTracks || !libraryItem.isBook) {
|
||||||
Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`)
|
Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`)
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
@@ -157,7 +162,7 @@ class ToolsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.params.id) {
|
if (req.params.id) {
|
||||||
const item = await Database.libraryItemModel.getOldById(req.params.id)
|
const item = await Database.libraryItemModel.getExpandedById(req.params.id)
|
||||||
if (!item?.media) return res.sendStatus(404)
|
if (!item?.media) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check user can access this library item
|
// Check user can access this library item
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ class BookFinder {
|
|||||||
/**
|
/**
|
||||||
* Search for books including fuzzy searches
|
* Search for books including fuzzy searches
|
||||||
*
|
*
|
||||||
* @param {Object} libraryItem
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
* @param {string} provider
|
* @param {string} provider
|
||||||
* @param {string} title
|
* @param {string} title
|
||||||
* @param {string} author
|
* @param {string} author
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class AbMergeManager {
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
* @param {import('../objects/LibraryItem')} libraryItem
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
* @param {AbMergeEncodeOptions} [options={}]
|
* @param {AbMergeEncodeOptions} [options={}]
|
||||||
*/
|
*/
|
||||||
async startAudiobookMerge(userId, libraryItem, options = {}) {
|
async startAudiobookMerge(userId, libraryItem, options = {}) {
|
||||||
@@ -67,7 +67,7 @@ class AbMergeManager {
|
|||||||
libraryItemId: libraryItem.id,
|
libraryItemId: libraryItem.id,
|
||||||
libraryItemDir,
|
libraryItemDir,
|
||||||
userId,
|
userId,
|
||||||
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
|
originalTrackPaths: libraryItem.media.includedAudioFiles.map((t) => t.metadata.path),
|
||||||
inos: libraryItem.media.includedAudioFiles.map((f) => f.ino),
|
inos: libraryItem.media.includedAudioFiles.map((f) => f.ino),
|
||||||
tempFilepath,
|
tempFilepath,
|
||||||
targetFilename,
|
targetFilename,
|
||||||
@@ -86,9 +86,9 @@ class AbMergeManager {
|
|||||||
key: 'MessageTaskEncodingM4b'
|
key: 'MessageTaskEncodingM4b'
|
||||||
}
|
}
|
||||||
const taskDescriptionString = {
|
const taskDescriptionString = {
|
||||||
text: `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`,
|
text: `Encoding audiobook "${libraryItem.media.title}" into a single m4b file.`,
|
||||||
key: 'MessageTaskEncodingM4bDescription',
|
key: 'MessageTaskEncodingM4bDescription',
|
||||||
subs: [libraryItem.media.metadata.title]
|
subs: [libraryItem.media.title]
|
||||||
}
|
}
|
||||||
task.setData('encode-m4b', taskTitleString, taskDescriptionString, false, taskData)
|
task.setData('encode-m4b', taskTitleString, taskDescriptionString, false, taskData)
|
||||||
TaskManager.addTask(task)
|
TaskManager.addTask(task)
|
||||||
@@ -103,7 +103,7 @@ class AbMergeManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {import('../objects/LibraryItem')} libraryItem
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
* @param {Task} task
|
* @param {Task} task
|
||||||
* @param {AbMergeEncodeOptions} encodingOptions
|
* @param {AbMergeEncodeOptions} encodingOptions
|
||||||
*/
|
*/
|
||||||
@@ -141,7 +141,7 @@ class AbMergeManager {
|
|||||||
const embedFraction = 1 - encodeFraction
|
const embedFraction = 1 - encodeFraction
|
||||||
try {
|
try {
|
||||||
const trackProgressMonitor = new TrackProgressMonitor(
|
const trackProgressMonitor = new TrackProgressMonitor(
|
||||||
libraryItem.media.tracks.map((t) => t.duration),
|
libraryItem.media.includedAudioFiles.map((t) => t.duration),
|
||||||
(trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }),
|
(trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }),
|
||||||
(trackIndex, progressInTrack, taskProgress) => {
|
(trackIndex, progressInTrack, taskProgress) => {
|
||||||
SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack })
|
SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack })
|
||||||
@@ -150,7 +150,7 @@ class AbMergeManager {
|
|||||||
(trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] })
|
(trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] })
|
||||||
)
|
)
|
||||||
task.data.ffmpeg = new Ffmpeg()
|
task.data.ffmpeg = new Ffmpeg()
|
||||||
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.tracks, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
|
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.includedAudioFiles, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
|
||||||
delete task.data.ffmpeg
|
delete task.data.ffmpeg
|
||||||
trackProgressMonitor.finish()
|
trackProgressMonitor.finish()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class ApiCacheManager {
|
|||||||
Logger.debug(`[ApiCacheManager] Skipping cache for random sort`)
|
Logger.debug(`[ApiCacheManager] Skipping cache for random sort`)
|
||||||
return next()
|
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 key = { user: req.user.username, url: req.url }
|
||||||
const stringifiedKey = JSON.stringify(key)
|
const stringifiedKey = JSON.stringify(key)
|
||||||
Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`)
|
Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`)
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ class AudioMetadataMangaer {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
getMetadataObjectForApi(libraryItem) {
|
getMetadataObjectForApi(libraryItem) {
|
||||||
return ffmpegHelpers.getFFMetadataObject(libraryItem.toOldJSONExpanded(), libraryItem.media.includedAudioFiles.length)
|
return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
* @param {*} libraryItems
|
* @param {import('../models/LibraryItem')[]} libraryItems
|
||||||
* @param {*} options
|
* @param {UpdateMetadataOptions} options
|
||||||
*/
|
*/
|
||||||
handleBatchEmbed(userId, libraryItems, options = {}) {
|
handleBatchEmbed(userId, libraryItems, options = {}) {
|
||||||
libraryItems.forEach((li) => {
|
libraryItems.forEach((li) => {
|
||||||
@@ -58,7 +58,7 @@ class AudioMetadataMangaer {
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
* @param {import('../objects/LibraryItem')} libraryItem
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
* @param {UpdateMetadataOptions} [options={}]
|
* @param {UpdateMetadataOptions} [options={}]
|
||||||
*/
|
*/
|
||||||
async updateMetadataForItem(userId, libraryItem, options = {}) {
|
async updateMetadataForItem(userId, libraryItem, options = {}) {
|
||||||
@@ -108,14 +108,14 @@ class AudioMetadataMangaer {
|
|||||||
key: 'MessageTaskEmbeddingMetadata'
|
key: 'MessageTaskEmbeddingMetadata'
|
||||||
}
|
}
|
||||||
const taskDescriptionString = {
|
const taskDescriptionString = {
|
||||||
text: `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`,
|
text: `Embedding metadata in audiobook "${libraryItem.media.title}".`,
|
||||||
key: 'MessageTaskEmbeddingMetadataDescription',
|
key: 'MessageTaskEmbeddingMetadataDescription',
|
||||||
subs: [libraryItem.media.metadata.title]
|
subs: [libraryItem.media.title]
|
||||||
}
|
}
|
||||||
task.setData('embed-metadata', taskTitleString, taskDescriptionString, false, taskData)
|
task.setData('embed-metadata', taskTitleString, taskDescriptionString, false, taskData)
|
||||||
|
|
||||||
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
|
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
|
||||||
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`)
|
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.title}"`)
|
||||||
SocketAuthority.adminEmitter('metadata_embed_queue_update', {
|
SocketAuthority.adminEmitter('metadata_embed_queue_update', {
|
||||||
libraryItemId: libraryItem.id,
|
libraryItemId: libraryItem.id,
|
||||||
queued: true
|
queued: true
|
||||||
|
|||||||
@@ -123,61 +123,6 @@ class CoverManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @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
|
|
||||||
var coverDirPath = forceLibraryItemFolder ? libraryItem.path : this.getCoverDirectory(libraryItem)
|
|
||||||
await fs.ensureDir(coverDirPath)
|
|
||||||
|
|
||||||
var temppath = Path.posix.join(coverDirPath, 'cover')
|
|
||||||
|
|
||||||
let errorMsg = ''
|
|
||||||
let success = await downloadImageFile(url, temppath)
|
|
||||||
.then(() => true)
|
|
||||||
.catch((err) => {
|
|
||||||
errorMsg = err.message || 'Unknown error'
|
|
||||||
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (!success) {
|
|
||||||
return {
|
|
||||||
error: 'Failed to download image from url: ' + errorMsg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var imgtype = await this.checkFileIsValidImage(temppath, true)
|
|
||||||
|
|
||||||
if (imgtype.error) {
|
|
||||||
return imgtype
|
|
||||||
}
|
|
||||||
|
|
||||||
var coverFilename = `cover.${imgtype.ext}`
|
|
||||||
var coverFullPath = Path.posix.join(coverDirPath, coverFilename)
|
|
||||||
await fs.rename(temppath, coverFullPath)
|
|
||||||
|
|
||||||
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
|
|
||||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
|
||||||
|
|
||||||
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`)
|
|
||||||
libraryItem.updateMediaCover(coverFullPath)
|
|
||||||
return {
|
|
||||||
cover: coverFullPath
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error)
|
|
||||||
return {
|
|
||||||
error: 'Failed to fetch image from url'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} coverPath
|
* @param {string} coverPath
|
||||||
|
|||||||
@@ -343,20 +343,20 @@ class PlaybackSessionManager {
|
|||||||
* @param {import('../models/User')} user
|
* @param {import('../models/User')} user
|
||||||
* @param {*} session
|
* @param {*} session
|
||||||
* @param {*} syncData
|
* @param {*} syncData
|
||||||
* @returns
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async syncSession(user, session, syncData) {
|
async syncSession(user, session, syncData) {
|
||||||
// TODO: Combine libraryItem query with library query
|
// TODO: Combine libraryItem query with library query
|
||||||
const libraryItem = await Database.libraryItemModel.getExpandedById(session.libraryItemId)
|
const libraryItem = await Database.libraryItemModel.getExpandedById(session.libraryItemId)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
|
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
|
||||||
return null
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
|
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
Logger.error(`[PlaybackSessionManager] syncSession Library not found "${libraryItem.libraryId}"`)
|
Logger.error(`[PlaybackSessionManager] syncSession Library not found "${libraryItem.libraryId}"`)
|
||||||
return null
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
session.currentTime = syncData.currentTime
|
session.currentTime = syncData.currentTime
|
||||||
@@ -382,6 +382,8 @@ class PlaybackSessionManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
this.saveSession(session)
|
this.saveSession(session)
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -115,10 +115,24 @@ class PodcastManager {
|
|||||||
let success = false
|
let success = false
|
||||||
if (this.currentDownload.isMp3) {
|
if (this.currentDownload.isMp3) {
|
||||||
// Download episode and tag it
|
// Download episode and tag it
|
||||||
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
|
const ffmpegDownloadResponse = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
|
||||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||||
return false
|
|
||||||
})
|
})
|
||||||
|
success = !!ffmpegDownloadResponse?.success
|
||||||
|
|
||||||
|
// If failed due to ffmpeg error, retry without tagging
|
||||||
|
// e.g. RSS feed may have incorrect file extension and file type
|
||||||
|
// See https://github.com/advplyr/audiobookshelf/issues/3837
|
||||||
|
if (!success && ffmpegDownloadResponse?.isFfmpegError) {
|
||||||
|
Logger.info(`[PodcastManager] Retrying episode download without tagging`)
|
||||||
|
// Download episode only
|
||||||
|
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
|
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Download episode only
|
// Download episode only
|
||||||
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath)
|
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath)
|
||||||
|
|||||||
@@ -107,6 +107,22 @@ class Author extends Model {
|
|||||||
return libraryItems
|
return libraryItems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} libraryId
|
||||||
|
* @returns {Promise<Author>}
|
||||||
|
*/
|
||||||
|
static async findOrCreateByNameAndLibrary(name, libraryId) {
|
||||||
|
const author = await this.getByNameAndLibrary(name, libraryId)
|
||||||
|
if (author) return author
|
||||||
|
return this.create({
|
||||||
|
name,
|
||||||
|
lastFirst: this.getLastFirst(name),
|
||||||
|
libraryId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize model
|
* Initialize model
|
||||||
* @param {import('../Database').sequelize} sequelize
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
|||||||
+104
-164
@@ -130,130 +130,6 @@ class Book extends Model {
|
|||||||
this.series
|
this.series
|
||||||
}
|
}
|
||||||
|
|
||||||
static getOldBook(libraryItemExpanded) {
|
|
||||||
const bookExpanded = libraryItemExpanded.media
|
|
||||||
let authors = []
|
|
||||||
if (bookExpanded.authors?.length) {
|
|
||||||
authors = bookExpanded.authors.map((au) => {
|
|
||||||
return {
|
|
||||||
id: au.id,
|
|
||||||
name: au.name
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else if (bookExpanded.bookAuthors?.length) {
|
|
||||||
authors = bookExpanded.bookAuthors
|
|
||||||
.map((ba) => {
|
|
||||||
if (ba.author) {
|
|
||||||
return {
|
|
||||||
id: ba.author.id,
|
|
||||||
name: ba.author.name
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((a) => a)
|
|
||||||
}
|
|
||||||
|
|
||||||
let series = []
|
|
||||||
if (bookExpanded.series?.length) {
|
|
||||||
series = bookExpanded.series.map((se) => {
|
|
||||||
return {
|
|
||||||
id: se.id,
|
|
||||||
name: se.name,
|
|
||||||
sequence: se.bookSeries.sequence
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else if (bookExpanded.bookSeries?.length) {
|
|
||||||
series = bookExpanded.bookSeries
|
|
||||||
.map((bs) => {
|
|
||||||
if (bs.series) {
|
|
||||||
return {
|
|
||||||
id: bs.series.id,
|
|
||||||
name: bs.series.name,
|
|
||||||
sequence: bs.sequence
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((s) => s)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: bookExpanded.id,
|
|
||||||
libraryItemId: libraryItemExpanded.id,
|
|
||||||
coverPath: bookExpanded.coverPath,
|
|
||||||
tags: bookExpanded.tags,
|
|
||||||
audioFiles: bookExpanded.audioFiles,
|
|
||||||
chapters: bookExpanded.chapters,
|
|
||||||
ebookFile: bookExpanded.ebookFile,
|
|
||||||
metadata: {
|
|
||||||
title: bookExpanded.title,
|
|
||||||
subtitle: bookExpanded.subtitle,
|
|
||||||
authors: authors,
|
|
||||||
narrators: bookExpanded.narrators,
|
|
||||||
series: series,
|
|
||||||
genres: bookExpanded.genres,
|
|
||||||
publishedYear: bookExpanded.publishedYear,
|
|
||||||
publishedDate: bookExpanded.publishedDate,
|
|
||||||
publisher: bookExpanded.publisher,
|
|
||||||
description: bookExpanded.description,
|
|
||||||
isbn: bookExpanded.isbn,
|
|
||||||
asin: bookExpanded.asin,
|
|
||||||
language: bookExpanded.language,
|
|
||||||
explicit: bookExpanded.explicit,
|
|
||||||
abridged: bookExpanded.abridged
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {object} oldBook
|
|
||||||
* @returns {boolean} true if updated
|
|
||||||
*/
|
|
||||||
static saveFromOld(oldBook) {
|
|
||||||
const book = this.getFromOld(oldBook)
|
|
||||||
return this.update(book, {
|
|
||||||
where: {
|
|
||||||
id: book.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((result) => result[0] > 0)
|
|
||||||
.catch((error) => {
|
|
||||||
Logger.error(`[Book] Failed to save book ${book.id}`, error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
static getFromOld(oldBook) {
|
|
||||||
return {
|
|
||||||
id: oldBook.id,
|
|
||||||
title: oldBook.metadata.title,
|
|
||||||
titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix,
|
|
||||||
subtitle: oldBook.metadata.subtitle,
|
|
||||||
publishedYear: oldBook.metadata.publishedYear,
|
|
||||||
publishedDate: oldBook.metadata.publishedDate,
|
|
||||||
publisher: oldBook.metadata.publisher,
|
|
||||||
description: oldBook.metadata.description,
|
|
||||||
isbn: oldBook.metadata.isbn,
|
|
||||||
asin: oldBook.metadata.asin,
|
|
||||||
language: oldBook.metadata.language,
|
|
||||||
explicit: !!oldBook.metadata.explicit,
|
|
||||||
abridged: !!oldBook.metadata.abridged,
|
|
||||||
narrators: oldBook.metadata.narrators,
|
|
||||||
ebookFile: oldBook.ebookFile?.toJSON() || null,
|
|
||||||
coverPath: oldBook.coverPath,
|
|
||||||
duration: oldBook.duration,
|
|
||||||
audioFiles: oldBook.audioFiles?.map((af) => af.toJSON()) || [],
|
|
||||||
chapters: oldBook.chapters,
|
|
||||||
tags: oldBook.tags,
|
|
||||||
genres: oldBook.metadata.genres
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize model
|
* Initialize model
|
||||||
* @param {import('../Database').sequelize} sequelize
|
* @param {import('../Database').sequelize} sequelize
|
||||||
@@ -542,49 +418,113 @@ class Book extends Model {
|
|||||||
await this.save()
|
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
|
return hasUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates or removes authors from the book using the author names from the request
|
||||||
|
*
|
||||||
|
* @param {string[]} authors
|
||||||
|
* @param {string} libraryId
|
||||||
|
* @returns {Promise<{authorsRemoved: import('./Author')[], authorsAdded: import('./Author')[]}>}
|
||||||
|
*/
|
||||||
|
async updateAuthorsFromRequest(authors, libraryId) {
|
||||||
|
if (!Array.isArray(authors)) return null
|
||||||
|
|
||||||
|
if (!this.authors) {
|
||||||
|
throw new Error(`[Book] Cannot update authors because authors are not loaded for book ${this.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./Author')} */
|
||||||
|
const authorModel = this.sequelize.models.author
|
||||||
|
|
||||||
|
/** @type {typeof import('./BookAuthor')} */
|
||||||
|
const bookAuthorModel = this.sequelize.models.bookAuthor
|
||||||
|
|
||||||
|
const authorsCleaned = authors.map((a) => a.toLowerCase()).filter((a) => a)
|
||||||
|
const authorsRemoved = this.authors.filter((au) => !authorsCleaned.includes(au.name.toLowerCase()))
|
||||||
|
const newAuthorNames = authors.filter((a) => !this.authors.some((au) => au.name.toLowerCase() === a.toLowerCase()))
|
||||||
|
|
||||||
|
for (const author of authorsRemoved) {
|
||||||
|
await bookAuthorModel.removeByIds(author.id, this.id)
|
||||||
|
Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`)
|
||||||
|
this.authors = this.authors.filter((au) => au.id !== author.id)
|
||||||
|
}
|
||||||
|
const authorsAdded = []
|
||||||
|
for (const authorName of newAuthorNames) {
|
||||||
|
const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
|
||||||
|
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
|
||||||
|
Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`)
|
||||||
|
this.authors.push(author)
|
||||||
|
authorsAdded.push(author)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorsRemoved,
|
||||||
|
authorsAdded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates or removes series from the book using the series names from the request.
|
||||||
|
* Updates series sequence if it has changed.
|
||||||
|
*
|
||||||
|
* @param {{ name: string, sequence: string }[]} seriesObjects
|
||||||
|
* @param {string} libraryId
|
||||||
|
* @returns {Promise<{seriesRemoved: import('./Series')[], seriesAdded: import('./Series')[], hasUpdates: boolean}>}
|
||||||
|
*/
|
||||||
|
async updateSeriesFromRequest(seriesObjects, libraryId) {
|
||||||
|
if (!Array.isArray(seriesObjects) || seriesObjects.some((se) => !se.name || typeof se.name !== 'string')) return null
|
||||||
|
|
||||||
|
if (!this.series) {
|
||||||
|
throw new Error(`[Book] Cannot update series because series are not loaded for book ${this.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./Series')} */
|
||||||
|
const seriesModel = this.sequelize.models.series
|
||||||
|
|
||||||
|
/** @type {typeof import('./BookSeries')} */
|
||||||
|
const bookSeriesModel = this.sequelize.models.bookSeries
|
||||||
|
|
||||||
|
const seriesNamesCleaned = seriesObjects.map((se) => se.name.toLowerCase())
|
||||||
|
const seriesRemoved = this.series.filter((se) => !seriesNamesCleaned.includes(se.name.toLowerCase()))
|
||||||
|
const seriesAdded = []
|
||||||
|
let hasUpdates = false
|
||||||
|
for (const seriesObj of seriesObjects) {
|
||||||
|
const seriesObjSequence = typeof seriesObj.sequence === 'string' ? seriesObj.sequence : null
|
||||||
|
|
||||||
|
const existingSeries = this.series.find((se) => se.name.toLowerCase() === seriesObj.name.toLowerCase())
|
||||||
|
if (existingSeries) {
|
||||||
|
if (existingSeries.bookSeries.sequence !== seriesObjSequence) {
|
||||||
|
existingSeries.bookSeries.sequence = seriesObjSequence
|
||||||
|
await existingSeries.bookSeries.save()
|
||||||
|
hasUpdates = true
|
||||||
|
Logger.debug(`[Book] "${this.title}" Updated series "${existingSeries.name}" sequence ${seriesObjSequence}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const series = await seriesModel.findOrCreateByNameAndLibrary(seriesObj.name, libraryId)
|
||||||
|
series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence })
|
||||||
|
this.series.push(series)
|
||||||
|
seriesAdded.push(series)
|
||||||
|
hasUpdates = true
|
||||||
|
Logger.debug(`[Book] "${this.title}" Added series "${series.name}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const series of seriesRemoved) {
|
||||||
|
await bookSeriesModel.removeByIds(series.id, this.id)
|
||||||
|
this.series = this.series.filter((se) => se.id !== series.id)
|
||||||
|
Logger.debug(`[Book] "${this.title}" Removed series ${series.id}`)
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
seriesRemoved,
|
||||||
|
seriesAdded,
|
||||||
|
hasUpdates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Old model kept metadata in a separate object
|
* Old model kept metadata in a separate object
|
||||||
*/
|
*/
|
||||||
|
|||||||
+29
-359
@@ -1,11 +1,8 @@
|
|||||||
const util = require('util')
|
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
const fsExtra = require('../libs/fsExtra')
|
const fsExtra = require('../libs/fsExtra')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const oldLibraryItem = require('../objects/LibraryItem')
|
|
||||||
const libraryFilters = require('../utils/queries/libraryFilters')
|
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||||
const { areEquivalent } = require('../utils/index')
|
|
||||||
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
||||||
const LibraryFile = require('../objects/files/LibraryFile')
|
const LibraryFile = require('../objects/files/LibraryFile')
|
||||||
const Book = require('./Book')
|
const Book = require('./Book')
|
||||||
@@ -122,244 +119,6 @@ class LibraryItem extends Model {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an expanded LibraryItem into an old library item
|
|
||||||
*
|
|
||||||
* @param {Model<LibraryItem>} libraryItemExpanded
|
|
||||||
* @returns {oldLibraryItem}
|
|
||||||
*/
|
|
||||||
static getOldLibraryItem(libraryItemExpanded) {
|
|
||||||
let media = null
|
|
||||||
if (libraryItemExpanded.mediaType === 'book') {
|
|
||||||
media = this.sequelize.models.book.getOldBook(libraryItemExpanded)
|
|
||||||
} else if (libraryItemExpanded.mediaType === 'podcast') {
|
|
||||||
media = this.sequelize.models.podcast.getOldPodcast(libraryItemExpanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new oldLibraryItem({
|
|
||||||
id: libraryItemExpanded.id,
|
|
||||||
ino: libraryItemExpanded.ino,
|
|
||||||
oldLibraryItemId: libraryItemExpanded.extraData?.oldLibraryItemId || null,
|
|
||||||
libraryId: libraryItemExpanded.libraryId,
|
|
||||||
folderId: libraryItemExpanded.libraryFolderId,
|
|
||||||
path: libraryItemExpanded.path,
|
|
||||||
relPath: libraryItemExpanded.relPath,
|
|
||||||
isFile: libraryItemExpanded.isFile,
|
|
||||||
mtimeMs: libraryItemExpanded.mtime?.valueOf(),
|
|
||||||
ctimeMs: libraryItemExpanded.ctime?.valueOf(),
|
|
||||||
birthtimeMs: libraryItemExpanded.birthtime?.valueOf(),
|
|
||||||
addedAt: libraryItemExpanded.createdAt.valueOf(),
|
|
||||||
updatedAt: libraryItemExpanded.updatedAt.valueOf(),
|
|
||||||
lastScan: libraryItemExpanded.lastScan?.valueOf(),
|
|
||||||
scanVersion: libraryItemExpanded.lastScanVersion,
|
|
||||||
isMissing: !!libraryItemExpanded.isMissing,
|
|
||||||
isInvalid: !!libraryItemExpanded.isInvalid,
|
|
||||||
mediaType: libraryItemExpanded.mediaType,
|
|
||||||
media,
|
|
||||||
libraryFiles: libraryItemExpanded.libraryFiles
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates libraryItem, book, authors and series from old library item
|
|
||||||
*
|
|
||||||
* @param {oldLibraryItem} oldLibraryItem
|
|
||||||
* @returns {Promise<boolean>} true if updates were made
|
|
||||||
*/
|
|
||||||
static async fullUpdateFromOld(oldLibraryItem) {
|
|
||||||
const libraryItemExpanded = await this.getExpandedById(oldLibraryItem.id)
|
|
||||||
if (!libraryItemExpanded) return false
|
|
||||||
|
|
||||||
let hasUpdates = false
|
|
||||||
|
|
||||||
// Check update Book/Podcast
|
|
||||||
if (libraryItemExpanded.media) {
|
|
||||||
let updatedMedia = null
|
|
||||||
if (libraryItemExpanded.mediaType === 'podcast') {
|
|
||||||
updatedMedia = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media)
|
|
||||||
|
|
||||||
const existingPodcastEpisodes = libraryItemExpanded.media.podcastEpisodes || []
|
|
||||||
const updatedPodcastEpisodes = oldLibraryItem.media.episodes || []
|
|
||||||
|
|
||||||
for (const existingPodcastEpisode of existingPodcastEpisodes) {
|
|
||||||
// Episode was removed
|
|
||||||
if (!updatedPodcastEpisodes.some((ep) => ep.id === existingPodcastEpisode.id)) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`)
|
|
||||||
await existingPodcastEpisode.destroy()
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const updatedPodcastEpisode of updatedPodcastEpisodes) {
|
|
||||||
const existingEpisodeMatch = existingPodcastEpisodes.find((ep) => ep.id === updatedPodcastEpisode.id)
|
|
||||||
if (!existingEpisodeMatch) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`)
|
|
||||||
await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode)
|
|
||||||
hasUpdates = true
|
|
||||||
} else {
|
|
||||||
const updatedEpisodeCleaned = this.sequelize.models.podcastEpisode.getFromOld(updatedPodcastEpisode)
|
|
||||||
let episodeHasUpdates = false
|
|
||||||
for (const key in updatedEpisodeCleaned) {
|
|
||||||
let existingValue = existingEpisodeMatch[key]
|
|
||||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
|
||||||
|
|
||||||
if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) {
|
|
||||||
Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from %j to %j`, existingValue, updatedEpisodeCleaned[key]))
|
|
||||||
episodeHasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (episodeHasUpdates) {
|
|
||||||
await existingEpisodeMatch.update(updatedEpisodeCleaned)
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (libraryItemExpanded.mediaType === 'book') {
|
|
||||||
updatedMedia = this.sequelize.models.book.getFromOld(oldLibraryItem.media)
|
|
||||||
|
|
||||||
const existingAuthors = libraryItemExpanded.media.authors || []
|
|
||||||
const existingSeriesAll = libraryItemExpanded.media.series || []
|
|
||||||
const updatedAuthors = oldLibraryItem.media.metadata.authors || []
|
|
||||||
const uniqueUpdatedAuthors = updatedAuthors.filter((au, idx) => updatedAuthors.findIndex((a) => a.id === au.id) === idx)
|
|
||||||
const updatedSeriesAll = oldLibraryItem.media.metadata.series || []
|
|
||||||
|
|
||||||
for (const existingAuthor of existingAuthors) {
|
|
||||||
// Author was removed from Book
|
|
||||||
if (!uniqueUpdatedAuthors.some((au) => au.id === existingAuthor.id)) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`)
|
|
||||||
await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id)
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const updatedAuthor of uniqueUpdatedAuthors) {
|
|
||||||
// Author was added
|
|
||||||
if (!existingAuthors.some((au) => au.id === updatedAuthor.id)) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`)
|
|
||||||
await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id })
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const existingSeries of existingSeriesAll) {
|
|
||||||
// Series was removed
|
|
||||||
if (!updatedSeriesAll.some((se) => se.id === existingSeries.id)) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`)
|
|
||||||
await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id)
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const updatedSeries of updatedSeriesAll) {
|
|
||||||
// Series was added/updated
|
|
||||||
const existingSeriesMatch = existingSeriesAll.find((se) => se.id === updatedSeries.id)
|
|
||||||
if (!existingSeriesMatch) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`)
|
|
||||||
await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence })
|
|
||||||
hasUpdates = true
|
|
||||||
} else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`)
|
|
||||||
await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence })
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasMediaUpdates = false
|
|
||||||
for (const key in updatedMedia) {
|
|
||||||
let existingValue = libraryItemExpanded.media[key]
|
|
||||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
|
||||||
|
|
||||||
if (!areEquivalent(updatedMedia[key], existingValue, true)) {
|
|
||||||
if (key === 'chapters') {
|
|
||||||
// Handle logging of chapters separately because the object is large
|
|
||||||
const chaptersRemoved = libraryItemExpanded.media.chapters.filter((ch) => !updatedMedia.chapters.some((uch) => uch.id === ch.id))
|
|
||||||
if (chaptersRemoved.length) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters removed: ${chaptersRemoved.map((ch) => ch.title).join(', ')}`)
|
|
||||||
}
|
|
||||||
const chaptersAdded = updatedMedia.chapters.filter((uch) => !libraryItemExpanded.media.chapters.some((ch) => ch.id === uch.id))
|
|
||||||
if (chaptersAdded.length) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters added: ${chaptersAdded.map((ch) => ch.title).join(', ')}`)
|
|
||||||
}
|
|
||||||
if (!chaptersRemoved.length && !chaptersAdded.length) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters updated`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from %j to %j`, existingValue, updatedMedia[key]))
|
|
||||||
}
|
|
||||||
|
|
||||||
hasMediaUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasMediaUpdates && updatedMedia) {
|
|
||||||
await libraryItemExpanded.media.update(updatedMedia)
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedLibraryItem = this.getFromOld(oldLibraryItem)
|
|
||||||
let hasLibraryItemUpdates = false
|
|
||||||
for (const key in updatedLibraryItem) {
|
|
||||||
let existingValue = libraryItemExpanded[key]
|
|
||||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
|
||||||
|
|
||||||
if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) {
|
|
||||||
if (key === 'libraryFiles') {
|
|
||||||
// Handle logging of libraryFiles separately because the object is large (should be addressed when migrating off the old library item model)
|
|
||||||
const libraryFilesRemoved = libraryItemExpanded.libraryFiles.filter((lf) => !updatedLibraryItem.libraryFiles.some((ulf) => ulf.ino === lf.ino))
|
|
||||||
if (libraryFilesRemoved.length) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files removed: ${libraryFilesRemoved.map((lf) => lf.metadata.path).join(', ')}`)
|
|
||||||
}
|
|
||||||
const libraryFilesAdded = updatedLibraryItem.libraryFiles.filter((ulf) => !libraryItemExpanded.libraryFiles.some((lf) => lf.ino === ulf.ino))
|
|
||||||
if (libraryFilesAdded.length) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files added: ${libraryFilesAdded.map((lf) => lf.metadata.path).join(', ')}`)
|
|
||||||
}
|
|
||||||
if (!libraryFilesRemoved.length && !libraryFilesAdded.length) {
|
|
||||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files updated`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from %j to %j`, existingValue, updatedLibraryItem[key]))
|
|
||||||
}
|
|
||||||
|
|
||||||
hasLibraryItemUpdates = true
|
|
||||||
if (key === 'updatedAt') {
|
|
||||||
libraryItemExpanded.changed('updatedAt', true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasLibraryItemUpdates) {
|
|
||||||
await libraryItemExpanded.update(updatedLibraryItem)
|
|
||||||
Logger.info(`[LibraryItem] Library item "${libraryItemExpanded.id}" updated`)
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
return hasUpdates
|
|
||||||
}
|
|
||||||
|
|
||||||
static getFromOld(oldLibraryItem) {
|
|
||||||
const extraData = {}
|
|
||||||
if (oldLibraryItem.oldLibraryItemId) {
|
|
||||||
extraData.oldLibraryItemId = oldLibraryItem.oldLibraryItemId
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: oldLibraryItem.id,
|
|
||||||
ino: oldLibraryItem.ino,
|
|
||||||
path: oldLibraryItem.path,
|
|
||||||
relPath: oldLibraryItem.relPath,
|
|
||||||
mediaId: oldLibraryItem.media.id,
|
|
||||||
mediaType: oldLibraryItem.mediaType,
|
|
||||||
isFile: !!oldLibraryItem.isFile,
|
|
||||||
isMissing: !!oldLibraryItem.isMissing,
|
|
||||||
isInvalid: !!oldLibraryItem.isInvalid,
|
|
||||||
mtime: oldLibraryItem.mtimeMs,
|
|
||||||
ctime: oldLibraryItem.ctimeMs,
|
|
||||||
updatedAt: oldLibraryItem.updatedAt,
|
|
||||||
birthtime: oldLibraryItem.birthtimeMs,
|
|
||||||
size: oldLibraryItem.size,
|
|
||||||
lastScan: oldLibraryItem.lastScan,
|
|
||||||
lastScanVersion: oldLibraryItem.scanVersion,
|
|
||||||
libraryId: oldLibraryItem.libraryId,
|
|
||||||
libraryFolderId: oldLibraryItem.folderId,
|
|
||||||
libraryFiles: oldLibraryItem.libraryFiles?.map((lf) => lf.toJSON()) || [],
|
|
||||||
extraData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove library item by id
|
* Remove library item by id
|
||||||
*
|
*
|
||||||
@@ -468,12 +227,14 @@ class LibraryItem extends Model {
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {import('sequelize').WhereOptions} where
|
* @param {import('sequelize').WhereOptions} where
|
||||||
|
* @param {import('sequelize').BindOrReplacements} [replacements]
|
||||||
* @param {import('sequelize').IncludeOptions} [include]
|
* @param {import('sequelize').IncludeOptions} [include]
|
||||||
* @returns {Promise<LibraryItemExpanded>}
|
* @returns {Promise<LibraryItemExpanded>}
|
||||||
*/
|
*/
|
||||||
static async findOneExpanded(where, include = null) {
|
static async findOneExpanded(where, replacements = null, include = null) {
|
||||||
const libraryItem = await this.findOne({
|
const libraryItem = await this.findOne({
|
||||||
where,
|
where,
|
||||||
|
replacements,
|
||||||
include
|
include
|
||||||
})
|
})
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
@@ -516,61 +277,12 @@ class LibraryItem extends Model {
|
|||||||
return libraryItem
|
return libraryItem
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get old library item by id
|
|
||||||
* @param {string} libraryItemId
|
|
||||||
* @returns {oldLibraryItem}
|
|
||||||
*/
|
|
||||||
static async getOldById(libraryItemId) {
|
|
||||||
if (!libraryItemId) return null
|
|
||||||
|
|
||||||
const libraryItem = await this.findByPk(libraryItemId)
|
|
||||||
if (!libraryItem) {
|
|
||||||
Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`)
|
|
||||||
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: ['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 this.getOldLibraryItem(libraryItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get library items using filter and sort
|
* Get library items using filter and sort
|
||||||
* @param {import('./Library')} library
|
* @param {import('./Library')} library
|
||||||
* @param {import('./User')} user
|
* @param {import('./User')} user
|
||||||
* @param {object} options
|
* @param {object} options
|
||||||
* @returns {{ libraryItems:oldLibraryItem[], count:number }}
|
* @returns {{ libraryItems:Object[], count:number }}
|
||||||
*/
|
*/
|
||||||
static async getByFilterAndSort(library, user, options) {
|
static async getByFilterAndSort(library, user, options) {
|
||||||
let start = Date.now()
|
let start = Date.now()
|
||||||
@@ -624,17 +336,19 @@ class LibraryItem extends Model {
|
|||||||
// "Continue Listening" shelf
|
// "Continue Listening" shelf
|
||||||
const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false)
|
const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false)
|
||||||
if (itemsInProgressPayload.items.length) {
|
if (itemsInProgressPayload.items.length) {
|
||||||
const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.isEBookOnly)
|
const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)
|
||||||
const audioOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => !li.media.isEBookOnly)
|
const audioItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')
|
||||||
|
|
||||||
shelves.push({
|
if (audioItemsInProgress.length) {
|
||||||
id: 'continue-listening',
|
shelves.push({
|
||||||
label: 'Continue Listening',
|
id: 'continue-listening',
|
||||||
labelStringKey: 'LabelContinueListening',
|
label: 'Continue Listening',
|
||||||
type: library.isPodcast ? 'episode' : 'book',
|
labelStringKey: 'LabelContinueListening',
|
||||||
entities: audioOnlyItemsInProgress,
|
type: library.isPodcast ? 'episode' : 'book',
|
||||||
total: itemsInProgressPayload.count
|
entities: audioItemsInProgress,
|
||||||
})
|
total: itemsInProgressPayload.count
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (ebookOnlyItemsInProgress.length) {
|
if (ebookOnlyItemsInProgress.length) {
|
||||||
// "Continue Reading" shelf
|
// "Continue Reading" shelf
|
||||||
@@ -733,17 +447,19 @@ class LibraryItem extends Model {
|
|||||||
// "Listen Again" shelf
|
// "Listen Again" shelf
|
||||||
const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit)
|
const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit)
|
||||||
if (mediaFinishedPayload.items.length) {
|
if (mediaFinishedPayload.items.length) {
|
||||||
const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.isEBookOnly)
|
const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)
|
||||||
const audioOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => !li.media.isEBookOnly)
|
const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')
|
||||||
|
|
||||||
shelves.push({
|
if (audioItemsInProgress.length) {
|
||||||
id: 'listen-again',
|
shelves.push({
|
||||||
label: 'Listen Again',
|
id: 'listen-again',
|
||||||
labelStringKey: 'LabelListenAgain',
|
label: 'Listen Again',
|
||||||
type: library.isPodcast ? 'episode' : 'book',
|
labelStringKey: 'LabelListenAgain',
|
||||||
entities: audioOnlyItemsInProgress,
|
type: library.isPodcast ? 'episode' : 'book',
|
||||||
total: mediaFinishedPayload.count
|
entities: audioItemsInProgress,
|
||||||
})
|
total: mediaFinishedPayload.count
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// "Read Again" shelf
|
// "Read Again" shelf
|
||||||
if (ebookOnlyItemsInProgress.length) {
|
if (ebookOnlyItemsInProgress.length) {
|
||||||
@@ -801,52 +517,6 @@ class LibraryItem extends Model {
|
|||||||
return (await this.count({ where: { id: libraryItemId } })) > 0
|
return (await this.count({ where: { id: libraryItemId } })) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {import('sequelize').WhereOptions} where
|
|
||||||
* @param {import('sequelize').BindOrReplacements} replacements
|
|
||||||
* @returns {Object} oldLibraryItem
|
|
||||||
*/
|
|
||||||
static async findOneOld(where, replacements = {}) {
|
|
||||||
const libraryItem = await this.findOne({
|
|
||||||
where,
|
|
||||||
replacements,
|
|
||||||
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: [
|
|
||||||
[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']
|
|
||||||
]
|
|
||||||
})
|
|
||||||
if (!libraryItem) return null
|
|
||||||
return this.getOldLibraryItem(libraryItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} libraryItemId
|
* @param {string} libraryItemId
|
||||||
@@ -970,7 +640,7 @@ class LibraryItem extends Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug(`Success saving abmetadata to "${metadataFilePath}"`)
|
Logger.debug(`[LibraryItem] Saved metadata for "${this.media.title}" file to "${metadataFilePath}"`)
|
||||||
|
|
||||||
return metadataLibraryFile
|
return metadataLibraryFile
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -87,13 +87,10 @@ class MediaItemShare extends Model {
|
|||||||
const libraryItemModel = this.sequelize.models.libraryItem
|
const libraryItemModel = this.sequelize.models.libraryItem
|
||||||
|
|
||||||
if (mediaItemType === 'book') {
|
if (mediaItemType === 'book') {
|
||||||
const libraryItem = await libraryItemModel.findOneExpanded(
|
const libraryItem = await libraryItemModel.findOneExpanded({ mediaId: mediaItemId }, null, {
|
||||||
{ mediaId: mediaItemId },
|
model: this.sequelize.models.library,
|
||||||
{
|
attributes: ['settings']
|
||||||
model: this.sequelize.models.library,
|
})
|
||||||
attributes: ['settings']
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return libraryItem
|
return libraryItem
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,33 +36,6 @@ class MediaProgress extends Model {
|
|||||||
this.createdAt
|
this.createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
static upsertFromOld(oldMediaProgress) {
|
|
||||||
const mediaProgress = this.getFromOld(oldMediaProgress)
|
|
||||||
return this.upsert(mediaProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
static getFromOld(oldMediaProgress) {
|
|
||||||
return {
|
|
||||||
id: oldMediaProgress.id,
|
|
||||||
userId: oldMediaProgress.userId,
|
|
||||||
mediaItemId: oldMediaProgress.mediaItemId,
|
|
||||||
mediaItemType: oldMediaProgress.mediaItemType,
|
|
||||||
duration: oldMediaProgress.duration,
|
|
||||||
currentTime: oldMediaProgress.currentTime,
|
|
||||||
ebookLocation: oldMediaProgress.ebookLocation || null,
|
|
||||||
ebookProgress: oldMediaProgress.ebookProgress || null,
|
|
||||||
isFinished: !!oldMediaProgress.isFinished,
|
|
||||||
hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening,
|
|
||||||
finishedAt: oldMediaProgress.finishedAt,
|
|
||||||
createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate,
|
|
||||||
updatedAt: oldMediaProgress.lastUpdate,
|
|
||||||
extraData: {
|
|
||||||
libraryItemId: oldMediaProgress.libraryItemId,
|
|
||||||
progress: oldMediaProgress.progress
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static removeById(mediaProgressId) {
|
static removeById(mediaProgressId) {
|
||||||
return this.destroy({
|
return this.destroy({
|
||||||
where: {
|
where: {
|
||||||
@@ -71,12 +44,6 @@ class MediaProgress extends Model {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getMediaItem(options) {
|
|
||||||
if (!this.mediaItemType) return Promise.resolve(null)
|
|
||||||
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
|
|
||||||
return this[mixinMethodName](options)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize model
|
* Initialize model
|
||||||
*
|
*
|
||||||
@@ -162,6 +129,12 @@ class MediaProgress extends Model {
|
|||||||
MediaProgress.belongsTo(user)
|
MediaProgress.belongsTo(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMediaItem(options) {
|
||||||
|
if (!this.mediaItemType) return Promise.resolve(null)
|
||||||
|
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
|
||||||
|
return this[mixinMethodName](options)
|
||||||
|
}
|
||||||
|
|
||||||
getOldMediaProgress() {
|
getOldMediaProgress() {
|
||||||
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
|
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
|
||||||
|
|
||||||
|
|||||||
@@ -66,66 +66,6 @@ class Podcast extends Model {
|
|||||||
this.podcastEpisodes
|
this.podcastEpisodes
|
||||||
}
|
}
|
||||||
|
|
||||||
static getOldPodcast(libraryItemExpanded) {
|
|
||||||
const podcastExpanded = libraryItemExpanded.media
|
|
||||||
const podcastEpisodes = podcastExpanded.podcastEpisodes?.map((ep) => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index)
|
|
||||||
return {
|
|
||||||
id: podcastExpanded.id,
|
|
||||||
libraryItemId: libraryItemExpanded.id,
|
|
||||||
metadata: {
|
|
||||||
title: podcastExpanded.title,
|
|
||||||
author: podcastExpanded.author,
|
|
||||||
description: podcastExpanded.description,
|
|
||||||
releaseDate: podcastExpanded.releaseDate,
|
|
||||||
genres: podcastExpanded.genres,
|
|
||||||
feedUrl: podcastExpanded.feedURL,
|
|
||||||
imageUrl: podcastExpanded.imageURL,
|
|
||||||
itunesPageUrl: podcastExpanded.itunesPageURL,
|
|
||||||
itunesId: podcastExpanded.itunesId,
|
|
||||||
itunesArtistId: podcastExpanded.itunesArtistId,
|
|
||||||
explicit: podcastExpanded.explicit,
|
|
||||||
language: podcastExpanded.language,
|
|
||||||
type: podcastExpanded.podcastType
|
|
||||||
},
|
|
||||||
coverPath: podcastExpanded.coverPath,
|
|
||||||
tags: podcastExpanded.tags,
|
|
||||||
episodes: podcastEpisodes || [],
|
|
||||||
autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes,
|
|
||||||
autoDownloadSchedule: podcastExpanded.autoDownloadSchedule,
|
|
||||||
lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null,
|
|
||||||
maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep,
|
|
||||||
maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static getFromOld(oldPodcast) {
|
|
||||||
const oldPodcastMetadata = oldPodcast.metadata
|
|
||||||
return {
|
|
||||||
id: oldPodcast.id,
|
|
||||||
title: oldPodcastMetadata.title,
|
|
||||||
titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix,
|
|
||||||
author: oldPodcastMetadata.author,
|
|
||||||
releaseDate: oldPodcastMetadata.releaseDate,
|
|
||||||
feedURL: oldPodcastMetadata.feedUrl,
|
|
||||||
imageURL: oldPodcastMetadata.imageUrl,
|
|
||||||
description: oldPodcastMetadata.description,
|
|
||||||
itunesPageURL: oldPodcastMetadata.itunesPageUrl,
|
|
||||||
itunesId: oldPodcastMetadata.itunesId,
|
|
||||||
itunesArtistId: oldPodcastMetadata.itunesArtistId,
|
|
||||||
language: oldPodcastMetadata.language,
|
|
||||||
podcastType: oldPodcastMetadata.type,
|
|
||||||
explicit: !!oldPodcastMetadata.explicit,
|
|
||||||
autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes,
|
|
||||||
autoDownloadSchedule: oldPodcast.autoDownloadSchedule,
|
|
||||||
lastEpisodeCheck: oldPodcast.lastEpisodeCheck,
|
|
||||||
maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep,
|
|
||||||
maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload,
|
|
||||||
coverPath: oldPodcast.coverPath,
|
|
||||||
tags: oldPodcast.tags,
|
|
||||||
genres: oldPodcastMetadata.genres
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload from the /api/podcasts POST endpoint
|
* Payload from the /api/podcasts POST endpoint
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
const oldPodcastEpisode = require('../objects/entities/PodcastEpisode')
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef ChapterObject
|
* @typedef ChapterObject
|
||||||
@@ -53,40 +52,6 @@ class PodcastEpisode extends Model {
|
|||||||
this.updatedAt
|
this.updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
static createFromOld(oldEpisode) {
|
|
||||||
const podcastEpisode = this.getFromOld(oldEpisode)
|
|
||||||
return this.create(podcastEpisode)
|
|
||||||
}
|
|
||||||
|
|
||||||
static getFromOld(oldEpisode) {
|
|
||||||
const extraData = {}
|
|
||||||
if (oldEpisode.oldEpisodeId) {
|
|
||||||
extraData.oldEpisodeId = oldEpisode.oldEpisodeId
|
|
||||||
}
|
|
||||||
if (oldEpisode.guid) {
|
|
||||||
extraData.guid = oldEpisode.guid
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: oldEpisode.id,
|
|
||||||
index: oldEpisode.index,
|
|
||||||
season: oldEpisode.season,
|
|
||||||
episode: oldEpisode.episode,
|
|
||||||
episodeType: oldEpisode.episodeType,
|
|
||||||
title: oldEpisode.title,
|
|
||||||
subtitle: oldEpisode.subtitle,
|
|
||||||
description: oldEpisode.description,
|
|
||||||
pubDate: oldEpisode.pubDate,
|
|
||||||
enclosureURL: oldEpisode.enclosure?.url || null,
|
|
||||||
enclosureSize: oldEpisode.enclosure?.length || null,
|
|
||||||
enclosureType: oldEpisode.enclosure?.type || null,
|
|
||||||
publishedAt: oldEpisode.publishedAt,
|
|
||||||
podcastId: oldEpisode.podcastId,
|
|
||||||
audioFile: oldEpisode.audioFile?.toJSON() || null,
|
|
||||||
chapters: oldEpisode.chapters,
|
|
||||||
extraData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode
|
* @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode
|
||||||
@@ -203,47 +168,11 @@ class PodcastEpisode extends Model {
|
|||||||
getAudioTrack(libraryItemId) {
|
getAudioTrack(libraryItemId) {
|
||||||
const track = structuredClone(this.audioFile)
|
const track = structuredClone(this.audioFile)
|
||||||
track.startOffset = 0
|
track.startOffset = 0
|
||||||
track.title = this.audioFile.metadata.title
|
track.title = this.audioFile.metadata.filename
|
||||||
track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}`
|
track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}`
|
||||||
return track
|
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) {
|
toOldJSON(libraryItemId) {
|
||||||
if (!libraryItemId) {
|
if (!libraryItemId) {
|
||||||
throw new Error(`[PodcastEpisode] Cannot convert to old JSON because libraryItemId is not provided`)
|
throw new Error(`[PodcastEpisode] Cannot convert to old JSON because libraryItemId is not provided`)
|
||||||
|
|||||||
+17
-1
@@ -1,6 +1,6 @@
|
|||||||
const { DataTypes, Model, where, fn, col, literal } = require('sequelize')
|
const { DataTypes, Model, where, fn, col, literal } = require('sequelize')
|
||||||
|
|
||||||
const { getTitlePrefixAtEnd } = require('../utils/index')
|
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils/index')
|
||||||
|
|
||||||
class Series extends Model {
|
class Series extends Model {
|
||||||
constructor(values, options) {
|
constructor(values, options) {
|
||||||
@@ -66,6 +66,22 @@ class Series extends Model {
|
|||||||
return series
|
return series
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} seriesName
|
||||||
|
* @param {string} libraryId
|
||||||
|
* @returns {Promise<Series>}
|
||||||
|
*/
|
||||||
|
static async findOrCreateByNameAndLibrary(seriesName, libraryId) {
|
||||||
|
const series = await this.getByNameAndLibrary(seriesName, libraryId)
|
||||||
|
if (series) return series
|
||||||
|
return this.create({
|
||||||
|
name: seriesName,
|
||||||
|
nameIgnorePrefix: getTitleIgnorePrefix(seriesName),
|
||||||
|
libraryId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize model
|
* Initialize model
|
||||||
* @param {import('../Database').sequelize} sequelize
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
|||||||
@@ -563,9 +563,8 @@ class User extends Model {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check user can access library item
|
* Check user can access library item
|
||||||
* TODO: Currently supports both old and new library item models
|
|
||||||
*
|
*
|
||||||
* @param {import('../objects/LibraryItem')|import('./LibraryItem')} libraryItem
|
* @param {import('./LibraryItem')} libraryItem
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
checkCanAccessLibraryItem(libraryItem) {
|
checkCanAccessLibraryItem(libraryItem) {
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
const fs = require('../libs/fsExtra')
|
|
||||||
const Path = require('path')
|
|
||||||
const Logger = require('../Logger')
|
|
||||||
const LibraryFile = require('./files/LibraryFile')
|
|
||||||
const Book = require('./mediaTypes/Book')
|
|
||||||
const Podcast = require('./mediaTypes/Podcast')
|
|
||||||
const { areEquivalent, copyValue } = require('../utils/index')
|
|
||||||
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
|
||||||
|
|
||||||
class LibraryItem {
|
|
||||||
constructor(libraryItem = null) {
|
|
||||||
this.id = null
|
|
||||||
this.ino = null // Inode
|
|
||||||
this.oldLibraryItemId = null
|
|
||||||
|
|
||||||
this.libraryId = null
|
|
||||||
this.folderId = null
|
|
||||||
|
|
||||||
this.path = null
|
|
||||||
this.relPath = null
|
|
||||||
this.isFile = false
|
|
||||||
this.mtimeMs = null
|
|
||||||
this.ctimeMs = null
|
|
||||||
this.birthtimeMs = null
|
|
||||||
this.addedAt = null
|
|
||||||
this.updatedAt = null
|
|
||||||
this.lastScan = null
|
|
||||||
this.scanVersion = null
|
|
||||||
|
|
||||||
// Was scanned and no longer exists
|
|
||||||
this.isMissing = false
|
|
||||||
// Was scanned and no longer has media files
|
|
||||||
this.isInvalid = false
|
|
||||||
|
|
||||||
this.mediaType = null
|
|
||||||
this.media = null
|
|
||||||
|
|
||||||
/** @type {LibraryFile[]} */
|
|
||||||
this.libraryFiles = []
|
|
||||||
|
|
||||||
if (libraryItem) {
|
|
||||||
this.construct(libraryItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temporary attributes
|
|
||||||
this.isSavingMetadata = false
|
|
||||||
}
|
|
||||||
|
|
||||||
construct(libraryItem) {
|
|
||||||
this.id = libraryItem.id
|
|
||||||
this.ino = libraryItem.ino || null
|
|
||||||
this.oldLibraryItemId = libraryItem.oldLibraryItemId
|
|
||||||
this.libraryId = libraryItem.libraryId
|
|
||||||
this.folderId = libraryItem.folderId
|
|
||||||
this.path = libraryItem.path
|
|
||||||
this.relPath = libraryItem.relPath
|
|
||||||
this.isFile = !!libraryItem.isFile
|
|
||||||
this.mtimeMs = libraryItem.mtimeMs || 0
|
|
||||||
this.ctimeMs = libraryItem.ctimeMs || 0
|
|
||||||
this.birthtimeMs = libraryItem.birthtimeMs || 0
|
|
||||||
this.addedAt = libraryItem.addedAt
|
|
||||||
this.updatedAt = libraryItem.updatedAt || this.addedAt
|
|
||||||
this.lastScan = libraryItem.lastScan || null
|
|
||||||
this.scanVersion = libraryItem.scanVersion || null
|
|
||||||
|
|
||||||
this.isMissing = !!libraryItem.isMissing
|
|
||||||
this.isInvalid = !!libraryItem.isInvalid
|
|
||||||
|
|
||||||
this.mediaType = libraryItem.mediaType
|
|
||||||
if (this.mediaType === 'book') {
|
|
||||||
this.media = new Book(libraryItem.media)
|
|
||||||
} else if (this.mediaType === 'podcast') {
|
|
||||||
this.media = new Podcast(libraryItem.media)
|
|
||||||
}
|
|
||||||
this.media.libraryItemId = this.id
|
|
||||||
|
|
||||||
this.libraryFiles = libraryItem.libraryFiles.map((f) => new LibraryFile(f))
|
|
||||||
|
|
||||||
// Migration for v2.2.23 to set ebook library files as supplementary
|
|
||||||
if (this.isBook && this.media.ebookFile) {
|
|
||||||
for (const libraryFile of this.libraryFiles) {
|
|
||||||
if (libraryFile.isEBookFile && libraryFile.isSupplementary === null) {
|
|
||||||
libraryFile.isSupplementary = this.media.ebookFile.ino !== libraryFile.ino
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
ino: this.ino,
|
|
||||||
oldLibraryItemId: this.oldLibraryItemId,
|
|
||||||
libraryId: this.libraryId,
|
|
||||||
folderId: this.folderId,
|
|
||||||
path: this.path,
|
|
||||||
relPath: this.relPath,
|
|
||||||
isFile: this.isFile,
|
|
||||||
mtimeMs: this.mtimeMs,
|
|
||||||
ctimeMs: this.ctimeMs,
|
|
||||||
birthtimeMs: this.birthtimeMs,
|
|
||||||
addedAt: this.addedAt,
|
|
||||||
updatedAt: this.updatedAt,
|
|
||||||
lastScan: this.lastScan,
|
|
||||||
scanVersion: this.scanVersion,
|
|
||||||
isMissing: !!this.isMissing,
|
|
||||||
isInvalid: !!this.isInvalid,
|
|
||||||
mediaType: this.mediaType,
|
|
||||||
media: this.media.toJSON(),
|
|
||||||
libraryFiles: this.libraryFiles.map((f) => f.toJSON())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONMinified() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
ino: this.ino,
|
|
||||||
oldLibraryItemId: this.oldLibraryItemId,
|
|
||||||
libraryId: this.libraryId,
|
|
||||||
folderId: this.folderId,
|
|
||||||
path: this.path,
|
|
||||||
relPath: this.relPath,
|
|
||||||
isFile: this.isFile,
|
|
||||||
mtimeMs: this.mtimeMs,
|
|
||||||
ctimeMs: this.ctimeMs,
|
|
||||||
birthtimeMs: this.birthtimeMs,
|
|
||||||
addedAt: this.addedAt,
|
|
||||||
updatedAt: this.updatedAt,
|
|
||||||
isMissing: !!this.isMissing,
|
|
||||||
isInvalid: !!this.isInvalid,
|
|
||||||
mediaType: this.mediaType,
|
|
||||||
media: this.media.toJSONMinified(),
|
|
||||||
numFiles: this.libraryFiles.length,
|
|
||||||
size: this.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adds additional helpful fields like media duration, tracks, etc.
|
|
||||||
toJSONExpanded() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
ino: this.ino,
|
|
||||||
oldLibraryItemId: this.oldLibraryItemId,
|
|
||||||
libraryId: this.libraryId,
|
|
||||||
folderId: this.folderId,
|
|
||||||
path: this.path,
|
|
||||||
relPath: this.relPath,
|
|
||||||
isFile: this.isFile,
|
|
||||||
mtimeMs: this.mtimeMs,
|
|
||||||
ctimeMs: this.ctimeMs,
|
|
||||||
birthtimeMs: this.birthtimeMs,
|
|
||||||
addedAt: this.addedAt,
|
|
||||||
updatedAt: this.updatedAt,
|
|
||||||
lastScan: this.lastScan,
|
|
||||||
scanVersion: this.scanVersion,
|
|
||||||
isMissing: !!this.isMissing,
|
|
||||||
isInvalid: !!this.isInvalid,
|
|
||||||
mediaType: this.mediaType,
|
|
||||||
media: this.media.toJSONExpanded(),
|
|
||||||
libraryFiles: this.libraryFiles.map((f) => f.toJSON()),
|
|
||||||
size: this.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get isPodcast() {
|
|
||||||
return this.mediaType === 'podcast'
|
|
||||||
}
|
|
||||||
get isBook() {
|
|
||||||
return this.mediaType === 'book'
|
|
||||||
}
|
|
||||||
get size() {
|
|
||||||
let total = 0
|
|
||||||
this.libraryFiles.forEach((lf) => (total += lf.metadata.size))
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
get hasAudioFiles() {
|
|
||||||
return this.libraryFiles.some((lf) => lf.fileType === 'audio')
|
|
||||||
}
|
|
||||||
|
|
||||||
update(payload) {
|
|
||||||
const json = this.toJSON()
|
|
||||||
let hasUpdates = false
|
|
||||||
for (const key in json) {
|
|
||||||
if (payload[key] !== undefined) {
|
|
||||||
if (key === 'media') {
|
|
||||||
if (this.media.update(payload[key])) {
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
} else if (!areEquivalent(payload[key], json[key])) {
|
|
||||||
this[key] = copyValue(payload[key])
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasUpdates) {
|
|
||||||
this.updatedAt = Date.now()
|
|
||||||
}
|
|
||||||
return hasUpdates
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMediaCover(coverPath) {
|
|
||||||
this.media.updateCover(coverPath)
|
|
||||||
this.updatedAt = Date.now()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
setMissing() {
|
|
||||||
this.isMissing = true
|
|
||||||
this.updatedAt = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save metadata.json file
|
|
||||||
* TODO: Move to new LibraryItem model
|
|
||||||
* @returns {Promise<LibraryFile>} null if not saved
|
|
||||||
*/
|
|
||||||
async saveMetadata() {
|
|
||||||
if (this.isSavingMetadata || !global.MetadataPath) return null
|
|
||||||
|
|
||||||
this.isSavingMetadata = true
|
|
||||||
|
|
||||||
let metadataPath = Path.join(global.MetadataPath, 'items', this.id)
|
|
||||||
let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem
|
|
||||||
if (storeMetadataWithItem && !this.isFile) {
|
|
||||||
metadataPath = this.path
|
|
||||||
} else {
|
|
||||||
// Make sure metadata book dir exists
|
|
||||||
storeMetadataWithItem = false
|
|
||||||
await fs.ensureDir(metadataPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
|
|
||||||
|
|
||||||
return fs
|
|
||||||
.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2))
|
|
||||||
.then(async () => {
|
|
||||||
// Add metadata.json to libraryFiles array if it is new
|
|
||||||
let metadataLibraryFile = this.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
|
||||||
if (storeMetadataWithItem) {
|
|
||||||
if (!metadataLibraryFile) {
|
|
||||||
metadataLibraryFile = new LibraryFile()
|
|
||||||
await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
|
|
||||||
this.libraryFiles.push(metadataLibraryFile)
|
|
||||||
} else {
|
|
||||||
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
|
|
||||||
if (fileTimestamps) {
|
|
||||||
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
|
|
||||||
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
|
|
||||||
metadataLibraryFile.metadata.size = fileTimestamps.size
|
|
||||||
metadataLibraryFile.ino = fileTimestamps.ino
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
|
|
||||||
if (libraryItemDirTimestamps) {
|
|
||||||
this.mtimeMs = libraryItemDirTimestamps.mtimeMs
|
|
||||||
this.ctimeMs = libraryItemDirTimestamps.ctimeMs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
|
|
||||||
|
|
||||||
return metadataLibraryFile
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.isSavingMetadata = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = LibraryItem
|
|
||||||
@@ -275,15 +275,15 @@ class Stream extends EventEmitter {
|
|||||||
|
|
||||||
this.ffmpeg.addOption([`-loglevel ${logLevel}`, '-map 0:a', `-c:a ${audioCodec}`])
|
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']
|
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') {
|
if (this.hlsSegmentType === 'fmp4') {
|
||||||
hlsOptions.push('-strict -2')
|
this.ffmpeg.addOption('-strict -2')
|
||||||
var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4')
|
var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4')
|
||||||
// var fmp4InitFilename = '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)
|
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.output(this.finalPlaylistPath)
|
||||||
|
|
||||||
this.ffmpeg.on('start', (command) => {
|
this.ffmpeg.on('start', (command) => {
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
const { areEquivalent, copyValue } = require('../../utils/index')
|
|
||||||
const AudioFile = require('../files/AudioFile')
|
|
||||||
const AudioTrack = require('../files/AudioTrack')
|
|
||||||
|
|
||||||
class PodcastEpisode {
|
|
||||||
constructor(episode) {
|
|
||||||
this.libraryItemId = null
|
|
||||||
this.podcastId = null
|
|
||||||
this.id = null
|
|
||||||
this.oldEpisodeId = null
|
|
||||||
this.index = null
|
|
||||||
|
|
||||||
this.season = null
|
|
||||||
this.episode = null
|
|
||||||
this.episodeType = null
|
|
||||||
this.title = null
|
|
||||||
this.subtitle = null
|
|
||||||
this.description = null
|
|
||||||
this.enclosure = null
|
|
||||||
this.guid = null
|
|
||||||
this.pubDate = null
|
|
||||||
this.chapters = []
|
|
||||||
|
|
||||||
this.audioFile = null
|
|
||||||
this.publishedAt = null
|
|
||||||
this.addedAt = null
|
|
||||||
this.updatedAt = null
|
|
||||||
|
|
||||||
if (episode) {
|
|
||||||
this.construct(episode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
construct(episode) {
|
|
||||||
this.libraryItemId = episode.libraryItemId
|
|
||||||
this.podcastId = episode.podcastId
|
|
||||||
this.id = episode.id
|
|
||||||
this.oldEpisodeId = episode.oldEpisodeId
|
|
||||||
this.index = episode.index
|
|
||||||
this.season = episode.season
|
|
||||||
this.episode = episode.episode
|
|
||||||
this.episodeType = episode.episodeType
|
|
||||||
this.title = episode.title
|
|
||||||
this.subtitle = episode.subtitle
|
|
||||||
this.description = episode.description
|
|
||||||
this.enclosure = episode.enclosure ? { ...episode.enclosure } : null
|
|
||||||
this.guid = episode.guid || null
|
|
||||||
this.pubDate = episode.pubDate
|
|
||||||
this.chapters = episode.chapters?.map((ch) => ({ ...ch })) || []
|
|
||||||
this.audioFile = episode.audioFile ? new AudioFile(episode.audioFile) : null
|
|
||||||
this.publishedAt = episode.publishedAt
|
|
||||||
this.addedAt = episode.addedAt
|
|
||||||
this.updatedAt = episode.updatedAt
|
|
||||||
|
|
||||||
if (this.audioFile) {
|
|
||||||
this.audioFile.index = 1 // Only 1 audio file per episode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
libraryItemId: this.libraryItemId,
|
|
||||||
podcastId: this.podcastId,
|
|
||||||
id: this.id,
|
|
||||||
oldEpisodeId: this.oldEpisodeId,
|
|
||||||
index: this.index,
|
|
||||||
season: this.season,
|
|
||||||
episode: this.episode,
|
|
||||||
episodeType: this.episodeType,
|
|
||||||
title: this.title,
|
|
||||||
subtitle: this.subtitle,
|
|
||||||
description: this.description,
|
|
||||||
enclosure: this.enclosure ? { ...this.enclosure } : null,
|
|
||||||
guid: this.guid,
|
|
||||||
pubDate: this.pubDate,
|
|
||||||
chapters: this.chapters.map((ch) => ({ ...ch })),
|
|
||||||
audioFile: this.audioFile?.toJSON() || null,
|
|
||||||
publishedAt: this.publishedAt,
|
|
||||||
addedAt: this.addedAt,
|
|
||||||
updatedAt: this.updatedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONExpanded() {
|
|
||||||
return {
|
|
||||||
libraryItemId: this.libraryItemId,
|
|
||||||
podcastId: this.podcastId,
|
|
||||||
id: this.id,
|
|
||||||
oldEpisodeId: this.oldEpisodeId,
|
|
||||||
index: this.index,
|
|
||||||
season: this.season,
|
|
||||||
episode: this.episode,
|
|
||||||
episodeType: this.episodeType,
|
|
||||||
title: this.title,
|
|
||||||
subtitle: this.subtitle,
|
|
||||||
description: this.description,
|
|
||||||
enclosure: this.enclosure ? { ...this.enclosure } : null,
|
|
||||||
guid: this.guid,
|
|
||||||
pubDate: this.pubDate,
|
|
||||||
chapters: this.chapters.map((ch) => ({ ...ch })),
|
|
||||||
audioFile: this.audioFile?.toJSON() || null,
|
|
||||||
audioTrack: this.audioTrack?.toJSON() || null,
|
|
||||||
publishedAt: this.publishedAt,
|
|
||||||
addedAt: this.addedAt,
|
|
||||||
updatedAt: this.updatedAt,
|
|
||||||
duration: this.duration,
|
|
||||||
size: this.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get audioTrack() {
|
|
||||||
if (!this.audioFile) return null
|
|
||||||
const audioTrack = new AudioTrack()
|
|
||||||
audioTrack.setData(this.libraryItemId, this.audioFile, 0)
|
|
||||||
return audioTrack
|
|
||||||
}
|
|
||||||
get tracks() {
|
|
||||||
return [this.audioTrack]
|
|
||||||
}
|
|
||||||
get duration() {
|
|
||||||
return this.audioFile?.duration || 0
|
|
||||||
}
|
|
||||||
get size() {
|
|
||||||
return this.audioFile?.metadata.size || 0
|
|
||||||
}
|
|
||||||
get enclosureUrl() {
|
|
||||||
return this.enclosure?.url || null
|
|
||||||
}
|
|
||||||
|
|
||||||
update(payload) {
|
|
||||||
let hasUpdates = false
|
|
||||||
for (const key in this.toJSON()) {
|
|
||||||
let newValue = payload[key]
|
|
||||||
if (newValue === '') newValue = null
|
|
||||||
let existingValue = this[key]
|
|
||||||
if (existingValue === '') existingValue = null
|
|
||||||
|
|
||||||
if (newValue != undefined && !areEquivalent(newValue, existingValue)) {
|
|
||||||
this[key] = copyValue(newValue)
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasUpdates) {
|
|
||||||
this.updatedAt = Date.now()
|
|
||||||
}
|
|
||||||
return hasUpdates
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = PodcastEpisode
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
const Logger = require('../../Logger')
|
|
||||||
const BookMetadata = require('../metadata/BookMetadata')
|
|
||||||
const { areEquivalent, copyValue } = require('../../utils/index')
|
|
||||||
const { filePathToPOSIX } = require('../../utils/fileUtils')
|
|
||||||
const AudioFile = require('../files/AudioFile')
|
|
||||||
const AudioTrack = require('../files/AudioTrack')
|
|
||||||
const EBookFile = require('../files/EBookFile')
|
|
||||||
|
|
||||||
class Book {
|
|
||||||
constructor(book) {
|
|
||||||
this.id = null
|
|
||||||
this.libraryItemId = null
|
|
||||||
this.metadata = null
|
|
||||||
|
|
||||||
this.coverPath = null
|
|
||||||
this.tags = []
|
|
||||||
|
|
||||||
this.audioFiles = []
|
|
||||||
this.chapters = []
|
|
||||||
this.ebookFile = null
|
|
||||||
|
|
||||||
this.lastCoverSearch = null
|
|
||||||
this.lastCoverSearchQuery = null
|
|
||||||
|
|
||||||
if (book) {
|
|
||||||
this.construct(book)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
construct(book) {
|
|
||||||
this.id = book.id
|
|
||||||
this.libraryItemId = book.libraryItemId
|
|
||||||
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.ebookFile = book.ebookFile ? new EBookFile(book.ebookFile) : null
|
|
||||||
this.lastCoverSearch = book.lastCoverSearch || null
|
|
||||||
this.lastCoverSearchQuery = book.lastCoverSearchQuery || null
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
libraryItemId: this.libraryItemId,
|
|
||||||
metadata: this.metadata.toJSON(),
|
|
||||||
coverPath: this.coverPath,
|
|
||||||
tags: [...this.tags],
|
|
||||||
audioFiles: this.audioFiles.map((f) => f.toJSON()),
|
|
||||||
chapters: this.chapters.map((c) => ({ ...c })),
|
|
||||||
ebookFile: this.ebookFile ? this.ebookFile.toJSON() : null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONMinified() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
metadata: this.metadata.toJSONMinified(),
|
|
||||||
coverPath: this.coverPath,
|
|
||||||
tags: [...this.tags],
|
|
||||||
numTracks: this.tracks.length,
|
|
||||||
numAudioFiles: this.audioFiles.length,
|
|
||||||
numChapters: this.chapters.length,
|
|
||||||
duration: this.duration,
|
|
||||||
size: this.size,
|
|
||||||
ebookFormat: this.ebookFile?.ebookFormat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONExpanded() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
libraryItemId: this.libraryItemId,
|
|
||||||
metadata: this.metadata.toJSONExpanded(),
|
|
||||||
coverPath: this.coverPath,
|
|
||||||
tags: [...this.tags],
|
|
||||||
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()),
|
|
||||||
ebookFile: this.ebookFile?.toJSON() || null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONForMetadataFile() {
|
|
||||||
return {
|
|
||||||
tags: [...this.tags],
|
|
||||||
chapters: this.chapters.map((c) => ({ ...c })),
|
|
||||||
...this.metadata.toJSONForMetadataFile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get size() {
|
|
||||||
var total = 0
|
|
||||||
this.audioFiles.forEach((af) => (total += af.metadata.size))
|
|
||||||
if (this.ebookFile) {
|
|
||||||
total += this.ebookFile.metadata.size
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
get includedAudioFiles() {
|
|
||||||
return this.audioFiles.filter((af) => !af.exclude)
|
|
||||||
}
|
|
||||||
get tracks() {
|
|
||||||
let startOffset = 0
|
|
||||||
return this.includedAudioFiles.map((af) => {
|
|
||||||
const audioTrack = new AudioTrack()
|
|
||||||
audioTrack.setData(this.libraryItemId, af, startOffset)
|
|
||||||
startOffset += audioTrack.duration
|
|
||||||
return audioTrack
|
|
||||||
})
|
|
||||||
}
|
|
||||||
get duration() {
|
|
||||||
let total = 0
|
|
||||||
this.tracks.forEach((track) => (total += track.duration))
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
get numTracks() {
|
|
||||||
return this.tracks.length
|
|
||||||
}
|
|
||||||
get isEBookOnly() {
|
|
||||||
return this.ebookFile && !this.numTracks
|
|
||||||
}
|
|
||||||
|
|
||||||
update(payload) {
|
|
||||||
const json = this.toJSON()
|
|
||||||
|
|
||||||
let hasUpdates = false
|
|
||||||
for (const key in json) {
|
|
||||||
if (payload[key] !== undefined) {
|
|
||||||
if (key === 'metadata') {
|
|
||||||
if (this.metadata.update(payload.metadata)) {
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
} else if (!areEquivalent(payload[key], json[key])) {
|
|
||||||
this[key] = copyValue(payload[key])
|
|
||||||
Logger.debug('[Book] Key updated', key, this[key])
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasUpdates
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCover(coverPath) {
|
|
||||||
coverPath = filePathToPOSIX(coverPath)
|
|
||||||
if (this.coverPath === coverPath) return false
|
|
||||||
this.coverPath = coverPath
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = Book
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
const Logger = require('../../Logger')
|
|
||||||
const PodcastEpisode = require('../entities/PodcastEpisode')
|
|
||||||
const PodcastMetadata = require('../metadata/PodcastMetadata')
|
|
||||||
const { areEquivalent, copyValue } = require('../../utils/index')
|
|
||||||
const { filePathToPOSIX } = require('../../utils/fileUtils')
|
|
||||||
|
|
||||||
class Podcast {
|
|
||||||
constructor(podcast) {
|
|
||||||
this.id = null
|
|
||||||
this.libraryItemId = null
|
|
||||||
this.metadata = null
|
|
||||||
this.coverPath = null
|
|
||||||
this.tags = []
|
|
||||||
this.episodes = []
|
|
||||||
|
|
||||||
this.autoDownloadEpisodes = false
|
|
||||||
this.autoDownloadSchedule = null
|
|
||||||
this.lastEpisodeCheck = 0
|
|
||||||
this.maxEpisodesToKeep = 0
|
|
||||||
this.maxNewEpisodesToDownload = 3
|
|
||||||
|
|
||||||
this.lastCoverSearch = null
|
|
||||||
this.lastCoverSearchQuery = null
|
|
||||||
|
|
||||||
if (podcast) {
|
|
||||||
this.construct(podcast)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
construct(podcast) {
|
|
||||||
this.id = podcast.id
|
|
||||||
this.libraryItemId = podcast.libraryItemId
|
|
||||||
this.metadata = new PodcastMetadata(podcast.metadata)
|
|
||||||
this.coverPath = podcast.coverPath
|
|
||||||
this.tags = [...podcast.tags]
|
|
||||||
this.episodes = podcast.episodes.map((e) => {
|
|
||||||
var podcastEpisode = new PodcastEpisode(e)
|
|
||||||
podcastEpisode.libraryItemId = this.libraryItemId
|
|
||||||
return podcastEpisode
|
|
||||||
})
|
|
||||||
this.autoDownloadEpisodes = !!podcast.autoDownloadEpisodes
|
|
||||||
this.autoDownloadSchedule = podcast.autoDownloadSchedule || '0 * * * *' // Added in 2.1.3 so default to hourly
|
|
||||||
this.lastEpisodeCheck = podcast.lastEpisodeCheck || 0
|
|
||||||
this.maxEpisodesToKeep = podcast.maxEpisodesToKeep || 0
|
|
||||||
|
|
||||||
// Default is 3 but 0 is allowed
|
|
||||||
if (typeof podcast.maxNewEpisodesToDownload !== 'number') {
|
|
||||||
this.maxNewEpisodesToDownload = 3
|
|
||||||
} else {
|
|
||||||
this.maxNewEpisodesToDownload = podcast.maxNewEpisodesToDownload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
libraryItemId: this.libraryItemId,
|
|
||||||
metadata: this.metadata.toJSON(),
|
|
||||||
coverPath: this.coverPath,
|
|
||||||
tags: [...this.tags],
|
|
||||||
episodes: this.episodes.map((e) => e.toJSON()),
|
|
||||||
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
|
||||||
autoDownloadSchedule: this.autoDownloadSchedule,
|
|
||||||
lastEpisodeCheck: this.lastEpisodeCheck,
|
|
||||||
maxEpisodesToKeep: this.maxEpisodesToKeep,
|
|
||||||
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONMinified() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
metadata: this.metadata.toJSONMinified(),
|
|
||||||
coverPath: this.coverPath,
|
|
||||||
tags: [...this.tags],
|
|
||||||
numEpisodes: this.episodes.length,
|
|
||||||
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
|
||||||
autoDownloadSchedule: this.autoDownloadSchedule,
|
|
||||||
lastEpisodeCheck: this.lastEpisodeCheck,
|
|
||||||
maxEpisodesToKeep: this.maxEpisodesToKeep,
|
|
||||||
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
|
|
||||||
size: this.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONExpanded() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
libraryItemId: this.libraryItemId,
|
|
||||||
metadata: this.metadata.toJSONExpanded(),
|
|
||||||
coverPath: this.coverPath,
|
|
||||||
tags: [...this.tags],
|
|
||||||
episodes: this.episodes.map((e) => e.toJSONExpanded()),
|
|
||||||
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
|
||||||
autoDownloadSchedule: this.autoDownloadSchedule,
|
|
||||||
lastEpisodeCheck: this.lastEpisodeCheck,
|
|
||||||
maxEpisodesToKeep: this.maxEpisodesToKeep,
|
|
||||||
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
|
|
||||||
size: this.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONForMetadataFile() {
|
|
||||||
return {
|
|
||||||
tags: [...this.tags],
|
|
||||||
title: this.metadata.title,
|
|
||||||
author: this.metadata.author,
|
|
||||||
description: this.metadata.description,
|
|
||||||
releaseDate: this.metadata.releaseDate,
|
|
||||||
genres: [...this.metadata.genres],
|
|
||||||
feedURL: this.metadata.feedUrl,
|
|
||||||
imageURL: this.metadata.imageUrl,
|
|
||||||
itunesPageURL: this.metadata.itunesPageUrl,
|
|
||||||
itunesId: this.metadata.itunesId,
|
|
||||||
itunesArtistId: this.metadata.itunesArtistId,
|
|
||||||
explicit: this.metadata.explicit,
|
|
||||||
language: this.metadata.language,
|
|
||||||
podcastType: this.metadata.type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get size() {
|
|
||||||
var total = 0
|
|
||||||
this.episodes.forEach((ep) => (total += ep.size))
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
get duration() {
|
|
||||||
let total = 0
|
|
||||||
this.episodes.forEach((ep) => (total += ep.duration))
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
get numTracks() {
|
|
||||||
return this.episodes.length
|
|
||||||
}
|
|
||||||
|
|
||||||
update(payload) {
|
|
||||||
var json = this.toJSON()
|
|
||||||
delete json.episodes // do not update media entities here
|
|
||||||
var hasUpdates = false
|
|
||||||
for (const key in json) {
|
|
||||||
if (payload[key] !== undefined) {
|
|
||||||
if (key === 'metadata') {
|
|
||||||
if (this.metadata.update(payload.metadata)) {
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
} else if (!areEquivalent(payload[key], json[key])) {
|
|
||||||
this[key] = copyValue(payload[key])
|
|
||||||
Logger.debug('[Podcast] Key updated', key, this[key])
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasUpdates
|
|
||||||
}
|
|
||||||
|
|
||||||
updateEpisode(id, payload) {
|
|
||||||
var episode = this.episodes.find((ep) => ep.id == id)
|
|
||||||
if (!episode) return false
|
|
||||||
return episode.update(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCover(coverPath) {
|
|
||||||
coverPath = filePathToPOSIX(coverPath)
|
|
||||||
if (this.coverPath === coverPath) return false
|
|
||||||
this.coverPath = coverPath
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
getEpisode(episodeId) {
|
|
||||||
if (!episodeId) return null
|
|
||||||
|
|
||||||
// Support old episode ids for mobile downloads
|
|
||||||
if (episodeId.startsWith('ep_')) return this.episodes.find((ep) => ep.oldEpisodeId == episodeId)
|
|
||||||
|
|
||||||
return this.episodes.find((ep) => ep.id == episodeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = Podcast
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
const Logger = require('../../Logger')
|
|
||||||
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
|
|
||||||
const parseNameString = require('../../utils/parsers/parseNameString')
|
|
||||||
class BookMetadata {
|
|
||||||
constructor(metadata) {
|
|
||||||
this.title = null
|
|
||||||
this.subtitle = null
|
|
||||||
this.authors = []
|
|
||||||
this.narrators = [] // Array of strings
|
|
||||||
this.series = []
|
|
||||||
this.genres = [] // Array of strings
|
|
||||||
this.publishedYear = null
|
|
||||||
this.publishedDate = null
|
|
||||||
this.publisher = null
|
|
||||||
this.description = null
|
|
||||||
this.isbn = null
|
|
||||||
this.asin = null
|
|
||||||
this.language = null
|
|
||||||
this.explicit = false
|
|
||||||
this.abridged = false
|
|
||||||
|
|
||||||
if (metadata) {
|
|
||||||
this.construct(metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
construct(metadata) {
|
|
||||||
this.title = metadata.title
|
|
||||||
this.subtitle = metadata.subtitle
|
|
||||||
this.authors = metadata.authors?.map ? metadata.authors.map((a) => ({ ...a })) : []
|
|
||||||
this.narrators = metadata.narrators ? [...metadata.narrators].filter((n) => n) : []
|
|
||||||
this.series = metadata.series?.map
|
|
||||||
? metadata.series.map((s) => ({
|
|
||||||
...s,
|
|
||||||
name: s.name || 'No Title'
|
|
||||||
}))
|
|
||||||
: []
|
|
||||||
this.genres = metadata.genres ? [...metadata.genres] : []
|
|
||||||
this.publishedYear = metadata.publishedYear || null
|
|
||||||
this.publishedDate = metadata.publishedDate || null
|
|
||||||
this.publisher = metadata.publisher
|
|
||||||
this.description = metadata.description
|
|
||||||
this.isbn = metadata.isbn
|
|
||||||
this.asin = metadata.asin
|
|
||||||
this.language = metadata.language
|
|
||||||
this.explicit = !!metadata.explicit
|
|
||||||
this.abridged = !!metadata.abridged
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
title: this.title,
|
|
||||||
subtitle: this.subtitle,
|
|
||||||
authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id
|
|
||||||
narrators: [...this.narrators],
|
|
||||||
series: this.series.map((s) => ({ ...s })), // Series JSONMinimal with name, id and 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONMinified() {
|
|
||||||
return {
|
|
||||||
title: this.title,
|
|
||||||
titleIgnorePrefix: this.titlePrefixAtEnd,
|
|
||||||
subtitle: this.subtitle,
|
|
||||||
authorName: this.authorName,
|
|
||||||
authorNameLF: this.authorNameLF,
|
|
||||||
narratorName: this.narratorName,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONExpanded() {
|
|
||||||
return {
|
|
||||||
title: this.title,
|
|
||||||
titleIgnorePrefix: this.titlePrefixAtEnd,
|
|
||||||
subtitle: this.subtitle,
|
|
||||||
authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id
|
|
||||||
narrators: [...this.narrators],
|
|
||||||
series: this.series.map((s) => ({ ...s })),
|
|
||||||
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,
|
|
||||||
authorName: this.authorName,
|
|
||||||
authorNameLF: this.authorNameLF,
|
|
||||||
narratorName: this.narratorName,
|
|
||||||
seriesName: this.seriesName,
|
|
||||||
abridged: this.abridged
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONForMetadataFile() {
|
|
||||||
const json = this.toJSON()
|
|
||||||
json.authors = json.authors.map((au) => au.name)
|
|
||||||
json.series = json.series.map((se) => {
|
|
||||||
if (!se.sequence) return se.name
|
|
||||||
return `${se.name} #${se.sequence}`
|
|
||||||
})
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
|
|
||||||
clone() {
|
|
||||||
return new BookMetadata(this.toJSON())
|
|
||||||
}
|
|
||||||
|
|
||||||
get titleIgnorePrefix() {
|
|
||||||
return getTitleIgnorePrefix(this.title)
|
|
||||||
}
|
|
||||||
get titlePrefixAtEnd() {
|
|
||||||
return getTitlePrefixAtEnd(this.title)
|
|
||||||
}
|
|
||||||
get authorName() {
|
|
||||||
if (!this.authors.length) return ''
|
|
||||||
return this.authors.map((au) => au.name).join(', ')
|
|
||||||
}
|
|
||||||
get authorNameLF() {
|
|
||||||
// Last, First
|
|
||||||
if (!this.authors.length) return ''
|
|
||||||
return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ')
|
|
||||||
}
|
|
||||||
get seriesName() {
|
|
||||||
if (!this.series.length) return ''
|
|
||||||
return this.series
|
|
||||||
.map((se) => {
|
|
||||||
if (!se.sequence) return se.name
|
|
||||||
return `${se.name} #${se.sequence}`
|
|
||||||
})
|
|
||||||
.join(', ')
|
|
||||||
}
|
|
||||||
get narratorName() {
|
|
||||||
return this.narrators.join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
getSeries(seriesId) {
|
|
||||||
return this.series.find((se) => se.id == seriesId)
|
|
||||||
}
|
|
||||||
|
|
||||||
update(payload) {
|
|
||||||
const json = this.toJSON()
|
|
||||||
let hasUpdates = false
|
|
||||||
|
|
||||||
for (const key in json) {
|
|
||||||
if (payload[key] !== undefined) {
|
|
||||||
if (!areEquivalent(payload[key], json[key])) {
|
|
||||||
this[key] = copyValue(payload[key])
|
|
||||||
Logger.debug('[BookMetadata] Key updated', key, this[key])
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasUpdates
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = BookMetadata
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
const Logger = require('../../Logger')
|
|
||||||
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
|
|
||||||
|
|
||||||
class PodcastMetadata {
|
|
||||||
constructor(metadata) {
|
|
||||||
this.title = null
|
|
||||||
this.author = null
|
|
||||||
this.description = null
|
|
||||||
this.releaseDate = null
|
|
||||||
this.genres = []
|
|
||||||
this.feedUrl = null
|
|
||||||
this.imageUrl = null
|
|
||||||
this.itunesPageUrl = null
|
|
||||||
this.itunesId = null
|
|
||||||
this.itunesArtistId = null
|
|
||||||
this.explicit = false
|
|
||||||
this.language = null
|
|
||||||
this.type = null
|
|
||||||
|
|
||||||
if (metadata) {
|
|
||||||
this.construct(metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
construct(metadata) {
|
|
||||||
this.title = metadata.title
|
|
||||||
this.author = metadata.author
|
|
||||||
this.description = metadata.description
|
|
||||||
this.releaseDate = metadata.releaseDate
|
|
||||||
this.genres = [...metadata.genres]
|
|
||||||
this.feedUrl = metadata.feedUrl
|
|
||||||
this.imageUrl = metadata.imageUrl
|
|
||||||
this.itunesPageUrl = metadata.itunesPageUrl
|
|
||||||
this.itunesId = metadata.itunesId
|
|
||||||
this.itunesArtistId = metadata.itunesArtistId
|
|
||||||
this.explicit = metadata.explicit
|
|
||||||
this.language = metadata.language || null
|
|
||||||
this.type = metadata.type || 'episodic'
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
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.type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONMinified() {
|
|
||||||
return {
|
|
||||||
title: this.title,
|
|
||||||
titleIgnorePrefix: this.titlePrefixAtEnd,
|
|
||||||
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.type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONExpanded() {
|
|
||||||
return this.toJSONMinified()
|
|
||||||
}
|
|
||||||
|
|
||||||
clone() {
|
|
||||||
return new PodcastMetadata(this.toJSON())
|
|
||||||
}
|
|
||||||
|
|
||||||
get titleIgnorePrefix() {
|
|
||||||
return getTitleIgnorePrefix(this.title)
|
|
||||||
}
|
|
||||||
|
|
||||||
get titlePrefixAtEnd() {
|
|
||||||
return getTitlePrefixAtEnd(this.title)
|
|
||||||
}
|
|
||||||
|
|
||||||
update(payload) {
|
|
||||||
const json = this.toJSON()
|
|
||||||
let hasUpdates = false
|
|
||||||
for (const key in json) {
|
|
||||||
if (payload[key] !== undefined) {
|
|
||||||
if (!areEquivalent(payload[key], json[key])) {
|
|
||||||
this[key] = copyValue(payload[key])
|
|
||||||
Logger.debug('[PodcastMetadata] Key updated', key, this[key])
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasUpdates
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = PodcastMetadata
|
|
||||||
+1
-106
@@ -65,7 +65,7 @@ class ApiRouter {
|
|||||||
//
|
//
|
||||||
// Library Routes
|
// 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.post('/libraries', LibraryController.create.bind(this))
|
||||||
this.router.get('/libraries', LibraryController.findAll.bind(this))
|
this.router.get('/libraries', LibraryController.findAll.bind(this))
|
||||||
this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this))
|
this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this))
|
||||||
@@ -105,7 +105,6 @@ class ApiRouter {
|
|||||||
this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this))
|
this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this))
|
||||||
|
|
||||||
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
|
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
|
||||||
this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this))
|
|
||||||
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
|
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
|
||||||
this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this))
|
this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this))
|
||||||
this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
|
this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
|
||||||
@@ -531,109 +530,5 @@ class ApiRouter {
|
|||||||
})
|
})
|
||||||
return listeningStats
|
return listeningStats
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryId) {
|
|
||||||
if (mediaPayload.metadata) {
|
|
||||||
const mediaMetadata = mediaPayload.metadata
|
|
||||||
|
|
||||||
// Create new authors if in payload
|
|
||||||
if (mediaMetadata.authors?.length) {
|
|
||||||
const newAuthors = []
|
|
||||||
for (let i = 0; i < mediaMetadata.authors.length; i++) {
|
|
||||||
const authorName = (mediaMetadata.authors[i].name || '').trim()
|
|
||||||
if (!authorName) {
|
|
||||||
Logger.error(`[ApiRouter] Invalid author object, no name`, mediaMetadata.authors[i])
|
|
||||||
mediaMetadata.authors[i].id = null
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaMetadata.authors[i].id?.startsWith('new')) {
|
|
||||||
mediaMetadata.authors[i].id = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the ID for the author exists
|
|
||||||
if (mediaMetadata.authors[i].id && !(await Database.checkAuthorExists(libraryId, mediaMetadata.authors[i].id))) {
|
|
||||||
Logger.warn(`[ApiRouter] Author id "${mediaMetadata.authors[i].id}" does not exist`)
|
|
||||||
mediaMetadata.authors[i].id = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mediaMetadata.authors[i].id) {
|
|
||||||
let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryId)
|
|
||||||
if (!author) {
|
|
||||||
author = await Database.authorModel.create({
|
|
||||||
name: authorName,
|
|
||||||
lastFirst: Database.authorModel.getLastFirst(authorName),
|
|
||||||
libraryId
|
|
||||||
})
|
|
||||||
Logger.debug(`[ApiRouter] Creating new author "${author.name}"`)
|
|
||||||
newAuthors.push(author)
|
|
||||||
// Update filter data
|
|
||||||
Database.addAuthorToFilterData(libraryId, author.name, author.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update ID in original payload
|
|
||||||
mediaMetadata.authors[i].id = author.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove authors without an id
|
|
||||||
mediaMetadata.authors = mediaMetadata.authors.filter((au) => !!au.id)
|
|
||||||
if (newAuthors.length) {
|
|
||||||
SocketAuthority.emitter(
|
|
||||||
'authors_added',
|
|
||||||
newAuthors.map((au) => au.toOldJSON())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new series if in payload
|
|
||||||
if (mediaMetadata.series && mediaMetadata.series.length) {
|
|
||||||
const newSeries = []
|
|
||||||
for (let i = 0; i < mediaMetadata.series.length; i++) {
|
|
||||||
const seriesName = (mediaMetadata.series[i].name || '').trim()
|
|
||||||
if (!seriesName) {
|
|
||||||
Logger.error(`[ApiRouter] Invalid series object, no name`, mediaMetadata.series[i])
|
|
||||||
mediaMetadata.series[i].id = null
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaMetadata.series[i].id?.startsWith('new')) {
|
|
||||||
mediaMetadata.series[i].id = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the ID for the series exists
|
|
||||||
if (mediaMetadata.series[i].id && !(await Database.checkSeriesExists(libraryId, mediaMetadata.series[i].id))) {
|
|
||||||
Logger.warn(`[ApiRouter] Series id "${mediaMetadata.series[i].id}" does not exist`)
|
|
||||||
mediaMetadata.series[i].id = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mediaMetadata.series[i].id) {
|
|
||||||
let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesName, libraryId)
|
|
||||||
if (!seriesItem) {
|
|
||||||
seriesItem = await Database.seriesModel.create({
|
|
||||||
name: seriesName,
|
|
||||||
nameIgnorePrefix: getTitleIgnorePrefix(seriesName),
|
|
||||||
libraryId
|
|
||||||
})
|
|
||||||
Logger.debug(`[ApiRouter] Creating new series "${seriesItem.name}"`)
|
|
||||||
newSeries.push(seriesItem)
|
|
||||||
// Update filter data
|
|
||||||
Database.addSeriesToFilterData(libraryId, seriesItem.name, seriesItem.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update ID in original payload
|
|
||||||
mediaMetadata.series[i].id = seriesItem.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove series without an id
|
|
||||||
mediaMetadata.series = mediaMetadata.series.filter((se) => se.id)
|
|
||||||
if (newSeries.length) {
|
|
||||||
SocketAuthority.emitter(
|
|
||||||
'multiple_series_added',
|
|
||||||
newSeries.map((se) => se.toOldJSON())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
module.exports = ApiRouter
|
module.exports = ApiRouter
|
||||||
|
|||||||
@@ -499,16 +499,17 @@ class AudioFileScanner {
|
|||||||
// Filter these out and log a warning
|
// Filter these out and log a warning
|
||||||
// See https://github.com/advplyr/audiobookshelf/issues/3361
|
// See https://github.com/advplyr/audiobookshelf/issues/3361
|
||||||
const afChaptersCleaned =
|
const afChaptersCleaned =
|
||||||
file.chapters?.filter((c) => {
|
file.chapters?.filter((c, i) => {
|
||||||
if (c.end - c.start < 0.1) {
|
if (c.end - c.start < 0.1) {
|
||||||
libraryScan.addLog(LogLevel.WARN, `Chapter "${c.title}" has invalid duration of ${c.end - c.start} seconds. Skipping this chapter.`)
|
libraryScan.addLog(LogLevel.WARN, `Audio file "${file.metadata.filename}" Chapter "${c.title}" (index ${i}) has invalid duration of ${c.end - c.start} seconds. Skipping this chapter.`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}) || []
|
}) || []
|
||||||
const afChapters = afChaptersCleaned.map((c) => ({
|
|
||||||
|
const afChapters = afChaptersCleaned.map((c, i) => ({
|
||||||
...c,
|
...c,
|
||||||
id: c.id + currChapterId,
|
id: currChapterId + i,
|
||||||
start: c.start + currStartTime,
|
start: c.start + currStartTime,
|
||||||
end: c.end + currStartTime
|
end: c.end + currStartTime
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -582,7 +582,7 @@ class LibraryScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if book dir group is already an item
|
// Check if book dir group is already an item
|
||||||
let existingLibraryItem = await Database.libraryItemModel.findOneOld({
|
let existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
|
||||||
libraryId: library.id,
|
libraryId: library.id,
|
||||||
path: potentialChildDirs
|
path: potentialChildDirs
|
||||||
})
|
})
|
||||||
@@ -606,17 +606,17 @@ class LibraryScanner {
|
|||||||
if (existingLibraryItem.path === fullPath) {
|
if (existingLibraryItem.path === fullPath) {
|
||||||
const exists = await fs.pathExists(fullPath)
|
const exists = await fs.pathExists(fullPath)
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.metadata.title}" - marking as missing`)
|
Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.title}" - marking as missing`)
|
||||||
existingLibraryItem.setMissing()
|
existingLibraryItem.isMissing = true
|
||||||
await Database.updateLibraryItem(existingLibraryItem)
|
await existingLibraryItem.save()
|
||||||
SocketAuthority.emitter('item_updated', existingLibraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', existingLibraryItem.toOldJSONExpanded())
|
||||||
|
|
||||||
itemGroupingResults[itemDir] = ScanResult.REMOVED
|
itemGroupingResults[itemDir] = ScanResult.REMOVED
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Scan library item for updates
|
// Scan library item for updates
|
||||||
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" with id "${existingLibraryItem.id}" - scan for updates`)
|
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.title}" with id "${existingLibraryItem.id}" - scan for updates`)
|
||||||
itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails)
|
itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails)
|
||||||
continue
|
continue
|
||||||
} else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) {
|
} else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) {
|
||||||
@@ -672,7 +672,7 @@ function isSingleMediaFile(fileUpdateGroup, itemDir) {
|
|||||||
async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) {
|
async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) {
|
||||||
const ino = await fileUtils.getIno(fullPath)
|
const ino = await fileUtils.getIno(fullPath)
|
||||||
if (!ino) return null
|
if (!ino) return null
|
||||||
const existingLibraryItem = await Database.libraryItemModel.findOneOld({
|
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
|
||||||
libraryId: libraryId,
|
libraryId: libraryId,
|
||||||
ino: ino
|
ino: ino
|
||||||
})
|
})
|
||||||
@@ -685,7 +685,7 @@ async function findLibraryItemByItemToFileInoMatch(libraryId, fullPath, isSingle
|
|||||||
// check if it was moved from another folder by comparing the ino to the library files
|
// check if it was moved from another folder by comparing the ino to the library files
|
||||||
const ino = await fileUtils.getIno(fullPath)
|
const ino = await fileUtils.getIno(fullPath)
|
||||||
if (!ino) return null
|
if (!ino) return null
|
||||||
const existingLibraryItem = await Database.libraryItemModel.findOneOld(
|
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
libraryId: libraryId
|
libraryId: libraryId
|
||||||
@@ -711,7 +711,7 @@ async function findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingle
|
|||||||
if (ino) itemFileInos.push(ino)
|
if (ino) itemFileInos.push(ino)
|
||||||
}
|
}
|
||||||
if (!itemFileInos.length) return null
|
if (!itemFileInos.length) return null
|
||||||
const existingLibraryItem = await Database.libraryItemModel.findOneOld({
|
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
|
||||||
libraryId: libraryId,
|
libraryId: libraryId,
|
||||||
ino: {
|
ino: {
|
||||||
[sequelize.Op.in]: itemFileInos
|
[sequelize.Op.in]: itemFileInos
|
||||||
|
|||||||
+193
-110
@@ -30,14 +30,14 @@ class Scanner {
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {import('../routers/ApiRouter')} apiRouterCtx
|
* @param {import('../routers/ApiRouter')} apiRouterCtx
|
||||||
* @param {import('../objects/LibraryItem')} libraryItem
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
* @param {QuickMatchOptions} options
|
* @param {QuickMatchOptions} options
|
||||||
* @returns {Promise<{updated: boolean, libraryItem: import('../objects/LibraryItem')}>}
|
* @returns {Promise<{updated: boolean, libraryItem: Object}>}
|
||||||
*/
|
*/
|
||||||
async quickMatchLibraryItem(apiRouterCtx, libraryItem, options = {}) {
|
async quickMatchLibraryItem(apiRouterCtx, libraryItem, options = {}) {
|
||||||
const provider = options.provider || 'google'
|
const provider = options.provider || 'google'
|
||||||
const searchTitle = options.title || libraryItem.media.metadata.title
|
const searchTitle = options.title || libraryItem.media.title
|
||||||
const searchAuthor = options.author || libraryItem.media.metadata.authorName
|
const searchAuthor = options.author || libraryItem.media.authorName
|
||||||
|
|
||||||
// If overrideCover and overrideDetails is not sent in options than use the server setting to determine if we should override
|
// If overrideCover and overrideDetails is not sent in options than use the server setting to determine if we should override
|
||||||
if (options.overrideCover === undefined && options.overrideDetails === undefined && Database.serverSettings.scannerPreferMatchedMetadata) {
|
if (options.overrideCover === undefined && options.overrideDetails === undefined && Database.serverSettings.scannerPreferMatchedMetadata) {
|
||||||
@@ -52,11 +52,11 @@ class Scanner {
|
|||||||
let existingSeries = []
|
let existingSeries = []
|
||||||
|
|
||||||
if (libraryItem.isBook) {
|
if (libraryItem.isBook) {
|
||||||
existingAuthors = libraryItem.media.metadata.authors.map((a) => a.id)
|
existingAuthors = libraryItem.media.authors.map((a) => a.id)
|
||||||
existingSeries = libraryItem.media.metadata.series.map((s) => s.id)
|
existingSeries = libraryItem.media.series.map((s) => s.id)
|
||||||
|
|
||||||
const searchISBN = options.isbn || libraryItem.media.metadata.isbn
|
const searchISBN = options.isbn || libraryItem.media.isbn
|
||||||
const searchASIN = options.asin || libraryItem.media.metadata.asin
|
const searchASIN = options.asin || libraryItem.media.asin
|
||||||
|
|
||||||
const results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
|
const results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
@@ -69,15 +69,21 @@ class Scanner {
|
|||||||
// Update cover if not set OR overrideCover flag
|
// Update cover if not set OR overrideCover flag
|
||||||
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
|
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
|
||||||
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
|
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
|
||||||
var coverResult = await CoverManager.downloadCoverFromUrl(libraryItem, matchData.cover)
|
const coverResult = await CoverManager.downloadCoverFromUrlNew(matchData.cover, libraryItem.id, libraryItem.isFile ? null : libraryItem.path)
|
||||||
if (!coverResult || coverResult.error || !coverResult.cover) {
|
if (coverResult.error) {
|
||||||
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`)
|
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult.error}`)
|
||||||
} else {
|
} else {
|
||||||
|
libraryItem.media.coverPath = coverResult.cover
|
||||||
|
libraryItem.media.changed('coverPath', true) // Cover path may be the same but this forces the update
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePayload = await this.quickMatchBookBuildUpdatePayload(libraryItem, matchData, options)
|
const bookBuildUpdateData = await this.quickMatchBookBuildUpdatePayload(apiRouterCtx, libraryItem, matchData, options)
|
||||||
|
updatePayload = bookBuildUpdateData.updatePayload
|
||||||
|
if (bookBuildUpdateData.hasSeriesUpdates || bookBuildUpdateData.hasAuthorUpdates) {
|
||||||
|
hasUpdated = true
|
||||||
|
}
|
||||||
} else if (libraryItem.isPodcast) {
|
} else if (libraryItem.isPodcast) {
|
||||||
// Podcast quick match
|
// Podcast quick match
|
||||||
const results = await PodcastFinder.search(searchTitle)
|
const results = await PodcastFinder.search(searchTitle)
|
||||||
@@ -91,10 +97,12 @@ class Scanner {
|
|||||||
// Update cover if not set OR overrideCover flag
|
// Update cover if not set OR overrideCover flag
|
||||||
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
|
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
|
||||||
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
|
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
|
||||||
var coverResult = await CoverManager.downloadCoverFromUrl(libraryItem, matchData.cover)
|
const coverResult = await CoverManager.downloadCoverFromUrlNew(matchData.cover, libraryItem.id, libraryItem.path)
|
||||||
if (!coverResult || coverResult.error || !coverResult.cover) {
|
if (coverResult.error) {
|
||||||
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`)
|
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult.error}`)
|
||||||
} else {
|
} else {
|
||||||
|
libraryItem.media.coverPath = coverResult.cover
|
||||||
|
libraryItem.media.changed('coverPath', true) // Cover path may be the same but this forces the update
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,44 +111,45 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updatePayload).length) {
|
if (Object.keys(updatePayload).length) {
|
||||||
Logger.debug('[Scanner] Updating details', updatePayload)
|
Logger.debug('[Scanner] Updating details with payload', updatePayload)
|
||||||
if (libraryItem.media.update(updatePayload)) {
|
libraryItem.media.set(updatePayload)
|
||||||
|
if (libraryItem.media.changed()) {
|
||||||
|
Logger.debug(`[Scanner] Updating library item "${libraryItem.media.title}" keys`, libraryItem.media.changed())
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
if (libraryItem.isPodcast && libraryItem.media.metadata.feedUrl) {
|
if (libraryItem.isPodcast && libraryItem.media.feedURL) {
|
||||||
// Quick match all unmatched podcast episodes
|
// Quick match all unmatched podcast episodes
|
||||||
await this.quickMatchPodcastEpisodes(libraryItem, options)
|
await this.quickMatchPodcastEpisodes(libraryItem, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
await Database.updateLibraryItem(libraryItem)
|
await libraryItem.media.save()
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
||||||
|
|
||||||
// Check if any authors or series are now empty and should be removed
|
libraryItem.changed('updatedAt', true)
|
||||||
if (libraryItem.isBook) {
|
await libraryItem.save()
|
||||||
const authorsRemoved = existingAuthors.filter((aid) => !libraryItem.media.metadata.authors.find((au) => au.id === aid))
|
|
||||||
const seriesRemoved = existingSeries.filter((sid) => !libraryItem.media.metadata.series.find((se) => se.id === sid))
|
|
||||||
|
|
||||||
if (authorsRemoved.length) {
|
await libraryItem.saveMetadataFile()
|
||||||
await apiRouterCtx.checkRemoveAuthorsWithNoBooks(authorsRemoved)
|
|
||||||
}
|
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||||
if (seriesRemoved.length) {
|
|
||||||
await apiRouterCtx.checkRemoveEmptySeries(seriesRemoved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updated: hasUpdated,
|
updated: hasUpdated,
|
||||||
libraryItem: libraryItem.toJSONExpanded()
|
libraryItem: libraryItem.toOldJSONExpanded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
|
* @param {*} matchData
|
||||||
|
* @param {QuickMatchOptions} options
|
||||||
|
* @returns {Map<string, any>} - Update payload
|
||||||
|
*/
|
||||||
quickMatchPodcastBuildUpdatePayload(libraryItem, matchData, options) {
|
quickMatchPodcastBuildUpdatePayload(libraryItem, matchData, options) {
|
||||||
const updatePayload = {}
|
const updatePayload = {}
|
||||||
updatePayload.metadata = {}
|
|
||||||
|
|
||||||
const matchDataTransformed = {
|
const matchDataTransformed = {
|
||||||
title: matchData.title || null,
|
title: matchData.title || null,
|
||||||
@@ -158,7 +167,7 @@ class Scanner {
|
|||||||
for (const key in matchDataTransformed) {
|
for (const key in matchDataTransformed) {
|
||||||
if (matchDataTransformed[key]) {
|
if (matchDataTransformed[key]) {
|
||||||
if (key === 'genres') {
|
if (key === 'genres') {
|
||||||
if (!libraryItem.media.metadata.genres.length || options.overrideDetails) {
|
if (!libraryItem.media.genres.length || options.overrideDetails) {
|
||||||
var genresArray = []
|
var genresArray = []
|
||||||
if (Array.isArray(matchDataTransformed[key])) genresArray = [...matchDataTransformed[key]]
|
if (Array.isArray(matchDataTransformed[key])) genresArray = [...matchDataTransformed[key]]
|
||||||
else {
|
else {
|
||||||
@@ -169,46 +178,42 @@ class Scanner {
|
|||||||
.map((v) => v.trim())
|
.map((v) => v.trim())
|
||||||
.filter((v) => !!v)
|
.filter((v) => !!v)
|
||||||
}
|
}
|
||||||
updatePayload.metadata[key] = genresArray
|
updatePayload[key] = genresArray
|
||||||
}
|
}
|
||||||
} else if (libraryItem.media.metadata[key] !== matchDataTransformed[key] && (!libraryItem.media.metadata[key] || options.overrideDetails)) {
|
} else if (libraryItem.media[key] !== matchDataTransformed[key] && (!libraryItem.media[key] || options.overrideDetails)) {
|
||||||
updatePayload.metadata[key] = matchDataTransformed[key]
|
updatePayload[key] = matchDataTransformed[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.keys(updatePayload.metadata).length) {
|
|
||||||
delete updatePayload.metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatePayload
|
return updatePayload
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {import('../objects/LibraryItem')} libraryItem
|
* @param {import('../routers/ApiRouter')} apiRouterCtx
|
||||||
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
* @param {*} matchData
|
* @param {*} matchData
|
||||||
* @param {QuickMatchOptions} options
|
* @param {QuickMatchOptions} options
|
||||||
* @returns
|
* @returns {Promise<{updatePayload: Map<string, any>, seriesIdsRemoved: string[], hasSeriesUpdates: boolean, authorIdsRemoved: string[], hasAuthorUpdates: boolean}>}
|
||||||
*/
|
*/
|
||||||
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) {
|
async quickMatchBookBuildUpdatePayload(apiRouterCtx, libraryItem, matchData, options) {
|
||||||
// Update media metadata if not set OR overrideDetails flag
|
// Update media metadata if not set OR overrideDetails flag
|
||||||
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn']
|
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn']
|
||||||
const updatePayload = {}
|
const updatePayload = {}
|
||||||
updatePayload.metadata = {}
|
|
||||||
|
|
||||||
for (const key in matchData) {
|
for (const key in matchData) {
|
||||||
if (matchData[key] && detailKeysToUpdate.includes(key)) {
|
if (matchData[key] && detailKeysToUpdate.includes(key)) {
|
||||||
if (key === 'narrator') {
|
if (key === 'narrator') {
|
||||||
if (!libraryItem.media.metadata.narratorName || options.overrideDetails) {
|
if (!libraryItem.media.narrators?.length || options.overrideDetails) {
|
||||||
updatePayload.metadata.narrators = matchData[key]
|
updatePayload.narrators = matchData[key]
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((v) => v.trim())
|
.map((v) => v.trim())
|
||||||
.filter((v) => !!v)
|
.filter((v) => !!v)
|
||||||
}
|
}
|
||||||
} else if (key === 'genres') {
|
} else if (key === 'genres') {
|
||||||
if (!libraryItem.media.metadata.genres.length || options.overrideDetails) {
|
if (!libraryItem.media.genres.length || options.overrideDetails) {
|
||||||
var genresArray = []
|
let genresArray = []
|
||||||
if (Array.isArray(matchData[key])) genresArray = [...matchData[key]]
|
if (Array.isArray(matchData[key])) genresArray = [...matchData[key]]
|
||||||
else {
|
else {
|
||||||
// Genres should always be passed in as an array but just incase handle a string
|
// Genres should always be passed in as an array but just incase handle a string
|
||||||
@@ -218,11 +223,11 @@ class Scanner {
|
|||||||
.map((v) => v.trim())
|
.map((v) => v.trim())
|
||||||
.filter((v) => !!v)
|
.filter((v) => !!v)
|
||||||
}
|
}
|
||||||
updatePayload.metadata[key] = genresArray
|
updatePayload[key] = genresArray
|
||||||
}
|
}
|
||||||
} else if (key === 'tags') {
|
} else if (key === 'tags') {
|
||||||
if (!libraryItem.media.tags.length || options.overrideDetails) {
|
if (!libraryItem.media.tags.length || options.overrideDetails) {
|
||||||
var tagsArray = []
|
let tagsArray = []
|
||||||
if (Array.isArray(matchData[key])) tagsArray = [...matchData[key]]
|
if (Array.isArray(matchData[key])) tagsArray = [...matchData[key]]
|
||||||
else
|
else
|
||||||
tagsArray = matchData[key]
|
tagsArray = matchData[key]
|
||||||
@@ -231,94 +236,174 @@ class Scanner {
|
|||||||
.filter((v) => !!v)
|
.filter((v) => !!v)
|
||||||
updatePayload[key] = tagsArray
|
updatePayload[key] = tagsArray
|
||||||
}
|
}
|
||||||
} else if (!libraryItem.media.metadata[key] || options.overrideDetails) {
|
} else if (!libraryItem.media[key] || options.overrideDetails) {
|
||||||
updatePayload.metadata[key] = matchData[key]
|
updatePayload[key] = matchData[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add or set author if not set
|
// Add or set author if not set
|
||||||
if (matchData.author && (!libraryItem.media.metadata.authorName || options.overrideDetails)) {
|
let hasAuthorUpdates = false
|
||||||
|
if (matchData.author && (!libraryItem.media.authorName || options.overrideDetails)) {
|
||||||
if (!Array.isArray(matchData.author)) {
|
if (!Array.isArray(matchData.author)) {
|
||||||
matchData.author = matchData.author
|
matchData.author = matchData.author
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((au) => au.trim())
|
.map((au) => au.trim())
|
||||||
.filter((au) => !!au)
|
.filter((au) => !!au)
|
||||||
}
|
}
|
||||||
const authorPayload = []
|
const authorIdsRemoved = []
|
||||||
for (const authorName of matchData.author) {
|
for (const authorName of matchData.author) {
|
||||||
let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryItem.libraryId)
|
const existingAuthor = libraryItem.media.authors.find((a) => a.name.toLowerCase() === authorName.toLowerCase())
|
||||||
if (!author) {
|
if (!existingAuthor) {
|
||||||
author = await Database.authorModel.create({
|
let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryItem.libraryId)
|
||||||
name: authorName,
|
if (!author) {
|
||||||
lastFirst: Database.authorModel.getLastFirst(authorName),
|
author = await Database.authorModel.create({
|
||||||
libraryId: libraryItem.libraryId
|
name: authorName,
|
||||||
})
|
lastFirst: Database.authorModel.getLastFirst(authorName),
|
||||||
SocketAuthority.emitter('author_added', author.toOldJSON())
|
libraryId: libraryItem.libraryId
|
||||||
// Update filter data
|
})
|
||||||
Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id)
|
SocketAuthority.emitter('author_added', author.toOldJSON())
|
||||||
|
// Update filter data
|
||||||
|
Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id)
|
||||||
|
|
||||||
|
await Database.bookAuthorModel
|
||||||
|
.create({
|
||||||
|
authorId: author.id,
|
||||||
|
bookId: libraryItem.media.id
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Added author "${author.name}" to "${libraryItem.media.title}"`)
|
||||||
|
libraryItem.media.authors.push(author)
|
||||||
|
hasAuthorUpdates = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const authorsRemoved = libraryItem.media.authors.filter((a) => !matchData.author.find((ma) => ma.toLowerCase() === a.name.toLowerCase()))
|
||||||
|
if (authorsRemoved.length) {
|
||||||
|
for (const author of authorsRemoved) {
|
||||||
|
await Database.bookAuthorModel.destroy({ where: { authorId: author.id, bookId: libraryItem.media.id } })
|
||||||
|
libraryItem.media.authors = libraryItem.media.authors.filter((a) => a.id !== author.id)
|
||||||
|
authorIdsRemoved.push(author.id)
|
||||||
|
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Removed author "${author.name}" from "${libraryItem.media.title}"`)
|
||||||
|
}
|
||||||
|
hasAuthorUpdates = true
|
||||||
}
|
}
|
||||||
authorPayload.push(author.toJSONMinimal())
|
|
||||||
}
|
}
|
||||||
updatePayload.metadata.authors = authorPayload
|
|
||||||
|
// For all authors removed from book, check if they are empty now and should be removed
|
||||||
|
if (authorIdsRemoved.length) {
|
||||||
|
await apiRouterCtx.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add or set series if not set
|
// Add or set series if not set
|
||||||
if (matchData.series && (!libraryItem.media.metadata.seriesName || options.overrideDetails)) {
|
let hasSeriesUpdates = false
|
||||||
|
if (matchData.series && (!libraryItem.media.seriesName || options.overrideDetails)) {
|
||||||
if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, sequence: matchData.sequence }]
|
if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, sequence: matchData.sequence }]
|
||||||
const seriesPayload = []
|
const seriesIdsRemoved = []
|
||||||
for (const seriesMatchItem of matchData.series) {
|
for (const seriesMatchItem of matchData.series) {
|
||||||
let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId)
|
const existingSeries = libraryItem.media.series.find((s) => s.name.toLowerCase() === seriesMatchItem.series.toLowerCase())
|
||||||
if (!seriesItem) {
|
if (existingSeries) {
|
||||||
seriesItem = await Database.seriesModel.create({
|
if (existingSeries.bookSeries.sequence !== seriesMatchItem.sequence) {
|
||||||
name: seriesMatchItem.series,
|
existingSeries.bookSeries.sequence = seriesMatchItem.sequence
|
||||||
nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series),
|
await existingSeries.bookSeries.save()
|
||||||
libraryId: libraryItem.libraryId
|
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Updated series sequence for "${existingSeries.name}" to ${seriesMatchItem.sequence} in "${libraryItem.media.title}"`)
|
||||||
|
hasSeriesUpdates = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId)
|
||||||
|
if (!seriesItem) {
|
||||||
|
seriesItem = await Database.seriesModel.create({
|
||||||
|
name: seriesMatchItem.series,
|
||||||
|
nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series),
|
||||||
|
libraryId: libraryItem.libraryId
|
||||||
|
})
|
||||||
|
// Update filter data
|
||||||
|
Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id)
|
||||||
|
SocketAuthority.emitter('series_added', seriesItem.toOldJSON())
|
||||||
|
}
|
||||||
|
const bookSeries = await Database.bookSeriesModel.create({
|
||||||
|
seriesId: seriesItem.id,
|
||||||
|
bookId: libraryItem.media.id,
|
||||||
|
sequence: seriesMatchItem.sequence
|
||||||
})
|
})
|
||||||
// Update filter data
|
seriesItem.bookSeries = bookSeries
|
||||||
Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id)
|
libraryItem.media.series.push(seriesItem)
|
||||||
SocketAuthority.emitter('series_added', seriesItem.toOldJSON())
|
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Added series "${seriesItem.name}" to "${libraryItem.media.title}"`)
|
||||||
|
hasSeriesUpdates = true
|
||||||
|
}
|
||||||
|
const seriesRemoved = libraryItem.media.series.filter((s) => !matchData.series.find((ms) => ms.series.toLowerCase() === s.name.toLowerCase()))
|
||||||
|
if (seriesRemoved.length) {
|
||||||
|
for (const series of seriesRemoved) {
|
||||||
|
await series.bookSeries.destroy()
|
||||||
|
libraryItem.media.series = libraryItem.media.series.filter((s) => s.id !== series.id)
|
||||||
|
seriesIdsRemoved.push(series.id)
|
||||||
|
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Removed series "${series.name}" from "${libraryItem.media.title}"`)
|
||||||
|
}
|
||||||
|
hasSeriesUpdates = true
|
||||||
}
|
}
|
||||||
seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.sequence))
|
|
||||||
}
|
}
|
||||||
updatePayload.metadata.series = seriesPayload
|
|
||||||
|
// For all series removed from book, check if it is empty now and should be removed
|
||||||
|
if (seriesIdsRemoved.length) {
|
||||||
|
await apiRouterCtx.checkRemoveEmptySeries(seriesIdsRemoved)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.keys(updatePayload.metadata).length) {
|
return {
|
||||||
delete updatePayload.metadata
|
updatePayload,
|
||||||
|
hasSeriesUpdates,
|
||||||
|
hasAuthorUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatePayload
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
|
* @param {QuickMatchOptions} options
|
||||||
|
* @returns {Promise<number>} - Number of episodes updated
|
||||||
|
*/
|
||||||
async quickMatchPodcastEpisodes(libraryItem, options = {}) {
|
async quickMatchPodcastEpisodes(libraryItem, options = {}) {
|
||||||
const episodesToQuickMatch = libraryItem.media.episodes.filter((ep) => !ep.enclosureUrl) // Only quick match episodes without enclosure
|
/** @type {import('../models/PodcastEpisode')[]} */
|
||||||
if (!episodesToQuickMatch.length) return false
|
const episodesToQuickMatch = libraryItem.media.podcastEpisodes.filter((ep) => !ep.enclosureURL) // Only quick match episodes that are not already matched
|
||||||
|
if (!episodesToQuickMatch.length) return 0
|
||||||
|
|
||||||
const feed = await getPodcastFeed(libraryItem.media.metadata.feedUrl)
|
const feed = await getPodcastFeed(libraryItem.media.feedURL)
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
Logger.error(`[Scanner] quickMatchPodcastEpisodes: Unable to quick match episodes feed not found for "${libraryItem.media.metadata.feedUrl}"`)
|
Logger.error(`[Scanner] quickMatchPodcastEpisodes: Unable to quick match episodes feed not found for "${libraryItem.media.feedURL}"`)
|
||||||
return false
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
let numEpisodesUpdated = 0
|
let numEpisodesUpdated = 0
|
||||||
for (const episode of episodesToQuickMatch) {
|
for (const episode of episodesToQuickMatch) {
|
||||||
const episodeMatches = findMatchingEpisodesInFeed(feed, episode.title)
|
const episodeMatches = findMatchingEpisodesInFeed(feed, episode.title)
|
||||||
if (episodeMatches && episodeMatches.length) {
|
if (episodeMatches?.length) {
|
||||||
const wasUpdated = this.updateEpisodeWithMatch(libraryItem, episode, episodeMatches[0].episode, options)
|
const wasUpdated = await this.updateEpisodeWithMatch(episode, episodeMatches[0].episode, options)
|
||||||
if (wasUpdated) numEpisodesUpdated++
|
if (wasUpdated) numEpisodesUpdated++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (numEpisodesUpdated) {
|
||||||
|
Logger.info(`[Scanner] quickMatchPodcastEpisodes: Updated ${numEpisodesUpdated} episodes for "${libraryItem.media.title}"`)
|
||||||
|
}
|
||||||
return numEpisodesUpdated
|
return numEpisodesUpdated
|
||||||
}
|
}
|
||||||
|
|
||||||
updateEpisodeWithMatch(libraryItem, episode, episodeToMatch, options = {}) {
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../models/PodcastEpisode')} episode
|
||||||
|
* @param {import('../utils/podcastUtils').RssPodcastEpisode} episodeToMatch
|
||||||
|
* @param {QuickMatchOptions} options
|
||||||
|
* @returns {Promise<boolean>} - true if episode was updated
|
||||||
|
*/
|
||||||
|
async updateEpisodeWithMatch(episode, episodeToMatch, options = {}) {
|
||||||
Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Found episode match for "${episode.title}" => ${episodeToMatch.title}`)
|
Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Found episode match for "${episode.title}" => ${episodeToMatch.title}`)
|
||||||
const matchDataTransformed = {
|
const matchDataTransformed = {
|
||||||
title: episodeToMatch.title || '',
|
title: episodeToMatch.title || '',
|
||||||
subtitle: episodeToMatch.subtitle || '',
|
subtitle: episodeToMatch.subtitle || '',
|
||||||
description: episodeToMatch.description || '',
|
description: episodeToMatch.description || '',
|
||||||
enclosure: episodeToMatch.enclosure || null,
|
enclosureURL: episodeToMatch.enclosure?.url || null,
|
||||||
|
enclosureSize: episodeToMatch.enclosure?.length || null,
|
||||||
|
enclosureType: episodeToMatch.enclosure?.type || null,
|
||||||
episode: episodeToMatch.episode || '',
|
episode: episodeToMatch.episode || '',
|
||||||
episodeType: episodeToMatch.episodeType || 'full',
|
episodeType: episodeToMatch.episodeType || 'full',
|
||||||
season: episodeToMatch.season || '',
|
season: episodeToMatch.season || '',
|
||||||
@@ -328,20 +413,19 @@ class Scanner {
|
|||||||
const updatePayload = {}
|
const updatePayload = {}
|
||||||
for (const key in matchDataTransformed) {
|
for (const key in matchDataTransformed) {
|
||||||
if (matchDataTransformed[key]) {
|
if (matchDataTransformed[key]) {
|
||||||
if (key === 'enclosure') {
|
if (episode[key] !== matchDataTransformed[key] && (!episode[key] || options.overrideDetails)) {
|
||||||
if (!episode.enclosure || JSON.stringify(episode.enclosure) !== JSON.stringify(matchDataTransformed.enclosure)) {
|
|
||||||
updatePayload[key] = {
|
|
||||||
...matchDataTransformed.enclosure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (episode[key] !== matchDataTransformed[key] && (!episode[key] || options.overrideDetails)) {
|
|
||||||
updatePayload[key] = matchDataTransformed[key]
|
updatePayload[key] = matchDataTransformed[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updatePayload).length) {
|
if (Object.keys(updatePayload).length) {
|
||||||
return libraryItem.media.updateEpisode(episode.id, updatePayload)
|
episode.set(updatePayload)
|
||||||
|
if (episode.changed()) {
|
||||||
|
Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Updating episode "${episode.title}" keys`, episode.changed())
|
||||||
|
await episode.save()
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -351,7 +435,7 @@ class Scanner {
|
|||||||
*
|
*
|
||||||
* @param {import('../routers/ApiRouter')} apiRouterCtx
|
* @param {import('../routers/ApiRouter')} apiRouterCtx
|
||||||
* @param {import('../models/Library')} library
|
* @param {import('../models/Library')} library
|
||||||
* @param {import('../objects/LibraryItem')[]} libraryItems
|
* @param {import('../models/LibraryItem')[]} libraryItems
|
||||||
* @param {LibraryScan} libraryScan
|
* @param {LibraryScan} libraryScan
|
||||||
* @returns {Promise<boolean>} false if scan canceled
|
* @returns {Promise<boolean>} false if scan canceled
|
||||||
*/
|
*/
|
||||||
@@ -359,20 +443,20 @@ class Scanner {
|
|||||||
for (let i = 0; i < libraryItems.length; i++) {
|
for (let i = 0; i < libraryItems.length; i++) {
|
||||||
const libraryItem = libraryItems[i]
|
const libraryItem = libraryItems[i]
|
||||||
|
|
||||||
if (libraryItem.media.metadata.asin && library.settings.skipMatchingMediaWithAsin) {
|
if (libraryItem.media.asin && library.settings.skipMatchingMediaWithAsin) {
|
||||||
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title}" because it already has an ASIN (${i + 1} of ${libraryItems.length})`)
|
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.title}" because it already has an ASIN (${i + 1} of ${libraryItems.length})`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItem.media.metadata.isbn && library.settings.skipMatchingMediaWithIsbn) {
|
if (libraryItem.media.isbn && library.settings.skipMatchingMediaWithIsbn) {
|
||||||
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title}" because it already has an ISBN (${i + 1} of ${libraryItems.length})`)
|
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.title}" because it already has an ISBN (${i + 1} of ${libraryItems.length})`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${libraryItems.length})`)
|
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.title}" (${i + 1} of ${libraryItems.length})`)
|
||||||
const result = await this.quickMatchLibraryItem(apiRouterCtx, libraryItem, { provider: library.provider })
|
const result = await this.quickMatchLibraryItem(apiRouterCtx, libraryItem, { provider: library.provider })
|
||||||
if (result.warning) {
|
if (result.warning) {
|
||||||
Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.metadata.title}"`)
|
Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.title}"`)
|
||||||
} else if (result.updated) {
|
} else if (result.updated) {
|
||||||
libraryScan.resultsUpdated++
|
libraryScan.resultsUpdated++
|
||||||
}
|
}
|
||||||
@@ -430,9 +514,8 @@ class Scanner {
|
|||||||
|
|
||||||
offset += limit
|
offset += limit
|
||||||
hasMoreChunks = libraryItems.length === limit
|
hasMoreChunks = libraryItems.length === limit
|
||||||
let oldLibraryItems = libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li))
|
|
||||||
|
|
||||||
const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, oldLibraryItems, libraryScan)
|
const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan)
|
||||||
if (!shouldContinue) {
|
if (!shouldContinue) {
|
||||||
isCanceled = true
|
isCanceled = true
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -189,8 +189,14 @@ class CbzStreamZipComicBookExtractor extends AbstractComicBookExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.archive?.close()
|
this.archive
|
||||||
Logger.debug(`[CbzStreamZipComicBookExtractor] Closed comic book "${this.comicPath}"`)
|
?.close()
|
||||||
|
.then(() => {
|
||||||
|
Logger.debug(`[CbzStreamZipComicBookExtractor] Closed comic book "${this.comicPath}"`)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
Logger.error(`[CbzStreamZipComicBookExtractor] Failed to close comic book "${this.comicPath}"`, error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ const fs = require('../libs/fsExtra')
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { filePathToPOSIX, copyToExisting } = require('./fileUtils')
|
const { filePathToPOSIX, copyToExisting } = require('./fileUtils')
|
||||||
const LibraryItem = require('../objects/LibraryItem')
|
|
||||||
|
|
||||||
function escapeSingleQuotes(path) {
|
function escapeSingleQuotes(path) {
|
||||||
// return path.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, '\\ ').replace(/'/g, "\\'")
|
return filePathToPOSIX(path).replace(/'/g, "'\\''")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns first track start time
|
// Returns first track start time
|
||||||
@@ -33,7 +32,7 @@ async function writeConcatFile(tracks, outputPath, startTime = 0) {
|
|||||||
|
|
||||||
var tracksToInclude = tracks.filter((t) => t.index >= trackToStartWithIndex)
|
var tracksToInclude = tracks.filter((t) => t.index >= trackToStartWithIndex)
|
||||||
var trackPaths = tracksToInclude.map((t) => {
|
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
|
return line
|
||||||
})
|
})
|
||||||
var inputstr = trackPaths.join('\n\n')
|
var inputstr = trackPaths.join('\n\n')
|
||||||
@@ -100,7 +99,7 @@ module.exports.resizeImage = resizeImage
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
|
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
|
||||||
* @returns
|
* @returns {Promise<{success: boolean, isFfmpegError?: boolean}>}
|
||||||
*/
|
*/
|
||||||
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
||||||
return new Promise(async (resolve) => {
|
return new Promise(async (resolve) => {
|
||||||
@@ -111,12 +110,16 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
|||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)'
|
'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)'
|
||||||
},
|
},
|
||||||
timeout: 30000
|
timeout: global.PodcastDownloadTimeout
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
Logger.error(`[ffmpegHelpers] Failed to download podcast episode with url "${podcastEpisodeDownload.url}"`, error)
|
Logger.error(`[ffmpegHelpers] Failed to download podcast episode with url "${podcastEpisodeDownload.url}"`, error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (!response) return resolve(false)
|
if (!response) {
|
||||||
|
return resolve({
|
||||||
|
success: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */
|
/** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */
|
||||||
const ffmpeg = Ffmpeg(response.data)
|
const ffmpeg = Ffmpeg(response.data)
|
||||||
@@ -178,7 +181,10 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
|||||||
if (stderrLines.length) {
|
if (stderrLines.length) {
|
||||||
Logger.error(`Full stderr dump for episode url "${podcastEpisodeDownload.url}": ${stderrLines.join('\n')}`)
|
Logger.error(`Full stderr dump for episode url "${podcastEpisodeDownload.url}": ${stderrLines.join('\n')}`)
|
||||||
}
|
}
|
||||||
resolve(false)
|
resolve({
|
||||||
|
success: false,
|
||||||
|
isFfmpegError: true
|
||||||
|
})
|
||||||
})
|
})
|
||||||
ffmpeg.on('progress', (progress) => {
|
ffmpeg.on('progress', (progress) => {
|
||||||
let progressPercent = 0
|
let progressPercent = 0
|
||||||
@@ -190,7 +196,9 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
|||||||
})
|
})
|
||||||
ffmpeg.on('end', () => {
|
ffmpeg.on('end', () => {
|
||||||
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Complete`)
|
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Complete`)
|
||||||
resolve(podcastEpisodeDownload.targetPath)
|
resolve({
|
||||||
|
success: true
|
||||||
|
})
|
||||||
})
|
})
|
||||||
ffmpeg.run()
|
ffmpeg.run()
|
||||||
})
|
})
|
||||||
@@ -365,28 +373,26 @@ function escapeFFMetadataValue(value) {
|
|||||||
/**
|
/**
|
||||||
* Retrieves the FFmpeg metadata object for a given library item.
|
* Retrieves the FFmpeg metadata object for a given library item.
|
||||||
*
|
*
|
||||||
* @param {LibraryItem} libraryItem - The library item containing the media metadata.
|
* @param {import('../models/LibraryItem')} libraryItem - The library item containing the media metadata.
|
||||||
* @param {number} audioFilesLength - The length of the audio files.
|
* @param {number} audioFilesLength - The length of the audio files.
|
||||||
* @returns {Object} - The FFmpeg metadata object.
|
* @returns {Object} - The FFmpeg metadata object.
|
||||||
*/
|
*/
|
||||||
function getFFMetadataObject(libraryItem, audioFilesLength) {
|
function getFFMetadataObject(libraryItem, audioFilesLength) {
|
||||||
const metadata = libraryItem.media.metadata
|
|
||||||
|
|
||||||
const ffmetadata = {
|
const ffmetadata = {
|
||||||
title: metadata.title,
|
title: libraryItem.media.title,
|
||||||
artist: metadata.authorName,
|
artist: libraryItem.media.authorName,
|
||||||
album_artist: metadata.authorName,
|
album_artist: libraryItem.media.authorName,
|
||||||
album: (metadata.title || '') + (metadata.subtitle ? `: ${metadata.subtitle}` : ''),
|
album: (libraryItem.media.title || '') + (libraryItem.media.subtitle ? `: ${libraryItem.media.subtitle}` : ''),
|
||||||
TIT3: metadata.subtitle, // mp3 only
|
TIT3: libraryItem.media.subtitle, // mp3 only
|
||||||
genre: metadata.genres?.join('; '),
|
genre: libraryItem.media.genres?.join('; '),
|
||||||
date: metadata.publishedYear,
|
date: libraryItem.media.publishedYear,
|
||||||
comment: metadata.description,
|
comment: libraryItem.media.description,
|
||||||
description: metadata.description,
|
description: libraryItem.media.description,
|
||||||
composer: metadata.narratorName,
|
composer: (libraryItem.media.narrators || []).join(', '),
|
||||||
copyright: metadata.publisher,
|
copyright: libraryItem.media.publisher,
|
||||||
publisher: metadata.publisher, // mp3 only
|
publisher: libraryItem.media.publisher, // mp3 only
|
||||||
TRACKTOTAL: `${audioFilesLength}`, // mp3 only
|
TRACKTOTAL: `${audioFilesLength}`, // mp3 only
|
||||||
grouping: metadata.series?.map((s) => s.name + (s.sequence ? ` #${s.sequence}` : '')).join('; ')
|
grouping: libraryItem.media.series?.map((s) => s.name + (s.bookSeries.sequence ? ` #${s.bookSeries.sequence}` : '')).join('; ')
|
||||||
}
|
}
|
||||||
Object.keys(ffmetadata).forEach((key) => {
|
Object.keys(ffmetadata).forEach((key) => {
|
||||||
if (!ffmetadata[key]) {
|
if (!ffmetadata[key]) {
|
||||||
@@ -402,7 +408,7 @@ module.exports.getFFMetadataObject = getFFMetadataObject
|
|||||||
/**
|
/**
|
||||||
* Merges audio files into a single output file using FFmpeg.
|
* Merges audio files into a single output file using FFmpeg.
|
||||||
*
|
*
|
||||||
* @param {Array} audioTracks - The audio tracks to merge.
|
* @param {import('../models/Book').AudioFileObject} audioTracks - The audio tracks to merge.
|
||||||
* @param {number} duration - The total duration of the audio tracks.
|
* @param {number} duration - The total duration of the audio tracks.
|
||||||
* @param {string} itemCachePath - The path to the item cache.
|
* @param {string} itemCachePath - The path to the item cache.
|
||||||
* @param {string} outputFilePath - The path to the output file.
|
* @param {string} outputFilePath - The path to the output file.
|
||||||
|
|||||||
@@ -6,35 +6,41 @@ const naturalSort = createNewSortInstance({
|
|||||||
})
|
})
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getSeriesFromBooks(books, filterSeries, hideSingleBookSeries) {
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../models/LibraryItem')[]} libraryItems
|
||||||
|
* @param {*} filterSeries
|
||||||
|
* @param {*} hideSingleBookSeries
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries) {
|
||||||
const _series = {}
|
const _series = {}
|
||||||
const seriesToFilterOut = {}
|
const seriesToFilterOut = {}
|
||||||
books.forEach((libraryItem) => {
|
libraryItems.forEach((libraryItem) => {
|
||||||
// get all book series for item that is not already filtered out
|
// get all book series for item that is not already filtered out
|
||||||
const bookSeries = (libraryItem.media.metadata.series || []).filter((se) => !seriesToFilterOut[se.id])
|
const allBookSeries = (libraryItem.media.series || []).filter((se) => !seriesToFilterOut[se.id])
|
||||||
if (!bookSeries.length) return
|
if (!allBookSeries.length) return
|
||||||
|
|
||||||
bookSeries.forEach((bookSeriesObj) => {
|
allBookSeries.forEach((bookSeries) => {
|
||||||
// const series = allSeries.find(se => se.id === bookSeriesObj.id)
|
const abJson = libraryItem.toOldJSONMinified()
|
||||||
|
abJson.sequence = bookSeries.bookSeries.sequence
|
||||||
const abJson = libraryItem.toJSONMinified()
|
|
||||||
abJson.sequence = bookSeriesObj.sequence
|
|
||||||
if (filterSeries) {
|
if (filterSeries) {
|
||||||
abJson.filterSeriesSequence = libraryItem.media.metadata.getSeries(filterSeries).sequence
|
const series = libraryItem.media.series.find((se) => se.id === filterSeries)
|
||||||
|
abJson.filterSeriesSequence = series.bookSeries.sequence
|
||||||
}
|
}
|
||||||
if (!_series[bookSeriesObj.id]) {
|
if (!_series[bookSeries.id]) {
|
||||||
_series[bookSeriesObj.id] = {
|
_series[bookSeries.id] = {
|
||||||
id: bookSeriesObj.id,
|
id: bookSeries.id,
|
||||||
name: bookSeriesObj.name,
|
name: bookSeries.name,
|
||||||
nameIgnorePrefix: getTitlePrefixAtEnd(bookSeriesObj.name),
|
nameIgnorePrefix: getTitlePrefixAtEnd(bookSeries.name),
|
||||||
nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeriesObj.name),
|
nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeries.name),
|
||||||
type: 'series',
|
type: 'series',
|
||||||
books: [abJson],
|
books: [abJson],
|
||||||
totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
|
totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_series[bookSeriesObj.id].books.push(abJson)
|
_series[bookSeries.id].books.push(abJson)
|
||||||
_series[bookSeriesObj.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
|
_series[bookSeries.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -52,6 +58,13 @@ module.exports = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../models/LibraryItem')[]} libraryItems
|
||||||
|
* @param {string} filterSeries - series id
|
||||||
|
* @param {boolean} hideSingleBookSeries
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
collapseBookSeries(libraryItems, filterSeries, hideSingleBookSeries) {
|
collapseBookSeries(libraryItems, filterSeries, hideSingleBookSeries) {
|
||||||
// Get series from the library items. If this list is being collapsed after filtering for a series,
|
// Get series from the library items. If this list is being collapsed after filtering for a series,
|
||||||
// don't collapse that series, only books that are in other series.
|
// don't collapse that series, only books that are in other series.
|
||||||
@@ -123,8 +136,9 @@ module.exports = {
|
|||||||
let libraryItems = books
|
let libraryItems = books
|
||||||
.map((book) => {
|
.map((book) => {
|
||||||
const libraryItem = book.libraryItem
|
const libraryItem = book.libraryItem
|
||||||
|
delete book.libraryItem
|
||||||
libraryItem.media = book
|
libraryItem.media = book
|
||||||
return Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
return libraryItem
|
||||||
})
|
})
|
||||||
.filter((li) => {
|
.filter((li) => {
|
||||||
return user.checkCanAccessLibraryItem(li)
|
return user.checkCanAccessLibraryItem(li)
|
||||||
@@ -143,15 +157,18 @@ module.exports = {
|
|||||||
if (!payload.sortBy || payload.sortBy === 'sequence') {
|
if (!payload.sortBy || payload.sortBy === 'sequence') {
|
||||||
sortArray = [
|
sortArray = [
|
||||||
{
|
{
|
||||||
[direction]: (li) => li.media.metadata.getSeries(seriesId).sequence
|
[direction]: (li) => {
|
||||||
|
const series = li.media.series.find((se) => se.id === seriesId)
|
||||||
|
return series.bookSeries.sequence
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
|
// If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
|
||||||
[direction]: (li) => {
|
[direction]: (li) => {
|
||||||
if (sortingIgnorePrefix) {
|
if (sortingIgnorePrefix) {
|
||||||
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
|
return li.collapsedSeries?.nameIgnorePrefix || li.media.titleIgnorePrefix
|
||||||
} else {
|
} else {
|
||||||
return li.collapsedSeries?.name || li.media.metadata.title
|
return li.collapsedSeries?.name || li.media.title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,9 +191,9 @@ module.exports = {
|
|||||||
[direction]: (li) => {
|
[direction]: (li) => {
|
||||||
if (payload.sortBy === 'media.metadata.title') {
|
if (payload.sortBy === 'media.metadata.title') {
|
||||||
if (sortingIgnorePrefix) {
|
if (sortingIgnorePrefix) {
|
||||||
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
|
return li.collapsedSeries?.nameIgnorePrefix || li.media.titleIgnorePrefix
|
||||||
} else {
|
} else {
|
||||||
return li.collapsedSeries?.name || li.media.metadata.title
|
return li.collapsedSeries?.name || li.media.title
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return payload.sortBy.split('.').reduce((a, b) => a[b], li)
|
return payload.sortBy.split('.').reduce((a, b) => a[b], li)
|
||||||
@@ -194,12 +211,12 @@ module.exports = {
|
|||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
libraryItems.map(async (li) => {
|
libraryItems.map(async (li) => {
|
||||||
const filteredSeries = li.media.metadata.getSeries(seriesId)
|
const filteredSeries = li.media.series.find((se) => se.id === seriesId)
|
||||||
const json = li.toJSONMinified()
|
const json = li.toOldJSONMinified()
|
||||||
json.media.metadata.series = {
|
json.media.metadata.series = {
|
||||||
id: filteredSeries.id,
|
id: filteredSeries.id,
|
||||||
name: filteredSeries.name,
|
name: filteredSeries.name,
|
||||||
sequence: filteredSeries.sequence
|
sequence: filteredSeries.bookSeries.sequence
|
||||||
}
|
}
|
||||||
|
|
||||||
if (li.collapsedSeries) {
|
if (li.collapsedSeries) {
|
||||||
|
|||||||
@@ -1200,7 +1200,7 @@ async function migrationPatchNewColumns(queryInterface) {
|
|||||||
*/
|
*/
|
||||||
async function handleOldLibraryItems(ctx) {
|
async function handleOldLibraryItems(ctx) {
|
||||||
const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')
|
const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')
|
||||||
const libraryItems = (await ctx.models.libraryItem.findAllExpandedWhere()).map((li) => ctx.models.libraryItem.getOldLibraryItem(li))
|
const libraryItems = await ctx.models.libraryItem.findAllExpandedWhere()
|
||||||
|
|
||||||
const bulkUpdateItems = []
|
const bulkUpdateItems = []
|
||||||
const bulkUpdateEpisodes = []
|
const bulkUpdateEpisodes = []
|
||||||
@@ -1218,8 +1218,8 @@ async function handleOldLibraryItems(ctx) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (libraryItem.media.episodes?.length && matchingOldLibraryItem.media.episodes?.length) {
|
if (libraryItem.media.podcastEpisodes?.length && matchingOldLibraryItem.media.episodes?.length) {
|
||||||
for (const podcastEpisode of libraryItem.media.episodes) {
|
for (const podcastEpisode of libraryItem.media.podcastEpisodes) {
|
||||||
// Find matching old episode by audio file ino
|
// Find matching old episode by audio file ino
|
||||||
const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find((oep) => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino)
|
const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find((oep) => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino)
|
||||||
if (matchingOldPodcastEpisode) {
|
if (matchingOldPodcastEpisode) {
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ async function parse(ebookFile) {
|
|||||||
archive = createComicBookExtractor(comicPath)
|
archive = createComicBookExtractor(comicPath)
|
||||||
await archive.open()
|
await archive.open()
|
||||||
|
|
||||||
const filePaths = await archive.getFilePaths()
|
const filePaths = await archive.getFilePaths().catch((error) => {
|
||||||
|
Logger.error(`[parseComicMetadata] Failed to get file paths from comic at "${comicPath}"`, error)
|
||||||
|
})
|
||||||
|
|
||||||
// Sort the file paths in a natural order to get the first image
|
// Sort the file paths in a natural order to get the first image
|
||||||
filePaths.sort((a, b) => {
|
filePaths.sort((a, b) => {
|
||||||
|
|||||||
@@ -52,6 +52,29 @@ function extractFirstArrayItem(json, key) {
|
|||||||
return json[key][0]
|
return json[key][0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractStringOrStringify(json) {
|
||||||
|
try {
|
||||||
|
if (typeof json[Object.keys(json)[0]]?.[0] === 'string') {
|
||||||
|
return json[Object.keys(json)[0]][0]
|
||||||
|
}
|
||||||
|
// Handles case where html was included without being wrapped in CDATA
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFirstArrayItemString(json, key) {
|
||||||
|
const item = extractFirstArrayItem(json, key)
|
||||||
|
if (!item) return ''
|
||||||
|
if (typeof item === 'object') {
|
||||||
|
if (item?.['_'] && typeof item['_'] === 'string') return item['_']
|
||||||
|
|
||||||
|
return extractStringOrStringify(item)
|
||||||
|
}
|
||||||
|
return typeof item === 'string' ? item : ''
|
||||||
|
}
|
||||||
|
|
||||||
function extractImage(channel) {
|
function extractImage(channel) {
|
||||||
if (!channel.image || !channel.image.url || !channel.image.url.length) {
|
if (!channel.image || !channel.image.url || !channel.image.url.length) {
|
||||||
if (!channel['itunes:image'] || !channel['itunes:image'].length || !channel['itunes:image'][0]['$']) {
|
if (!channel['itunes:image'] || !channel['itunes:image'].length || !channel['itunes:image'][0]['$']) {
|
||||||
@@ -101,7 +124,7 @@ function extractPodcastMetadata(channel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (channel['description']) {
|
if (channel['description']) {
|
||||||
const rawDescription = extractFirstArrayItem(channel, 'description') || ''
|
const rawDescription = extractFirstArrayItemString(channel, 'description')
|
||||||
metadata.description = htmlSanitizer.sanitize(rawDescription.trim())
|
metadata.description = htmlSanitizer.sanitize(rawDescription.trim())
|
||||||
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
|
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
|
||||||
}
|
}
|
||||||
@@ -145,7 +168,8 @@ function extractEpisodeData(item) {
|
|||||||
|
|
||||||
// Supposed to be the plaintext description but not always followed
|
// Supposed to be the plaintext description but not always followed
|
||||||
if (item['description']) {
|
if (item['description']) {
|
||||||
const rawDescription = extractFirstArrayItem(item, 'description') || ''
|
const rawDescription = extractFirstArrayItemString(item, 'description')
|
||||||
|
|
||||||
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription.trim())
|
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription.trim())
|
||||||
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
|
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
|
||||||
}
|
}
|
||||||
@@ -175,9 +199,7 @@ function extractEpisodeData(item) {
|
|||||||
const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
|
const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
|
||||||
arrayFields.forEach((key) => {
|
arrayFields.forEach((key) => {
|
||||||
const cleanKey = key.split(':').pop()
|
const cleanKey = key.split(':').pop()
|
||||||
let value = extractFirstArrayItem(item, key)
|
episode[cleanKey] = extractFirstArrayItemString(item, key)
|
||||||
if (value?.['_']) value = value['_']
|
|
||||||
episode[cleanKey] = value
|
|
||||||
})
|
})
|
||||||
return episode
|
return episode
|
||||||
}
|
}
|
||||||
@@ -281,7 +303,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
|||||||
return axios({
|
return axios({
|
||||||
url: feedUrl,
|
url: feedUrl,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
timeout: 12000,
|
timeout: global.PodcastDownloadTimeout,
|
||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8',
|
Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8',
|
||||||
@@ -330,6 +352,12 @@ module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => {
|
|||||||
return this.findMatchingEpisodesInFeed(feed, searchTitle)
|
return this.findMatchingEpisodesInFeed(feed, searchTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {RssPodcast} feed
|
||||||
|
* @param {string} searchTitle
|
||||||
|
* @returns {Array<{ episode: RssPodcastEpisode, levenshtein: number }>}
|
||||||
|
*/
|
||||||
module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
|
module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
|
||||||
searchTitle = searchTitle.toLowerCase().trim()
|
searchTitle = searchTitle.toLowerCase().trim()
|
||||||
if (!feed?.episodes) {
|
if (!feed?.episodes) {
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ function parseChapters(_chapters) {
|
|||||||
.map((chap) => {
|
.map((chap) => {
|
||||||
let title = chap['TAG:title'] || chap.title || ''
|
let title = chap['TAG:title'] || chap.title || ''
|
||||||
if (!title && chap.tags?.title) title = chap.tags.title
|
if (!title && chap.tags?.title) title = chap.tags.title
|
||||||
|
title = title.trim()
|
||||||
|
|
||||||
const timebase = chap.time_base?.includes('/') ? Number(chap.time_base.split('/')[1]) : 1
|
const timebase = chap.time_base?.includes('/') ? Number(chap.time_base.split('/')[1]) : 1
|
||||||
const start = !isNullOrNaN(chap.start_time) ? Number(chap.start_time) : !isNullOrNaN(chap.start) ? Number(chap.start) / timebase : 0
|
const start = !isNullOrNaN(chap.start_time) ? Number(chap.start_time) : !isNullOrNaN(chap.start) ? Number(chap.start) / timebase : 0
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ module.exports = {
|
|||||||
* @param {import('../../models/User')} user
|
* @param {import('../../models/User')} user
|
||||||
* @param {number} limit
|
* @param {number} limit
|
||||||
* @param {number} offset
|
* @param {number} offset
|
||||||
* @returns {Promise<{ libraryItems:import('../../objects/LibraryItem')[], count:number }>}
|
* @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}
|
||||||
*/
|
*/
|
||||||
async getLibraryItemsForAuthor(author, user, limit, offset) {
|
async getLibraryItemsForAuthor(author, user, limit, offset) {
|
||||||
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(author.libraryId, user, 'authors', author.id, 'addedAt', true, false, [], limit, offset)
|
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(author.libraryId, user, 'authors', author.id, 'addedAt', true, false, [], limit, offset)
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ module.exports = {
|
|||||||
delete podcast.libraryItem
|
delete podcast.libraryItem
|
||||||
libraryItem.media = podcast
|
libraryItem.media = podcast
|
||||||
|
|
||||||
libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSON()
|
libraryItem.recentEpisode = ep.toOldJSON(libraryItem.id)
|
||||||
return libraryItem
|
return libraryItem
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -460,13 +460,14 @@ module.exports = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const episodeResults = episodes.map((ep) => {
|
const episodeResults = episodes.map((ep) => {
|
||||||
const libraryItem = ep.podcast.libraryItem
|
ep.podcast.podcastEpisodes = [] // Not needed
|
||||||
libraryItem.media = ep.podcast
|
const oldPodcastJson = ep.podcast.toOldJSON(ep.podcast.libraryItem.id)
|
||||||
const oldPodcast = Database.podcastModel.getOldPodcast(libraryItem)
|
|
||||||
const oldPodcastEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSONExpanded()
|
const oldPodcastEpisodeJson = ep.toOldJSONExpanded(ep.podcast.libraryItem.id)
|
||||||
oldPodcastEpisode.podcast = oldPodcast
|
|
||||||
oldPodcastEpisode.libraryId = libraryItem.libraryId
|
oldPodcastEpisodeJson.podcast = oldPodcastJson
|
||||||
return oldPodcastEpisode
|
oldPodcastEpisodeJson.libraryId = ep.podcast.libraryItem.libraryId
|
||||||
|
return oldPodcastEpisodeJson
|
||||||
})
|
})
|
||||||
|
|
||||||
return episodeResults
|
return episodeResults
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ describe('LibraryItemController', () => {
|
|||||||
|
|
||||||
it('should remove authors and series with no books on library item update media', async () => {
|
it('should remove authors and series with no books on library item update media', async () => {
|
||||||
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id)
|
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id)
|
||||||
|
libraryItem.saveMetadataFile = sinon.stub()
|
||||||
// Update library item 1 remove all authors and series
|
// Update library item 1 remove all authors and series
|
||||||
const fakeReq = {
|
const fakeReq = {
|
||||||
query: {},
|
query: {},
|
||||||
|
|||||||
Reference in New Issue
Block a user