mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-02 00:40:39 +02:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c881bcbe59 | |||
| 89aa4a8bdc | |||
| c5a4f63670 | |||
| 1b97582975 | |||
| 9b7aacf3ea | |||
| e40e0bfa25 | |||
| d56e3a3617 | |||
| 78fe6d47ba | |||
| 995cf51ae3 | |||
| d838ff2f2e | |||
| f2f07ff534 | |||
| 8cff68ca64 | |||
| eb5331d34a | |||
| f425185575 | |||
| 9fc352a5a4 | |||
| e85ddc1aa1 | |||
| b9be7510f8 | |||
| f4497acd48 | |||
| f73a0cce72 | |||
| 254ba1f089 | |||
| 0a179e4eed | |||
| 0ac63b2678 | |||
| 1d13d0a553 | |||
| fc6ff016a7 | |||
| e378b79fbc | |||
| 7e377297d7 | |||
| 00a02921dd | |||
| b5d4c11f6f | |||
| a0bc959850 | |||
| a4b0f6c202 | |||
| 65cf928afe | |||
| cf7fd315b6 | |||
| d86a3b3dc2 | |||
| e07e2cd359 | |||
| 8140d7021a | |||
| bdbc5e3161 | |||
| bb9013541b | |||
| 1668153acd | |||
| aeba7674f8 | |||
| 5b0d105e21 | |||
| feb54d0629 | |||
| 3284fe8f31 | |||
| 18cb394884 | |||
| d0bce2949e | |||
| a0e80772cd | |||
| e44595521d | |||
| fdf647eb32 | |||
| 71369bd2a0 | |||
| 36b1f43f4c | |||
| a8bc1df3e7 | |||
| a96869f547 | |||
| 77b030199e | |||
| 0e1c6c0ba7 | |||
| c397422d3b | |||
| 15313826bf | |||
| c6405b9013 | |||
| d748d43efc | |||
| d54edb93d6 | |||
| b8ca6671fc | |||
| cb7fb646ba |
+6
-1
@@ -14,7 +14,10 @@ RUN apk update && \
|
|||||||
apk add --no-cache --update \
|
apk add --no-cache --update \
|
||||||
curl \
|
curl \
|
||||||
tzdata \
|
tzdata \
|
||||||
ffmpeg
|
ffmpeg \
|
||||||
|
make \
|
||||||
|
python3 \
|
||||||
|
g++
|
||||||
|
|
||||||
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
|
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
|
||||||
COPY --from=build /client/dist /client/dist
|
COPY --from=build /client/dist /client/dist
|
||||||
@@ -23,6 +26,8 @@ COPY server server
|
|||||||
|
|
||||||
RUN npm ci --only=production
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
RUN apk del make python3 g++
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
HEALTHCHECK \
|
HEALTHCHECK \
|
||||||
--interval=30s \
|
--interval=30s \
|
||||||
|
|||||||
@@ -303,13 +303,13 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Batch update success!')
|
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error('Batch update failed')
|
this.$toast.error(this.$strings.ToastBatchUpdateFailed)
|
||||||
console.error('Failed to batch update read/not read', error)
|
console.error('Failed to batch update read/not read', error)
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -680,7 +680,6 @@ export default {
|
|||||||
.$patch(apiEndpoint, updatePayload)
|
.$patch(apiEndpoint, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
@@ -757,6 +756,8 @@ export default {
|
|||||||
this.store.commit('globals/setConfirmPrompt', payload)
|
this.store.commit('globals/setConfirmPrompt', payload)
|
||||||
},
|
},
|
||||||
removeSeriesFromContinueListening() {
|
removeSeriesFromContinueListening() {
|
||||||
|
if (!this.series) return
|
||||||
|
|
||||||
const axios = this.$axios || this.$nuxt.$axios
|
const axios = this.$axios || this.$nuxt.$axios
|
||||||
this.processing = true
|
this.processing = true
|
||||||
axios
|
axios
|
||||||
|
|||||||
@@ -271,12 +271,16 @@ export default {
|
|||||||
let filterValue = null
|
let filterValue = null
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
const decoded = this.$decode(parts[1])
|
const decoded = this.$decode(parts[1])
|
||||||
if (decoded.startsWith('aut_')) {
|
if (parts[0] === 'authors') {
|
||||||
const author = this.authors.find((au) => au.id == decoded)
|
const author = this.authors.find((au) => au.id == decoded)
|
||||||
if (author) filterValue = author.name
|
if (author) filterValue = author.name
|
||||||
} else if (decoded.startsWith('ser_')) {
|
} else if (parts[0] === 'series') {
|
||||||
const series = this.series.find((se) => se.id == decoded)
|
if (decoded === 'no-series') {
|
||||||
if (series) filterValue = series.name
|
filterValue = this.$strings.MessageNoSeries
|
||||||
|
} else {
|
||||||
|
const series = this.series.find((se) => se.id == decoded)
|
||||||
|
if (series) filterValue = series.name
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
filterValue = decoded
|
filterValue = decoded
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +1,99 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
|
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
|
||||||
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||||
<p class="text-base text-gray-200">{{ metadata.filename }}</p>
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p>
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
<ui-btn v-if="ffprobeData" small class="ml-2" @click="ffprobeData = null">{{ $strings.ButtonReset }}</ui-btn>
|
||||||
|
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">Probe Audio File</ui-btn>
|
||||||
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row text-sm">
|
|
||||||
<div class="w-full sm:w-1/2">
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelSize }}
|
|
||||||
</p>
|
|
||||||
<p>{{ $bytesPretty(metadata.size) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelDuration }}
|
|
||||||
</p>
|
|
||||||
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
|
|
||||||
<p>{{ audioFile.format }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelChapters }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.chapters?.length || 0 }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelEmbeddedCover }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-full sm:w-1/2">
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelCodec }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.codec }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelChannels }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelBitrate }}
|
|
||||||
</p>
|
|
||||||
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
|
|
||||||
<p>{{ audioFile.timeBase }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="audioFile.language" class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelLanguage }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.language || '' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
<template v-if="!ffprobeData">
|
||||||
|
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
||||||
|
|
||||||
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
|
<div class="flex flex-col sm:flex-row text-sm">
|
||||||
<p class="w-32 min-w-32 text-black-50 mb-1">
|
<div class="w-full sm:w-1/2">
|
||||||
{{ key.replace('tag', '') }}
|
<div class="flex mb-1">
|
||||||
</p>
|
<p class="w-32 text-black-50">
|
||||||
<p>{{ value }}</p>
|
{{ $strings.LabelSize }}
|
||||||
|
</p>
|
||||||
|
<p>{{ $bytesPretty(metadata.size) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelDuration }}
|
||||||
|
</p>
|
||||||
|
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
|
||||||
|
<p>{{ audioFile.format }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelChapters }}
|
||||||
|
</p>
|
||||||
|
<p>{{ audioFile.chapters?.length || 0 }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelEmbeddedCover }}
|
||||||
|
</p>
|
||||||
|
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full sm:w-1/2">
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelCodec }}
|
||||||
|
</p>
|
||||||
|
<p>{{ audioFile.codec }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelChannels }}
|
||||||
|
</p>
|
||||||
|
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelBitrate }}
|
||||||
|
</p>
|
||||||
|
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
|
||||||
|
<p>{{ audioFile.timeBase }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="audioFile.language" class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelLanguage }}
|
||||||
|
</p>
|
||||||
|
<p>{{ audioFile.language || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
|
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
||||||
|
|
||||||
|
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
|
||||||
|
<p class="w-32 min-w-32 text-black-50 mb-1">
|
||||||
|
{{ key.replace('tag', '') }}
|
||||||
|
</p>
|
||||||
|
<p>{{ value }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="w-full">
|
||||||
|
<div class="relative">
|
||||||
|
<ui-textarea-with-label :value="prettyFfprobeData" readonly :rows="30" class="text-xs" />
|
||||||
|
|
||||||
|
<button class="absolute top-4 right-4" :class="copiedToClipboard ? 'text-success' : 'text-white/50 hover:text-white/80'" @click.stop="copyFfprobeData">
|
||||||
|
<span class="material-icons">{{ copiedToClipboard ? 'check' : 'content_copy' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
@@ -91,10 +106,24 @@ export default {
|
|||||||
audioFile: {
|
audioFile: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
}
|
},
|
||||||
|
libraryItemId: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
probingFile: false,
|
||||||
|
ffprobeData: null,
|
||||||
|
copiedToClipboard: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.ffprobeData = null
|
||||||
|
this.copiedToClipboard = false
|
||||||
|
this.probingFile = false
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
@@ -110,9 +139,36 @@ export default {
|
|||||||
},
|
},
|
||||||
metaTags() {
|
metaTags() {
|
||||||
return this.audioFile?.metaTags || {}
|
return this.audioFile?.metaTags || {}
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
|
prettyFfprobeData() {
|
||||||
|
if (!this.ffprobeData) return ''
|
||||||
|
return JSON.stringify(this.ffprobeData, null, 2)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getFFProbeData() {
|
||||||
|
this.probingFile = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/items/${this.libraryItemId}/ffprobe/${this.audioFile.ino}`)
|
||||||
|
.then((data) => {
|
||||||
|
console.log('Got ffprobe data', data)
|
||||||
|
this.ffprobeData = data
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to get ffprobe data', error)
|
||||||
|
this.$toast.error('FFProbe failed')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.probingFile = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async copyFfprobeData() {
|
||||||
|
this.copiedToClipboard = await this.$copyToClipboard(this.prettyFfprobeData)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickedOption(action) {
|
clickedOption(action) {
|
||||||
this.$emit('action', action)
|
this.$emit('action', { action })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
<p class="text-lg md:text-2xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||||
@@ -50,19 +50,19 @@
|
|||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
|
||||||
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
|
||||||
<div class="px-1">
|
<div class="px-1 text-xs">
|
||||||
{{ _session.libraryId }}
|
{{ _session.libraryId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center -mx-1 mb-1">
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
|
||||||
<div class="px-1">
|
<div class="px-1 text-xs">
|
||||||
{{ _session.libraryItemId }}
|
{{ _session.libraryItemId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
|
||||||
<div class="px-1">
|
<div class="px-1 text-xs">
|
||||||
{{ _session.episodeId }}
|
{{ _session.episodeId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-full md:w-1/3">
|
<div class="w-full md:w-1/3">
|
||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
||||||
<p class="mb-1">{{ _session.userId }}</p>
|
<p class="mb-1 text-xs">{{ _session.userId }}</p>
|
||||||
|
|
||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
||||||
<p class="mb-1">{{ playMethodName }}</p>
|
<p class="mb-1">{{ playMethodName }}</p>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
||||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||||
|
|
||||||
<div class="absolute top-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
|
||||||
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||||
</div>
|
</button>
|
||||||
<slot name="outer" />
|
<slot name="outer" />
|
||||||
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||||
<slot />
|
<slot />
|
||||||
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
|
|||||||
@@ -8,10 +8,9 @@
|
|||||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
<template v-if="!showImageUploader">
|
<template v-if="!showImageUploader">
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div class="flex">
|
<div class="flex flex-wrap">
|
||||||
<div>
|
<div class="w-full flex justify-center mb-2 md:w-auto md:mb-0 md:block">
|
||||||
<covers-collection-cover :book-items="books" :width="200" :height="100 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-collection-cover :book-items="books" :width="200" :height="100 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<!-- <ui-btn type="button" @click="showImageUploader = true">Upload</ui-btn> -->
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-4">
|
<div class="flex-grow px-4">
|
||||||
<ui-text-input-with-label v-model="newCollectionName" :label="$strings.LabelName" class="mb-2" />
|
<ui-text-input-with-label v-model="newCollectionName" :label="$strings.LabelName" class="mb-2" />
|
||||||
@@ -41,7 +40,6 @@
|
|||||||
<ui-btn color="success">Upload</ui-btn>
|
<ui-btn color="success">Upload</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- <modals-upload-image-modal v-model="showUploadImageModal" entity="collection" :entity-id="collection.id" /> -->
|
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -38,6 +38,17 @@
|
|||||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
|
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isBookLibrary" class="py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="hideSingleBookSeries" @input="formUpdated" />
|
||||||
|
<ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp">
|
||||||
|
<p class="pl-4 text-base">
|
||||||
|
{{ $strings.LabelSettingsHideSingleBookSeries }}
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -57,7 +68,8 @@ export default {
|
|||||||
disableWatcher: false,
|
disableWatcher: false,
|
||||||
skipMatchingMediaWithAsin: false,
|
skipMatchingMediaWithAsin: false,
|
||||||
skipMatchingMediaWithIsbn: false,
|
skipMatchingMediaWithIsbn: false,
|
||||||
audiobooksOnly: false
|
audiobooksOnly: false,
|
||||||
|
hideSingleBookSeries: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -86,7 +98,8 @@ export default {
|
|||||||
disableWatcher: !!this.disableWatcher,
|
disableWatcher: !!this.disableWatcher,
|
||||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
||||||
audiobooksOnly: !!this.audiobooksOnly
|
audiobooksOnly: !!this.audiobooksOnly,
|
||||||
|
hideSingleBookSeries: !!this.hideSingleBookSeries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -99,6 +112,7 @@ export default {
|
|||||||
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||||
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
||||||
|
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end pt-4">
|
<div class="flex justify-end pt-4">
|
||||||
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" label="Select all episodes" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
|
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" :label="selectAllLabel" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
|
||||||
<ui-btn v-if="!allDownloaded" :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
<ui-btn v-if="!allDownloaded" :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
||||||
<p v-else class="text-success text-base px-2 py-4">All episodes are downloaded</p>
|
<p v-else class="text-success text-base px-2 py-4">All episodes are downloaded</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,46 +99,82 @@ export default {
|
|||||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||||
},
|
},
|
||||||
buttonText() {
|
buttonText() {
|
||||||
if (!this.episodesSelected.length) return 'No Episodes Selected'
|
if (!this.episodesSelected.length) return this.$strings.LabelNoEpisodesSelected
|
||||||
return `Download ${this.episodesSelected.length} Episode${this.episodesSelected.length > 1 ? 's' : ''}`
|
if (this.episodesSelected.length === 1) return `${this.$strings.LabelDownload} ${this.$strings.LabelEpisode.toLowerCase()}`
|
||||||
|
return this.$getString('LabelDownloadNEpisodes', [this.episodesSelected.length])
|
||||||
},
|
},
|
||||||
itemEpisodes() {
|
itemEpisodes() {
|
||||||
if (!this.libraryItem) return []
|
if (!this.libraryItem) return []
|
||||||
return this.libraryItem.media.episodes || []
|
return this.libraryItem.media.episodes || []
|
||||||
},
|
},
|
||||||
itemEpisodeMap() {
|
itemEpisodeMap() {
|
||||||
var map = {}
|
const map = {}
|
||||||
this.itemEpisodes.forEach((item) => {
|
this.itemEpisodes.forEach((item) => {
|
||||||
if (item.enclosure) map[item.enclosure.url.split('?')[0]] = true
|
if (item.enclosure) {
|
||||||
|
const cleanUrl = this.getCleanEpisodeUrl(item.enclosure.url)
|
||||||
|
map[cleanUrl] = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return map
|
return map
|
||||||
},
|
},
|
||||||
episodesList() {
|
episodesList() {
|
||||||
return this.episodesCleaned.filter((episode) => {
|
return this.episodesCleaned.filter((episode) => {
|
||||||
if (!this.searchText) return true
|
if (!this.searchText) return true
|
||||||
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
selectAllLabel() {
|
||||||
|
if (this.episodesList.length === this.episodesCleaned.length) {
|
||||||
|
return this.$strings.LabelSelectAllEpisodes
|
||||||
|
}
|
||||||
|
const episodesNotDownloaded = this.episodesList.filter((ep) => !this.itemEpisodeMap[ep.cleanUrl]).length
|
||||||
|
return this.$getString('LabelSelectEpisodesShowing', [episodesNotDownloaded])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
/**
|
||||||
|
* RSS feed episode url is used for matching with existing downloaded episodes.
|
||||||
|
* Some RSS feeds include timestamps in the episode url (e.g. patreon) that can change on requests.
|
||||||
|
* These need to be removed in order to detect the same episode each time the feed is pulled.
|
||||||
|
*
|
||||||
|
* An RSS feed may include an `id` in the query string. In these cases we want to leave the `id`.
|
||||||
|
* @see https://github.com/advplyr/audiobookshelf/issues/1896
|
||||||
|
*
|
||||||
|
* @param {string} url - rss feed episode url
|
||||||
|
* @returns {string} rss feed episode url without dynamic query strings
|
||||||
|
*/
|
||||||
|
getCleanEpisodeUrl(url) {
|
||||||
|
let queryString = url.split('?')[1]
|
||||||
|
if (!queryString) return url
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(queryString)
|
||||||
|
for (const p of Array.from(searchParams.keys())) {
|
||||||
|
if (p !== 'id') searchParams.delete(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searchParams.toString()) return url
|
||||||
|
return `${url}?${searchParams.toString()}`
|
||||||
|
},
|
||||||
inputUpdate() {
|
inputUpdate() {
|
||||||
clearTimeout(this.searchTimeout)
|
clearTimeout(this.searchTimeout)
|
||||||
this.searchTimeout = setTimeout(() => {
|
this.searchTimeout = setTimeout(() => {
|
||||||
if (!this.search || !this.search.trim()) {
|
if (!this.search?.trim()) {
|
||||||
this.searchText = ''
|
this.searchText = ''
|
||||||
|
this.checkSetIsSelectedAll()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.searchText = this.search.toLowerCase().trim()
|
this.searchText = this.search.toLowerCase().trim()
|
||||||
|
this.checkSetIsSelectedAll()
|
||||||
}, 500)
|
}, 500)
|
||||||
},
|
},
|
||||||
toggleSelectAll(val) {
|
toggleSelectAll(val) {
|
||||||
for (const episode of this.episodesCleaned) {
|
for (const episode of this.episodesList) {
|
||||||
if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false
|
if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false
|
||||||
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
|
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
checkSetIsSelectedAll() {
|
checkSetIsSelectedAll() {
|
||||||
for (const episode of this.episodesCleaned) {
|
for (const episode of this.episodesList) {
|
||||||
if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) {
|
if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) {
|
||||||
this.selectAll = false
|
this.selectAll = false
|
||||||
return
|
return
|
||||||
@@ -147,19 +183,19 @@ export default {
|
|||||||
this.selectAll = true
|
this.selectAll = true
|
||||||
},
|
},
|
||||||
toggleSelectEpisode(episode) {
|
toggleSelectEpisode(episode) {
|
||||||
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
|
if (this.itemEpisodeMap[episode.cleanUrl]) return
|
||||||
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
|
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
|
||||||
this.checkSetIsSelectedAll()
|
this.checkSetIsSelectedAll()
|
||||||
},
|
},
|
||||||
submit() {
|
submit() {
|
||||||
var episodesToDownload = []
|
let episodesToDownload = []
|
||||||
if (this.episodesSelected.length) {
|
if (this.episodesSelected.length) {
|
||||||
episodesToDownload = this.episodesSelected.map((cleanUrl) => this.episodesCleaned.find((ep) => ep.cleanUrl == cleanUrl))
|
episodesToDownload = this.episodesSelected.map((cleanUrl) => this.episodesCleaned.find((ep) => ep.cleanUrl == cleanUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
var payloadSize = JSON.stringify(episodesToDownload).length
|
const payloadSize = JSON.stringify(episodesToDownload).length
|
||||||
var sizeInMb = payloadSize / 1024 / 1024
|
const sizeInMb = payloadSize / 1024 / 1024
|
||||||
var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
||||||
console.log('Request size', sizeInMb)
|
console.log('Request size', sizeInMb)
|
||||||
if (sizeInMb > 4.99) {
|
if (sizeInMb > 4.99) {
|
||||||
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
||||||
@@ -174,10 +210,9 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
|
|
||||||
console.error('Failed to download episodes', error)
|
console.error('Failed to download episodes', error)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(error.response?.data || 'Failed to download episodes')
|
||||||
|
|
||||||
this.selectedEpisodes = {}
|
this.selectedEpisodes = {}
|
||||||
this.selectAll = false
|
this.selectAll = false
|
||||||
@@ -189,7 +224,7 @@ export default {
|
|||||||
.map((_ep) => {
|
.map((_ep) => {
|
||||||
return {
|
return {
|
||||||
..._ep,
|
..._ep,
|
||||||
cleanUrl: _ep.enclosure.url.split('?')[0]
|
cleanUrl: this.getCleanEpisodeUrl(_ep.enclosure.url)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ export default {
|
|||||||
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
|
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
|
||||||
},
|
},
|
||||||
canGoNext() {
|
canGoNext() {
|
||||||
return this.page < this.numPages - 1
|
return this.page < this.numPages
|
||||||
},
|
},
|
||||||
canGoPrev() {
|
canGoPrev() {
|
||||||
return this.page > 0
|
return this.page > 1
|
||||||
},
|
},
|
||||||
userMediaProgress() {
|
userMediaProgress() {
|
||||||
if (!this.libraryItemId) return
|
if (!this.libraryItemId) return
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="epub-reader" class="h-full w-full">
|
<div id="epub-reader" class="h-full w-full">
|
||||||
<div class="h-full flex items-center justify-center">
|
<div class="h-full flex items-center justify-center">
|
||||||
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center overflow-x-hidden justify-center">
|
<button type="button" aria-label="Previous page" class="w-24 max-w-24 h-full hidden sm:flex items-center overflow-x-hidden justify-center opacity-50 hover:opacity-100">
|
||||||
<span v-if="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
<span v-if="hasPrev" class="material-icons text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
||||||
</div>
|
</button>
|
||||||
<div id="frame" class="w-full" style="height: 80%">
|
<div id="frame" class="w-full" style="height: 80%">
|
||||||
<div id="viewer"></div>
|
<div id="viewer"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center justify-center overflow-x-hidden">
|
<button type="button" aria-label="Next page" class="w-24 max-w-24 h-full hidden sm:flex items-center justify-center overflow-x-hidden opacity-50 hover:opacity-100">
|
||||||
<span v-if="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
<span v-if="hasNext" class="material-icons text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -39,7 +39,13 @@ export default {
|
|||||||
/** @type {ePub.Book} */
|
/** @type {ePub.Book} */
|
||||||
book: null,
|
book: null,
|
||||||
/** @type {ePub.Rendition} */
|
/** @type {ePub.Rendition} */
|
||||||
rendition: null
|
rendition: null,
|
||||||
|
ereaderSettings: {
|
||||||
|
theme: 'dark',
|
||||||
|
fontScale: 100,
|
||||||
|
lineSpacing: 115,
|
||||||
|
spread: 'auto'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -63,7 +69,7 @@ export default {
|
|||||||
},
|
},
|
||||||
/** @returns {Array<ePub.NavItem>} */
|
/** @returns {Array<ePub.NavItem>} */
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.book ? this.book.navigation.toc : []
|
return this.book?.navigation?.toc || []
|
||||||
},
|
},
|
||||||
userMediaProgress() {
|
userMediaProgress() {
|
||||||
if (!this.libraryItemId) return
|
if (!this.libraryItemId) return
|
||||||
@@ -92,9 +98,40 @@ export default {
|
|||||||
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
||||||
}
|
}
|
||||||
return `/api/items/${this.libraryItemId}/ebook`
|
return `/api/items/${this.libraryItemId}/ebook`
|
||||||
|
},
|
||||||
|
themeRules() {
|
||||||
|
const isDark = this.ereaderSettings.theme === 'dark'
|
||||||
|
const fontColor = isDark ? '#fff' : '#000'
|
||||||
|
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
|
||||||
|
|
||||||
|
const lineSpacing = this.ereaderSettings.lineSpacing / 100
|
||||||
|
|
||||||
|
const fontScale = this.ereaderSettings.fontScale / 100
|
||||||
|
|
||||||
|
return {
|
||||||
|
'*': {
|
||||||
|
color: `${fontColor}!important`,
|
||||||
|
'background-color': `${backgroundColor}!important`,
|
||||||
|
'line-height': lineSpacing * fontScale + 'rem!important'
|
||||||
|
},
|
||||||
|
a: {
|
||||||
|
color: `${fontColor}!important`
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
updateSettings(settings) {
|
||||||
|
this.ereaderSettings = settings
|
||||||
|
|
||||||
|
if (!this.rendition) return
|
||||||
|
|
||||||
|
this.applyTheme()
|
||||||
|
|
||||||
|
const fontScale = settings.fontScale || 100
|
||||||
|
this.rendition.themes.fontSize(`${fontScale}%`)
|
||||||
|
this.rendition.spread(settings.spread || 'auto')
|
||||||
|
},
|
||||||
prev() {
|
prev() {
|
||||||
return this.rendition?.prev()
|
return this.rendition?.prev()
|
||||||
},
|
},
|
||||||
@@ -242,35 +279,30 @@ export default {
|
|||||||
/** @type {ePub.Rendition} */
|
/** @type {ePub.Rendition} */
|
||||||
reader.rendition = reader.book.renderTo('viewer', {
|
reader.rendition = reader.book.renderTo('viewer', {
|
||||||
width: this.readerWidth,
|
width: this.readerWidth,
|
||||||
height: this.readerHeight * 0.8
|
height: this.readerHeight * 0.8,
|
||||||
|
spread: 'auto',
|
||||||
|
snap: true,
|
||||||
|
manager: 'continuous',
|
||||||
|
flow: 'paginated'
|
||||||
})
|
})
|
||||||
|
|
||||||
// load saved progress
|
// load saved progress
|
||||||
reader.rendition.display(this.savedEbookLocation || reader.book.locations.start)
|
reader.rendition.display(this.savedEbookLocation || reader.book.locations.start)
|
||||||
|
|
||||||
// load style
|
reader.rendition.on('rendered', () => {
|
||||||
reader.rendition.themes.default({ '*': { color: '#fff!important', 'background-color': 'rgb(35 35 35)!important' }, a: { color: '#fff!important' } })
|
this.applyTheme()
|
||||||
|
})
|
||||||
|
|
||||||
reader.book.ready.then(() => {
|
reader.book.ready.then(() => {
|
||||||
// set up event listeners
|
// set up event listeners
|
||||||
reader.rendition.on('relocated', reader.relocated)
|
reader.rendition.on('relocated', reader.relocated)
|
||||||
reader.rendition.on('keydown', reader.keyUp)
|
reader.rendition.on('keydown', reader.keyUp)
|
||||||
|
|
||||||
let touchStart = 0
|
|
||||||
let touchEnd = 0
|
|
||||||
reader.rendition.on('touchstart', (event) => {
|
reader.rendition.on('touchstart', (event) => {
|
||||||
touchStart = event.changedTouches[0].screenX
|
this.$emit('touchstart', event)
|
||||||
})
|
})
|
||||||
|
|
||||||
reader.rendition.on('touchend', (event) => {
|
reader.rendition.on('touchend', (event) => {
|
||||||
touchEnd = event.changedTouches[0].screenX
|
this.$emit('touchend', event)
|
||||||
const touchDistanceX = Math.abs(touchEnd - touchStart)
|
|
||||||
if (touchStart < touchEnd && touchDistanceX > 120) {
|
|
||||||
this.next()
|
|
||||||
}
|
|
||||||
if (touchStart > touchEnd && touchDistanceX > 120) {
|
|
||||||
this.prev()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// load ebook cfi locations
|
// load ebook cfi locations
|
||||||
@@ -288,6 +320,12 @@ export default {
|
|||||||
this.windowWidth = window.innerWidth
|
this.windowWidth = window.innerWidth
|
||||||
this.windowHeight = window.innerHeight
|
this.windowHeight = window.innerHeight
|
||||||
this.rendition?.resize(this.readerWidth, this.readerHeight * 0.8)
|
this.rendition?.resize(this.readerWidth, this.readerHeight * 0.8)
|
||||||
|
},
|
||||||
|
applyTheme() {
|
||||||
|
if (!this.rendition) return
|
||||||
|
this.rendition.getContents().forEach((c) => {
|
||||||
|
c.addStylesheetRules(this.themeRules)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 flex items-center text-center">
|
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center">
|
||||||
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 flex items-center text-center">
|
<div class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center">
|
||||||
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
|
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
|
||||||
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
|
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,36 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="show" id="reader" class="absolute top-0 left-0 w-full z-60 bg-primary text-white" :class="{ 'reader-player-open': !!streamLibraryItem }">
|
<div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': !!streamLibraryItem }">
|
||||||
<div class="absolute top-4 left-4 z-20">
|
<div class="absolute top-4 left-4 z-20 flex items-center">
|
||||||
<span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
|
<button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100">
|
||||||
|
<span class="material-icons text-2xl">menu</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="hasSettings" @click="openSettings" type="button" aria-label="Ereader settings" class="mx-4 inline-flex opacity-80 hover:opacity-100">
|
||||||
|
<span class="material-icons text-1.5xl">settings</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute top-4 left-1/2 transform -translate-x-1/2">
|
<div class="absolute top-4 left-1/2 transform -translate-x-1/2">
|
||||||
<h1 class="text-lg sm:text-xl md:text-2xl mb-1" style="line-height: 1.15; font-weight: 100">
|
<h1 :data-type="ebookType" class="text-lg sm:text-xl md:text-2xl mb-1 data-[type=comic]:hidden" style="line-height: 1.15; font-weight: 100">
|
||||||
<span style="font-weight: 600">{{ abTitle }}</span>
|
<span style="font-weight: 600">{{ abTitle }}</span>
|
||||||
<span v-if="abAuthor" style="display: inline"> – </span>
|
<span v-if="abAuthor" class="hidden md:inline"> – </span>
|
||||||
<span v-if="abAuthor">{{ abAuthor }}</span>
|
<span v-if="abAuthor" class="hidden md:inline">{{ abAuthor }}</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute top-4 right-4 z-20">
|
<div class="absolute top-4 right-4 z-20">
|
||||||
<span v-if="hasSettings" class="material-icons cursor-pointer text-2xl" @click="openSettings">settings</span>
|
<button @click="close" type="button" aria-label="Close ereader" class="inline-flex opacity-80 hover:opacity-100">
|
||||||
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
|
<span class="material-icons text-2xl">close</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" />
|
<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" @touchstart="touchstart" @touchend="touchend" @hook:mounted="readerMounted" />
|
||||||
|
|
||||||
<!-- TOC side nav -->
|
<!-- TOC side nav -->
|
||||||
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
|
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
|
||||||
<div v-if="hasToC" class="w-96 h-full max-h-full absolute top-0 left-0 bg-bg shadow-xl transition-transform z-30" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC">
|
<div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC">
|
||||||
<div class="p-4 h-full">
|
<div class="p-4 h-full">
|
||||||
<p class="text-lg font-semibold mb-2">Table of Contents</p>
|
<div class="flex items-center mb-2">
|
||||||
|
<button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100">
|
||||||
|
<span class="material-icons text-2xl">arrow_back</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p>
|
||||||
|
</div>
|
||||||
<div class="tocContent">
|
<div class="tocContent">
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
|
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
|
||||||
<a :href="chapter.href" class="text-white/70 hover:text-white" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
|
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
|
||||||
<ul v-if="chapter.subitems.length">
|
<ul v-if="chapter.subitems.length">
|
||||||
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
|
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
|
||||||
<a :href="subchapter.href" class="text-white/70 hover:text-white" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a>
|
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@@ -38,6 +50,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ereader settings modal -->
|
||||||
|
<modals-modal v-model="showSettings" name="ereader-settings-modal" :width="500" :height="'unset'" :processing="false">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
|
||||||
|
<p class="text-xl md:text-3xl text-white truncate">{{ $strings.HeaderEreaderSettings }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="px-2 py-4 md:p-8 w-full text-base rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto" style="max-height: 80vh">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="w-40">
|
||||||
|
<p class="text-lg">{{ $strings.LabelTheme }}:</p>
|
||||||
|
</div>
|
||||||
|
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="w-40">
|
||||||
|
<p class="text-lg">{{ $strings.LabelFontScale }}:</p>
|
||||||
|
</div>
|
||||||
|
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" @input="settingsUpdated" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="w-40">
|
||||||
|
<p class="text-lg">{{ $strings.LabelLineSpacing }}:</p>
|
||||||
|
</div>
|
||||||
|
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" @input="settingsUpdated" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-40">
|
||||||
|
<p class="text-lg">{{ $strings.LabelLayout }}:</p>
|
||||||
|
</div>
|
||||||
|
<ui-toggle-btns v-model="ereaderSettings.spread" :items="spreadItems" @input="settingsUpdated" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -45,8 +92,21 @@
|
|||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
touchstartX: 0,
|
||||||
|
touchstartY: 0,
|
||||||
|
touchendX: 0,
|
||||||
|
touchendY: 0,
|
||||||
|
touchstartTime: 0,
|
||||||
|
touchIdentifier: null,
|
||||||
chapters: [],
|
chapters: [],
|
||||||
tocOpen: false
|
tocOpen: false,
|
||||||
|
showSettings: false,
|
||||||
|
ereaderSettings: {
|
||||||
|
theme: 'dark',
|
||||||
|
fontScale: 100,
|
||||||
|
lineSpacing: 115,
|
||||||
|
spread: 'auto'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -65,6 +125,34 @@ export default {
|
|||||||
this.$store.commit('setShowEReader', val)
|
this.$store.commit('setShowEReader', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
ereaderTheme() {
|
||||||
|
if (this.isEpub) return this.ereaderSettings.theme
|
||||||
|
return 'dark'
|
||||||
|
},
|
||||||
|
spreadItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLayoutSinglePage,
|
||||||
|
value: 'none'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLayoutSplitPage,
|
||||||
|
value: 'auto'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
themeItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelThemeDark,
|
||||||
|
value: 'dark'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelThemeLight,
|
||||||
|
value: 'light'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
componentName() {
|
componentName() {
|
||||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||||
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||||
@@ -75,11 +163,8 @@ export default {
|
|||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
},
|
||||||
hasToC() {
|
|
||||||
return this.isEpub
|
|
||||||
},
|
|
||||||
hasSettings() {
|
hasSettings() {
|
||||||
return false
|
return this.isEpub
|
||||||
},
|
},
|
||||||
abTitle() {
|
abTitle() {
|
||||||
return this.mediaMetadata.title
|
return this.mediaMetadata.title
|
||||||
@@ -144,14 +229,28 @@ export default {
|
|||||||
},
|
},
|
||||||
ebookFileId() {
|
ebookFileId() {
|
||||||
return this.$store.state.ereaderFileId
|
return this.$store.state.ereaderFileId
|
||||||
|
},
|
||||||
|
isDarkTheme() {
|
||||||
|
return this.ereaderSettings.theme === 'dark'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
readerMounted() {
|
||||||
|
if (this.isEpub) {
|
||||||
|
this.loadEreaderSettings()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
settingsUpdated() {
|
||||||
|
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
|
||||||
|
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
|
||||||
|
},
|
||||||
toggleToC() {
|
toggleToC() {
|
||||||
this.tocOpen = !this.tocOpen
|
this.tocOpen = !this.tocOpen
|
||||||
this.chapters = this.$refs.readerComponent.chapters
|
this.chapters = this.$refs.readerComponent.chapters
|
||||||
},
|
},
|
||||||
openSettings() {},
|
openSettings() {
|
||||||
|
this.showSettings = true
|
||||||
|
},
|
||||||
hotkey(action) {
|
hotkey(action) {
|
||||||
if (!this.$refs.readerComponent) return
|
if (!this.$refs.readerComponent) return
|
||||||
|
|
||||||
@@ -169,11 +268,72 @@ export default {
|
|||||||
prev() {
|
prev() {
|
||||||
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
|
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
|
||||||
},
|
},
|
||||||
|
handleGesture() {
|
||||||
|
// Touch must be less than 1s. Must be > 60px drag and X distance > Y distance
|
||||||
|
const touchTimeMs = Date.now() - this.touchstartTime
|
||||||
|
if (touchTimeMs >= 1000) {
|
||||||
|
console.log('Touch too long', touchTimeMs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
|
||||||
|
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
|
||||||
|
const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))
|
||||||
|
if (touchDistance < 60) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.touchendX < this.touchstartX) {
|
||||||
|
this.next()
|
||||||
|
}
|
||||||
|
if (this.touchendX > this.touchstartX) {
|
||||||
|
this.prev()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
touchstart(e) {
|
||||||
|
// Ignore rapid touch
|
||||||
|
if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.touchstartX = e.touches[0].screenX
|
||||||
|
this.touchstartY = e.touches[0].screenY
|
||||||
|
this.touchstartTime = Date.now()
|
||||||
|
this.touchIdentifier = e.touches[0].identifier
|
||||||
|
},
|
||||||
|
touchend(e) {
|
||||||
|
if (this.touchIdentifier !== e.changedTouches[0].identifier) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.touchendX = e.changedTouches[0].screenX
|
||||||
|
this.touchendY = e.changedTouches[0].screenY
|
||||||
|
this.handleGesture()
|
||||||
|
},
|
||||||
registerListeners() {
|
registerListeners() {
|
||||||
this.$eventBus.$on('reader-hotkey', this.hotkey)
|
this.$eventBus.$on('reader-hotkey', this.hotkey)
|
||||||
|
document.body.addEventListener('touchstart', this.touchstart)
|
||||||
|
document.body.addEventListener('touchend', this.touchend)
|
||||||
},
|
},
|
||||||
unregisterListeners() {
|
unregisterListeners() {
|
||||||
this.$eventBus.$off('reader-hotkey', this.hotkey)
|
this.$eventBus.$off('reader-hotkey', this.hotkey)
|
||||||
|
document.body.removeEventListener('touchstart', this.touchstart)
|
||||||
|
document.body.removeEventListener('touchend', this.touchend)
|
||||||
|
},
|
||||||
|
loadEreaderSettings() {
|
||||||
|
try {
|
||||||
|
const settings = localStorage.getItem('ereaderSettings')
|
||||||
|
if (settings) {
|
||||||
|
this.ereaderSettings = JSON.parse(settings)
|
||||||
|
this.settingsUpdated()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load ereader settings', error)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.registerListeners()
|
this.registerListeners()
|
||||||
|
|||||||
@@ -235,7 +235,6 @@ export default {
|
|||||||
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
console.log('Data', this.data)
|
|
||||||
|
|
||||||
this.monthLabels = []
|
this.monthLabels = []
|
||||||
var lastMonth = null
|
var lastMonth = null
|
||||||
|
|||||||
@@ -21,14 +21,14 @@
|
|||||||
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="w-full flex flex-row items-center justify-center">
|
<div class="w-full flex flex-row items-center justify-center">
|
||||||
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn>
|
<ui-btn v-if="backup.serverVersion && backup.key" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn>
|
||||||
|
|
||||||
<a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
|
||||||
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
|
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
|
||||||
<span class="material-icons-outlined text-2xl text-error">error_outline</span>
|
<span class="material-icons-outlined text-2xl text-error">error_outline</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
|
<button aria-label="Download Backup" class="inline-flex material-icons text-xl mx-1 mt-1 text-white/70 hover:text-white/100" @click.stop="downloadBackup(backup)">download</button>
|
||||||
|
|
||||||
|
<button aria-label="Delete Backup" class="inline-flex material-icons text-xl mx-1 text-white/70 hover:text-error" @click="deleteBackupClick(backup)">delete</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -80,6 +80,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
downloadBackup(backup) {
|
||||||
|
this.$downloadFile(`${process.env.serverUrl}/api/backups/${backup.id}/download?token=${this.userToken}`)
|
||||||
|
},
|
||||||
confirm() {
|
confirm() {
|
||||||
this.showConfirmApply = false
|
this.showConfirmApply = false
|
||||||
|
|
||||||
@@ -91,8 +94,9 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.isBackingUp = false
|
this.isBackingUp = false
|
||||||
console.error('Failed', error)
|
console.error('Failed to apply backup', error)
|
||||||
this.$toast.error(this.$strings.ToastBackupRestoreFailed)
|
const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
deleteBackupClick(backup) {
|
deleteBackupClick(backup) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<th class="text-left px-4 w-24">
|
<th class="text-left px-4 w-24">
|
||||||
{{ $strings.LabelRead }} <ui-tooltip :text="$strings.LabelReadEbookWithoutProgress" direction="top" class="inline-block"><span class="material-icons-outlined text-sm align-middle">info</span></ui-tooltip>
|
{{ $strings.LabelRead }} <ui-tooltip :text="$strings.LabelReadEbookWithoutProgress" direction="top" class="inline-block"><span class="material-icons-outlined text-sm align-middle">info</span></ui-tooltip>
|
||||||
</th>
|
</th>
|
||||||
<th v-if="userCanDelete || userCanDownload || userIsAdmin" class="text-center w-16"></th>
|
<th v-if="showMoreColumn" class="text-center w-16"></th>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="file in ebookFiles">
|
<template v-for="file in ebookFiles">
|
||||||
<tables-ebook-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" @read="readEbook" />
|
<tables-ebook-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" @read="readEbook" />
|
||||||
@@ -58,20 +58,20 @@ export default {
|
|||||||
userCanDelete() {
|
userCanDelete() {
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
},
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
userIsAdmin() {
|
userIsAdmin() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
|
libraryIsAudiobooksOnly() {
|
||||||
|
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
|
||||||
|
},
|
||||||
|
showMoreColumn() {
|
||||||
|
return this.userCanDelete || this.userCanDownload || (this.userCanUpdate && !this.libraryIsAudiobooksOnly)
|
||||||
|
},
|
||||||
ebookFiles() {
|
ebookFiles() {
|
||||||
return (this.libraryItem.libraryFiles || []).filter((lf) => lf.fileType === 'ebook')
|
return (this.libraryItem.libraryFiles || []).filter((lf) => lf.fileType === 'ebook')
|
||||||
},
|
|
||||||
ebookFileIno() {
|
|
||||||
return this.libraryItem.media.ebookFile?.ino
|
|
||||||
},
|
|
||||||
audioFiles() {
|
|
||||||
if (this.libraryItem.mediaType === 'podcast') {
|
|
||||||
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || []
|
|
||||||
}
|
|
||||||
return this.libraryItem.media?.audioFiles || []
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
|
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :library-item-id="libraryItemId" :audio-file="selectedAudioFile" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
|
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :library-item-id="libraryItemId" :audio-file="selectedAudioFile" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,13 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-sm">{{ user.type }}</td>
|
<td class="text-sm">{{ user.type }}</td>
|
||||||
<td class="hidden lg:table-cell">
|
<td class="hidden lg:table-cell">
|
||||||
<div v-if="usersOnline[user.id]">
|
<div v-if="usersOnline[user.id]?.session?.displayTitle">
|
||||||
<p v-if="usersOnline[user.id].session && usersOnline[user.id].session.libraryItem" class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
|
<p class="truncate text-xs">Listening: {{ usersOnline[user.id].session.displayTitle || '' }}</p>
|
||||||
<p v-else-if="usersOnline[user.id].mostRecent && usersOnline[user.id].mostRecent.media" class="truncate text-xs">Last: {{ usersOnline[user.id].mostRecent.media.metadata.title }}</p>
|
<p class="truncate text-xs text-gray-300">{{ getDeviceInfoString(usersOnline[user.id].session.deviceInfo) }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="user.latestSession?.displayTitle">
|
||||||
|
<p class="truncate text-xs">Last: {{ user.latestSession.displayTitle || '' }}</p>
|
||||||
|
<p class="truncate text-xs text-gray-300">{{ getDeviceInfoString(user.latestSession.deviceInfo) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs font-mono hidden sm:table-cell">
|
<td class="text-xs font-mono hidden sm:table-cell">
|
||||||
@@ -83,6 +87,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
getDeviceInfoString(deviceInfo) {
|
||||||
|
if (!deviceInfo) return ''
|
||||||
|
if (deviceInfo.manufacturer && deviceInfo.model) return `${deviceInfo.manufacturer} ${deviceInfo.model}`
|
||||||
|
|
||||||
|
return `${deviceInfo.osName || 'Unknown'} ${deviceInfo.osVersion || ''} ${deviceInfo.browserName || ''}`
|
||||||
|
},
|
||||||
deleteUserClick(user) {
|
deleteUserClick(user) {
|
||||||
if (this.isDeletingUser) return
|
if (this.isDeletingUser) return
|
||||||
if (confirm(this.$getString('MessageRemoveUserWarning', [user.username]))) {
|
if (confirm(this.$getString('MessageRemoveUserWarning', [user.username]))) {
|
||||||
@@ -114,11 +124,12 @@ export default {
|
|||||||
},
|
},
|
||||||
loadUsers() {
|
loadUsers() {
|
||||||
this.$axios
|
this.$axios
|
||||||
.$get('/api/users')
|
.$get('/api/users?include=latestSession')
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
this.users = res.users.sort((a, b) => {
|
this.users = res.users.sort((a, b) => {
|
||||||
return a.createdAt - b.createdAt
|
return a.createdAt - b.createdAt
|
||||||
})
|
})
|
||||||
|
console.log('Loaded users', this.users)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
|
|||||||
@@ -188,7 +188,6 @@ export default {
|
|||||||
.$patch(`/api/me/progress/${this.book.id}`, updatePayload)
|
.$patch(`/api/me/progress/${this.book.id}`, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
|
|||||||
@@ -198,7 +198,6 @@ export default {
|
|||||||
.$patch(routepath, updatePayload)
|
.$patch(routepath, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ export default {
|
|||||||
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full py-6">
|
<div class="w-full py-6">
|
||||||
<p class="text-lg mb-2 font-semibold md:hidden">{{ $strings.HeaderEpisodes }}</p>
|
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center flex-nowrap whitespace-nowrap mb-2 md:mb-0">
|
||||||
<p class="text-lg mb-0 font-semibold hidden md:block">{{ $strings.HeaderEpisodes }}</p>
|
<p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p>
|
||||||
|
<div class="inline-flex bg-white/5 px-1 mx-2 rounded-md text-sm text-gray-100">
|
||||||
|
<p v-if="episodesList.length === episodes.length">{{ episodes.length }}</p>
|
||||||
|
<p v-else>{{ episodesList.length }} / {{ episodes.length }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex-grow hidden md:block" />
|
<div class="flex-grow hidden md:block" />
|
||||||
<template v-if="isSelectionMode">
|
<div class="flex items-center">
|
||||||
<ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
<template v-if="isSelectionMode">
|
||||||
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
|
<ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||||
</ui-tooltip>
|
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
|
||||||
<ui-btn color="error" :disabled="processing" small class="h-9" @click="removeSelectedEpisodes">{{ $getString('MessageRemoveEpisodes', [selectedEpisodes.length]) }}</ui-btn>
|
</ui-tooltip>
|
||||||
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
|
<ui-btn color="error" :disabled="processing" small class="h-9" @click="removeSelectedEpisodes">{{ $getString('MessageRemoveEpisodes', [selectedEpisodes.length]) }}</ui-btn>
|
||||||
</template>
|
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
<template v-else>
|
</template>
|
||||||
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 sm:ml-4" />
|
<template v-else>
|
||||||
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" />
|
||||||
<div class="flex-grow md:hidden" />
|
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
<div class="flex-grow md:hidden" />
|
||||||
</template>
|
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
|
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
|
||||||
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
|
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
|
||||||
@@ -157,7 +164,7 @@ export default {
|
|||||||
episodesList() {
|
episodesList() {
|
||||||
return this.episodesSorted.filter((episode) => {
|
return this.episodesSorted.filter((episode) => {
|
||||||
if (!this.searchText) return true
|
if (!this.searchText) return true
|
||||||
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
selectedIsFinished() {
|
selectedIsFinished() {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
.btn::before {
|
.btn::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inline-flex">
|
||||||
|
<input v-model="input" type="range" :min="min" :max="max" :step="step" />
|
||||||
|
|
||||||
|
<p class="text-sm ml-2">{{ input }}%</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: [String, Number],
|
||||||
|
min: Number,
|
||||||
|
max: Number,
|
||||||
|
step: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
input: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
input[type='range'] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
input[type='range']:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* chromium */
|
||||||
|
input[type='range']::-webkit-slider-runnable-track {
|
||||||
|
background-color: rgb(0 0 0 / 0.25);
|
||||||
|
border-radius: 9999px;
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
input[type='range']::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: rgb(255 255 255 / 0.7);
|
||||||
|
height: 1.25rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
}
|
||||||
|
input[type='range']:focus::-webkit-slider-thumb {
|
||||||
|
border: 1px solid #6b6b6b;
|
||||||
|
outline: 3px solid #6b6b6b;
|
||||||
|
outline-offset: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* firefox */
|
||||||
|
input[type='range']::-moz-range-track {
|
||||||
|
background-color: rgb(0 0 0 / 0.25);
|
||||||
|
border-radius: 9999px;
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
input[type='range']::-moz-range-thumb {
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
background-color: rgb(255 255 255 / 0.7);
|
||||||
|
height: 1.25rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
}
|
||||||
|
input[type='range']:focus::-moz-range-thumb {
|
||||||
|
border: 1px solid #6b6b6b;
|
||||||
|
outline: 3px solid #6b6b6b;
|
||||||
|
outline-offset: 0.125rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||||
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
|
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :rows="rows" class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ export default {
|
|||||||
value: [String, Number],
|
value: [String, Number],
|
||||||
label: String,
|
label: String,
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
|
readonly: Boolean,
|
||||||
rows: {
|
rows: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 2
|
default: 2
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inline-flex toggle-btn-wrapper shadow-md">
|
||||||
|
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-none relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
|
||||||
|
{{ item.text }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
/**
|
||||||
|
* [{ "text", "", "value": "" }]
|
||||||
|
*/
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: Object
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
clickBtn(value) {
|
||||||
|
this.$emit('input', value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toggle-btn-wrapper .toggle-btn:first-child {
|
||||||
|
border-top-left-radius: 0.375rem /* 6px */;
|
||||||
|
border-bottom-left-radius: 0.375rem /* 6px */;
|
||||||
|
}
|
||||||
|
.toggle-btn-wrapper .toggle-btn:last-child {
|
||||||
|
border-top-right-radius: 0.375rem /* 6px */;
|
||||||
|
border-bottom-right-radius: 0.375rem /* 6px */;
|
||||||
|
}
|
||||||
|
.toggle-btn-wrapper .toggle-btn:first-child::before {
|
||||||
|
border-top-left-radius: 0.375rem /* 6px */;
|
||||||
|
border-bottom-left-radius: 0.375rem /* 6px */;
|
||||||
|
}
|
||||||
|
.toggle-btn-wrapper .toggle-btn:last-child::before {
|
||||||
|
border-top-right-radius: 0.375rem /* 6px */;
|
||||||
|
border-bottom-right-radius: 0.375rem /* 6px */;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn-wrapper .toggle-btn:not(:first-child) {
|
||||||
|
margin-left: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, 0);
|
||||||
|
transition: all 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
.toggle-btn:hover:not(:disabled)::before {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.toggle-btn:hover:not(:disabled) {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
.toggle-btn.selected {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.toggle-btn.selected::before {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
button.toggle-btn:disabled::before {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -71,8 +71,8 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
'/api/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||||
'/api/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
|
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } }
|
||||||
},
|
},
|
||||||
|
|
||||||
io: {
|
io: {
|
||||||
|
|||||||
@@ -112,17 +112,17 @@
|
|||||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||||
<div class="flex-grow">{{ $strings.LabelFilename }}</div>
|
<div class="flex-grow">{{ $strings.LabelFilename }}</div>
|
||||||
<div class="w-20">{{ $strings.LabelDuration }}</div>
|
<div class="w-20">{{ $strings.LabelDuration }}</div>
|
||||||
<div class="w-20 text-center">{{ $strings.HeaderChapters }}</div>
|
<div class="w-20 hidden md:block text-center">{{ $strings.HeaderChapters }}</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="track in audioTracks">
|
<template v-for="track in audioTracks">
|
||||||
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
|
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
|
||||||
<div class="flex-grow">
|
<div class="flex-grow max-w-[calc(100%-80px)] pr-2">
|
||||||
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
|
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-20" style="min-width: 80px">
|
<div class="w-20" style="min-width: 80px">
|
||||||
<p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>
|
<p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-20 flex justify-center" style="min-width: 80px">
|
<div class="w-20 hidden md:flex justify-center" style="min-width: 80px">
|
||||||
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
|
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
|
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
|
||||||
<div class="flex flex-wrap -mx-2">
|
<div class="flex flex-wrap -mx-2">
|
||||||
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 64k)'" class="m-2 max-w-40" />
|
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 128k)'" class="m-2 max-w-40" />
|
||||||
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="'Audio Channels (1 or 2)'" class="m-2 max-w-40" />
|
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="'Audio Channels (1 or 2)'" class="m-2 max-w-40" />
|
||||||
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="'Audio Codec'" class="m-2 max-w-40" />
|
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="'Audio Codec'" class="m-2 max-w-40" />
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +214,7 @@ export default {
|
|||||||
showEncodeOptions: false,
|
showEncodeOptions: false,
|
||||||
shouldBackupAudioFiles: true,
|
shouldBackupAudioFiles: true,
|
||||||
encodingOptions: {
|
encodingOptions: {
|
||||||
bitrate: '64k',
|
bitrate: '128k',
|
||||||
channels: '2',
|
channels: '2',
|
||||||
codec: 'aac'
|
codec: 'aac'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,18 @@
|
|||||||
<div v-if="enableBackups" class="mb-6">
|
<div v-if="enableBackups" class="mb-6">
|
||||||
<div class="flex items-center pl-6 mb-2">
|
<div class="flex items-center pl-6 mb-2">
|
||||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">schedule</span>
|
<span class="material-icons-outlined text-2xl text-black-50 mr-2">schedule</span>
|
||||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span></div>
|
<div class="w-48">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span>
|
||||||
|
</div>
|
||||||
<div class="text-gray-100">{{ scheduleDescription }}</div>
|
<div class="text-gray-100">{{ scheduleDescription }}</div>
|
||||||
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer ml-2" @click="showCronBuilder = !showCronBuilder">edit</span>
|
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer ml-2" @click="showCronBuilder = !showCronBuilder">edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="nextBackupDate" class="flex items-center pl-6 py-0.5 px-2">
|
<div v-if="nextBackupDate" class="flex items-center pl-6 py-0.5 px-2">
|
||||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">event</span>
|
<span class="material-icons-outlined text-2xl text-black-50 mr-2">event</span>
|
||||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span></div>
|
<div class="w-48">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span>
|
||||||
|
</div>
|
||||||
<div class="text-gray-100">{{ nextBackupDate }}</div>
|
<div class="text-gray-100">{{ nextBackupDate }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,6 +52,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
updatingServerSettings: false,
|
updatingServerSettings: false,
|
||||||
@@ -98,7 +107,7 @@ export default {
|
|||||||
this.$toast.error('Invalid number of backups to keep')
|
this.$toast.error('Invalid number of backups to keep')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var updatePayload = {
|
const updatePayload = {
|
||||||
backupSchedule: this.enableBackups ? this.cronExpression : false,
|
backupSchedule: this.enableBackups ? this.cronExpression : false,
|
||||||
backupsToKeep: Number(this.backupsToKeep),
|
backupsToKeep: Number(this.backupsToKeep),
|
||||||
maxBackupSize: Number(this.maxBackupSize)
|
maxBackupSize: Number(this.maxBackupSize)
|
||||||
@@ -108,15 +117,15 @@ export default {
|
|||||||
updateServerSettings(payload) {
|
updateServerSettings(payload) {
|
||||||
this.updatingServerSettings = true
|
this.updatingServerSettings = true
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('updateServerSettings', payload)
|
.dispatch('updateServerSettings', payload)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
console.log('Updated Server Settings', success)
|
console.log('Updated Server Settings', success)
|
||||||
this.updatingServerSettings = false
|
this.updatingServerSettings = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update server settings', error)
|
console.error('Failed to update server settings', error)
|
||||||
this.updatingServerSettings = false
|
this.updatingServerSettings = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
initServerSettings() {
|
initServerSettings() {
|
||||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||||
|
|||||||
@@ -34,10 +34,14 @@
|
|||||||
<div class="w-full md:w-1/2 px-1">
|
<div class="w-full md:w-1/2 px-1">
|
||||||
<ui-text-input-with-label ref="fromInput" v-model="newSettings.fromAddress" :disabled="savingSettings" :label="$strings.LabelEmailSettingsFromAddress" />
|
<ui-text-input-with-label ref="fromInput" v-model="newSettings.fromAddress" :disabled="savingSettings" :label="$strings.LabelEmailSettingsFromAddress" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-full md:w-1/2 px-1">
|
||||||
|
<ui-text-input-with-label ref="testInput" v-model="newSettings.testAddress" :disabled="savingSettings" :label="$strings.LabelEmailSettingsTestAddress" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between pt-4">
|
<div class="flex items-center justify-between pt-4">
|
||||||
<ui-btn :loading="sendingTest" :disabled="savingSettings || !newSettings.host" type="button" @click="sendTestClick">{{ $strings.ButtonTest }}</ui-btn>
|
<ui-btn v-if="hasUpdates" :disabled="savingSettings" type="button" @click="resetChanges">{{ $strings.ButtonReset }}</ui-btn>
|
||||||
|
<ui-btn v-else :loading="sendingTest" :disabled="savingSettings || !newSettings.host" type="button" @click="sendTestClick">{{ $strings.ButtonTest }}</ui-btn>
|
||||||
<ui-btn :loading="savingSettings" :disabled="!hasUpdates" type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn :loading="savingSettings" :disabled="!hasUpdates" type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -47,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</app-settings-content>
|
</app-settings-content>
|
||||||
|
|
||||||
<app-settings-content :header-text="$strings.HeaderEReaderDevices" showAddButton :description="''" @clicked="addNewDeviceClick">
|
<app-settings-content :header-text="$strings.HeaderEreaderDevices" showAddButton :description="''" @clicked="addNewDeviceClick">
|
||||||
<table v-if="existingEReaderDevices.length" class="tracksTable my-4">
|
<table v-if="existingEReaderDevices.length" class="tracksTable my-4">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-left">{{ $strings.LabelName }}</th>
|
<th class="text-left">{{ $strings.LabelName }}</th>
|
||||||
@@ -80,6 +84,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -93,6 +102,7 @@ export default {
|
|||||||
secure: true,
|
secure: true,
|
||||||
user: null,
|
user: null,
|
||||||
pass: null,
|
pass: null,
|
||||||
|
testAddress: null,
|
||||||
fromAddress: null
|
fromAddress: null
|
||||||
},
|
},
|
||||||
newEReaderDevice: {
|
newEReaderDevice: {
|
||||||
@@ -117,6 +127,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
resetChanges() {
|
||||||
|
this.newSettings = {
|
||||||
|
...this.settings
|
||||||
|
}
|
||||||
|
},
|
||||||
editDeviceClick(device) {
|
editDeviceClick(device) {
|
||||||
this.selectedEReaderDevice = device
|
this.selectedEReaderDevice = device
|
||||||
this.showEReaderDeviceModal = true
|
this.showEReaderDeviceModal = true
|
||||||
@@ -139,7 +154,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.deletingDeviceName = device.name
|
this.deletingDeviceName = device.name
|
||||||
this.$axios
|
this.$axios
|
||||||
.$patch(`/emails/ereader-devices`, payload)
|
.$post(`/api/emails/ereader-devices`, payload)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.ereaderDevicesUpdated(data.ereaderDevices)
|
this.ereaderDevicesUpdated(data.ereaderDevices)
|
||||||
this.$toast.success('Device deleted')
|
this.$toast.success('Device deleted')
|
||||||
@@ -196,6 +211,7 @@ export default {
|
|||||||
secure: this.newSettings.secure,
|
secure: this.newSettings.secure,
|
||||||
user: this.newSettings.user,
|
user: this.newSettings.user,
|
||||||
pass: this.newSettings.pass,
|
pass: this.newSettings.pass,
|
||||||
|
testAddress: this.newSettings.testAddress,
|
||||||
fromAddress: this.newSettings.fromAddress
|
fromAddress: this.newSettings.fromAddress
|
||||||
}
|
}
|
||||||
this.savingSettings = true
|
this.savingSettings = true
|
||||||
|
|||||||
@@ -192,7 +192,6 @@
|
|||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeCache">{{ $strings.ButtonPurgeAllCache }}</ui-btn>
|
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeCache">{{ $strings.ButtonPurgeAllCache }}</ui-btn>
|
||||||
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeItemsCache">{{ $strings.ButtonPurgeItemsCache }}</ui-btn>
|
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeItemsCache">{{ $strings.ButtonPurgeItemsCache }}</ui-btn>
|
||||||
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isResettingLibraryItems" @click="resetLibraryItems">{{ $strings.ButtonRemoveAllLibraryItems }}</ui-btn>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-4">
|
<div class="flex items-center py-4">
|
||||||
@@ -249,6 +248,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isResettingLibraryItems: false,
|
isResettingLibraryItems: false,
|
||||||
@@ -363,23 +367,6 @@ export default {
|
|||||||
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
|
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
|
||||||
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
|
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
|
||||||
},
|
},
|
||||||
resetLibraryItems() {
|
|
||||||
if (confirm(this.$strings.MessageRemoveAllItemsWarning)) {
|
|
||||||
this.isResettingLibraryItems = true
|
|
||||||
this.$axios
|
|
||||||
.$delete('/api/items/all')
|
|
||||||
.then(() => {
|
|
||||||
this.isResettingLibraryItems = false
|
|
||||||
this.$toast.success('Successfully reset items')
|
|
||||||
location.reload()
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('failed to reset items', error)
|
|
||||||
this.isResettingLibraryItems = false
|
|
||||||
this.$toast.error('Failed to reset items - manually remove the /config/libraryItems folder')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
purgeCache() {
|
purgeCache() {
|
||||||
this.showConfirmPurgeCache = true
|
this.showConfirmPurgeCache = true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showLibraryModal: false,
|
showLibraryModal: false,
|
||||||
|
|||||||
@@ -87,6 +87,11 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
asyncData({ redirect, store }) {
|
asyncData({ redirect, store }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!store.state.libraries.currentLibraryId) {
|
if (!store.state.libraries.currentLibraryId) {
|
||||||
return redirect('/config')
|
return redirect('/config')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
search: null,
|
search: null,
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@@ -104,7 +104,12 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
async asyncData({ params, redirect, app }) {
|
async asyncData({ store, redirect, app }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const users = await app.$axios
|
const users = await app.$axios
|
||||||
.$get('/api/users')
|
.$get('/api/users')
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<div class="flex mb-4 items-center">
|
<div class="flex mb-4 items-center">
|
||||||
<h1 class="text-2xl">{{ $strings.HeaderStatsRecentSessions }}</h1>
|
<h1 class="text-2xl">{{ $strings.HeaderStatsRecentSessions }}</h1>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">{{ $strings.ButtonViewAll }}</ui-btn>
|
<ui-btn v-if="isAdminOrUp" :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">{{ $strings.ButtonViewAll }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!mostRecentListeningSessions.length">{{ $strings.MessageNoListeningSessions }}</p>
|
<p v-if="!mostRecentListeningSessions.length">{{ $strings.MessageNoListeningSessions }}</p>
|
||||||
<template v-for="(item, index) in mostRecentListeningSessions">
|
<template v-for="(item, index) in mostRecentListeningSessions">
|
||||||
@@ -82,6 +82,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
isAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
@@ -116,7 +119,6 @@ export default {
|
|||||||
console.error('Failed to load listening sesions', err)
|
console.error('Failed to load listening sesions', err)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
console.log('Loaded user listening data', this.listeningStats)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -47,12 +47,6 @@
|
|||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderSavedMediaProgress }}</h1>
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderSavedMediaProgress }}</h1>
|
||||||
|
|
||||||
<div v-if="mediaProgressWithoutMedia.length" class="flex items-center py-2 mb-2">
|
|
||||||
<p class="text-error">User has media progress for {{ mediaProgressWithoutMedia.length }} items that no longer exist.</p>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn small :loading="purgingMediaProgress" @click.stop="purgeMediaProgress">{{ $strings.ButtonPurgeMediaProgress }}</ui-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table v-if="mediaProgressWithMedia.length" class="userAudiobooksTable">
|
<table v-if="mediaProgressWithMedia.length" class="userAudiobooksTable">
|
||||||
<tr class="bg-primary bg-opacity-40">
|
<tr class="bg-primary bg-opacity-40">
|
||||||
<th class="w-16 text-left">{{ $strings.LabelItem }}</th>
|
<th class="w-16 text-left">{{ $strings.LabelItem }}</th>
|
||||||
@@ -111,8 +105,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
listeningSessions: {},
|
listeningSessions: {},
|
||||||
listeningStats: {},
|
listeningStats: {}
|
||||||
purgingMediaProgress: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -134,9 +127,6 @@ export default {
|
|||||||
mediaProgressWithMedia() {
|
mediaProgressWithMedia() {
|
||||||
return this.mediaProgress.filter((mp) => mp.media)
|
return this.mediaProgress.filter((mp) => mp.media)
|
||||||
},
|
},
|
||||||
mediaProgressWithoutMedia() {
|
|
||||||
return this.mediaProgress.filter((mp) => !mp.media)
|
|
||||||
},
|
|
||||||
totalListeningTime() {
|
totalListeningTime() {
|
||||||
return this.listeningStats.totalTime || 0
|
return this.listeningStats.totalTime || 0
|
||||||
},
|
},
|
||||||
@@ -176,24 +166,6 @@ export default {
|
|||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
console.log('Loaded user listening data', this.listeningSessions, this.listeningStats)
|
console.log('Loaded user listening data', this.listeningSessions, this.listeningStats)
|
||||||
},
|
|
||||||
purgeMediaProgress() {
|
|
||||||
this.purgingMediaProgress = true
|
|
||||||
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/users/${this.user.id}/purge-media-progress`)
|
|
||||||
.then((updatedUser) => {
|
|
||||||
console.log('Updated user', updatedUser)
|
|
||||||
this.$toast.success('Media progress purged')
|
|
||||||
this.user = updatedUser
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to purge media progress', error)
|
|
||||||
this.$toast.error('Failed to purge media progress')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.purgingMediaProgress = false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selectedAccount: null,
|
selectedAccount: null,
|
||||||
|
|||||||
@@ -533,7 +533,6 @@ export default {
|
|||||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||||
<widgets-explicit-indicator :explicit="podcast.explicit" />
|
<widgets-explicit-indicator :explicit="podcast.explicit" />
|
||||||
<widgets-already-in-library-indicator :already-in-library="podcast.alreadyInLibrary"/>
|
<widgets-already-in-library-indicator :already-in-library="podcast.alreadyInLibrary" />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
||||||
@@ -146,11 +146,15 @@ export default {
|
|||||||
async submitSearch(term) {
|
async submitSearch(term) {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.termSearched = ''
|
this.termSearched = ''
|
||||||
var results = await this.$axios.$get(`/api/search/podcast?term=${encodeURIComponent(term)}`).catch((error) => {
|
let results = await this.$axios.$get(`/api/search/podcast?term=${encodeURIComponent(term)}`).catch((error) => {
|
||||||
console.error('Search request failed', error)
|
console.error('Search request failed', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
console.log('Got results', results)
|
console.log('Got results', results)
|
||||||
|
|
||||||
|
// Filter out podcasts without an RSS feed
|
||||||
|
results = results.filter((r) => r.feedUrl)
|
||||||
|
|
||||||
for (let result of results) {
|
for (let result of results) {
|
||||||
let podcast = this.existentPodcasts.find((p) => p.itunesId === result.id || p.title === result.title.toLowerCase())
|
let podcast = this.existentPodcasts.find((p) => p.itunesId === result.id || p.title === result.title.toLowerCase())
|
||||||
if (podcast) {
|
if (podcast) {
|
||||||
@@ -164,7 +168,7 @@ export default {
|
|||||||
},
|
},
|
||||||
async selectPodcast(podcast) {
|
async selectPodcast(podcast) {
|
||||||
console.log('Selected podcast', podcast)
|
console.log('Selected podcast', podcast)
|
||||||
if(podcast.existentId){
|
if (podcast.existentId) {
|
||||||
this.$router.push(`/item/${podcast.existentId}`)
|
this.$router.push(`/item/${podcast.existentId}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -173,7 +177,7 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
var payload = await this.$axios.$post(`/api/podcasts/feed`, {rssFeed: podcast.feedUrl}).catch((error) => {
|
const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => {
|
||||||
console.error('Failed to get feed', error)
|
console.error('Failed to get feed', error)
|
||||||
this.$toast.error('Failed to get podcast feed')
|
this.$toast.error('Failed to get podcast feed')
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default {
|
|||||||
return redirect(`/library/${libraryId}`)
|
return redirect(`/library/${libraryId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const series = await app.$axios.$get(`/api/series/${params.id}?include=progress,rssfeed`).catch((error) => {
|
const series = await app.$axios.$get(`/api/libraries/${library.id}/series/${params.id}?include=progress,rssfeed`).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|||||||
+14
-6
@@ -74,9 +74,17 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.$router.replace('/oops?message=No libraries available')
|
this.$router.replace('/oops?message=No libraries available')
|
||||||
}
|
}
|
||||||
} else if (this.$route.query.redirect) {
|
|
||||||
this.$router.replace(this.$route.query.redirect)
|
|
||||||
} else {
|
} else {
|
||||||
|
if (this.$route.query.redirect) {
|
||||||
|
const isAdminUser = this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
const redirect = this.$route.query.redirect
|
||||||
|
// If not admin user then do not redirect to config pages other than your stats
|
||||||
|
if (isAdminUser || !redirect.startsWith('/config/') || redirect === '/config/stats') {
|
||||||
|
this.$router.replace(redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
|
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,17 +152,17 @@ export default {
|
|||||||
this.error = null
|
this.error = null
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
var payload = {
|
const payload = {
|
||||||
username: this.username,
|
username: this.username,
|
||||||
password: this.password || ''
|
password: this.password || ''
|
||||||
}
|
}
|
||||||
var authRes = await this.$axios.$post('/login', payload).catch((error) => {
|
const authRes = await this.$axios.$post('/login', payload).catch((error) => {
|
||||||
console.error('Failed', error.response)
|
console.error('Failed', error.response)
|
||||||
if (error.response) this.error = error.response.data
|
if (error.response) this.error = error.response.data
|
||||||
else this.error = 'Unknown Error'
|
else this.error = 'Unknown Error'
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
if (authRes && authRes.error) {
|
if (authRes?.error) {
|
||||||
this.error = authRes.error
|
this.error = authRes.error
|
||||||
} else if (authRes) {
|
} else if (authRes) {
|
||||||
this.setUser(authRes)
|
this.setUser(authRes)
|
||||||
@@ -162,7 +170,7 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
},
|
},
|
||||||
checkAuth() {
|
checkAuth() {
|
||||||
var token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (!token) return false
|
if (!token) return false
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ export default class PlayerHandler {
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
|
clientName: 'Abs Web',
|
||||||
deviceId: this.getDeviceId()
|
deviceId: this.getDeviceId()
|
||||||
},
|
},
|
||||||
supportedMimeTypes: this.player.playableMimeTypes,
|
supportedMimeTypes: this.player.playableMimeTypes,
|
||||||
@@ -281,6 +282,10 @@ export default class PlayerHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First sync happens after 20 seconds
|
||||||
|
* subsequent syncs happen every 10 seconds
|
||||||
|
*/
|
||||||
startPlayInterval() {
|
startPlayInterval() {
|
||||||
clearInterval(this.playInterval)
|
clearInterval(this.playInterval)
|
||||||
let lastTick = Date.now()
|
let lastTick = Date.now()
|
||||||
@@ -293,7 +298,7 @@ export default class PlayerHandler {
|
|||||||
const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
|
const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
|
||||||
lastTick = Date.now()
|
lastTick = Date.now()
|
||||||
this.listeningTimeSinceSync += exactTimeElapsed
|
this.listeningTimeSinceSync += exactTimeElapsed
|
||||||
const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 5 : 20
|
const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 10 : 20
|
||||||
if (this.listeningTimeSinceSync >= TimeToWaitBeforeSync) {
|
if (this.listeningTimeSinceSync >= TimeToWaitBeforeSync) {
|
||||||
this.sendProgressSync(currentTime)
|
this.sendProgressSync(currentTime)
|
||||||
}
|
}
|
||||||
@@ -315,7 +320,7 @@ export default class PlayerHandler {
|
|||||||
}
|
}
|
||||||
this.listeningTimeSinceSync = 0
|
this.listeningTimeSinceSync = 0
|
||||||
this.lastSyncTime = 0
|
this.lastSyncTime = 0
|
||||||
return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 1000 }).catch((error) => {
|
return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 6000 }).catch((error) => {
|
||||||
console.error('Failed to close session', error)
|
console.error('Failed to close session', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -335,12 +340,13 @@ export default class PlayerHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.listeningTimeSinceSync = 0
|
this.listeningTimeSinceSync = 0
|
||||||
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 3000 }).then(() => {
|
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000 }).then(() => {
|
||||||
this.failedProgressSyncs = 0
|
this.failedProgressSyncs = 0
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('Failed to update session progress', error)
|
console.error('Failed to update session progress', error)
|
||||||
|
// After 4 failed sync attempts show an alert toast
|
||||||
this.failedProgressSyncs++
|
this.failedProgressSyncs++
|
||||||
if (this.failedProgressSyncs >= 2) {
|
if (this.failedProgressSyncs >= 4) {
|
||||||
this.ctx.showFailedProgressSyncs()
|
this.ctx.showFailedProgressSyncs()
|
||||||
this.failedProgressSyncs = 0
|
this.failedProgressSyncs = 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export default function ({ $axios, store, $config }) {
|
|||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
config.url = `/dev${config.url}`
|
||||||
|
console.log('Making request to ' + config.url)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
$axios.onError(error => {
|
$axios.onError(error => {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Vue.prototype.$strings = { ...enUsStrings }
|
|||||||
|
|
||||||
Vue.prototype.$getString = (key, subs) => {
|
Vue.prototype.$getString = (key, subs) => {
|
||||||
if (!Vue.prototype.$strings[key]) return ''
|
if (!Vue.prototype.$strings[key]) return ''
|
||||||
if (subs && Array.isArray(subs) && subs.length) {
|
if (subs?.length && Array.isArray(subs)) {
|
||||||
return supplant(Vue.prototype.$strings[key], subs)
|
return supplant(Vue.prototype.$strings[key], subs)
|
||||||
}
|
}
|
||||||
return Vue.prototype.$strings[key]
|
return Vue.prototype.$strings[key]
|
||||||
|
|||||||
@@ -24,20 +24,20 @@ Vue.prototype.$formatJsDate = (jsdate, fnsFormat = 'MM/dd/yyyy HH:mm') => {
|
|||||||
return format(jsdate, fnsFormat)
|
return format(jsdate, fnsFormat)
|
||||||
}
|
}
|
||||||
Vue.prototype.$formatTime = (unixms, fnsFormat = 'HH:mm') => {
|
Vue.prototype.$formatTime = (unixms, fnsFormat = 'HH:mm') => {
|
||||||
if (!unixms) return ''
|
if (!unixms) return ''
|
||||||
return format(unixms, fnsFormat)
|
return format(unixms, fnsFormat)
|
||||||
}
|
}
|
||||||
Vue.prototype.$formatJsTime = (jsdate, fnsFormat = 'HH:mm') => {
|
Vue.prototype.$formatJsTime = (jsdate, fnsFormat = 'HH:mm') => {
|
||||||
if (!jsdate || !isDate(jsdate)) return ''
|
if (!jsdate || !isDate(jsdate)) return ''
|
||||||
return format(jsdate, fnsFormat)
|
return format(jsdate, fnsFormat)
|
||||||
}
|
}
|
||||||
Vue.prototype.$formatDatetime = (unixms, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {
|
Vue.prototype.$formatDatetime = (unixms, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {
|
||||||
if (!unixms) return ''
|
if (!unixms) return ''
|
||||||
return format(unixms, `${fnsDateFormart} ${fnsTimeFormat}`)
|
return format(unixms, `${fnsDateFormart} ${fnsTimeFormat}`)
|
||||||
}
|
}
|
||||||
Vue.prototype.$formatJsDatetime = (jsdate, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {
|
Vue.prototype.$formatJsDatetime = (jsdate, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {
|
||||||
if (!jsdate || !isDate(jsdate)) return ''
|
if (!jsdate || !isDate(jsdate)) return ''
|
||||||
return format(jsdate, `${fnsDateFormart} ${fnsTimeFormat}`)
|
return format(jsdate, `${fnsDateFormart} ${fnsTimeFormat}`)
|
||||||
}
|
}
|
||||||
Vue.prototype.$addDaysToToday = (daysToAdd) => {
|
Vue.prototype.$addDaysToToday = (daysToAdd) => {
|
||||||
var date = addDays(new Date(), daysToAdd)
|
var date = addDays(new Date(), daysToAdd)
|
||||||
@@ -132,8 +132,10 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
|
|||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
navigator.clipboard.writeText(str).then(() => {
|
navigator.clipboard.writeText(str).then(() => {
|
||||||
if (ctx) ctx.$toast.success('Copied to clipboard')
|
if (ctx) ctx.$toast.success('Copied to clipboard')
|
||||||
|
resolve(true)
|
||||||
}, (err) => {
|
}, (err) => {
|
||||||
console.error('Clipboard copy failed', str, err)
|
console.error('Clipboard copy failed', str, err)
|
||||||
|
resolve(false)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const el = document.createElement('textarea')
|
const el = document.createElement('textarea')
|
||||||
@@ -147,6 +149,7 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
|
|||||||
document.body.removeChild(el)
|
document.body.removeChild(el)
|
||||||
|
|
||||||
if (ctx) ctx.$toast.success('Copied to clipboard')
|
if (ctx) ctx.$toast.success('Copied to clipboard')
|
||||||
|
resolve(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,22 +33,22 @@ export const getters = {
|
|||||||
return state.user.bookmarks.filter(bm => bm.libraryItemId === libraryItemId)
|
return state.user.bookmarks.filter(bm => bm.libraryItemId === libraryItemId)
|
||||||
},
|
},
|
||||||
getUserSetting: (state) => (key) => {
|
getUserSetting: (state) => (key) => {
|
||||||
return state.settings ? state.settings[key] : null
|
return state.settings?.[key] || null
|
||||||
},
|
},
|
||||||
getUserCanUpdate: (state) => {
|
getUserCanUpdate: (state) => {
|
||||||
return state.user && state.user.permissions ? !!state.user.permissions.update : false
|
return !!state.user?.permissions?.update
|
||||||
},
|
},
|
||||||
getUserCanDelete: (state) => {
|
getUserCanDelete: (state) => {
|
||||||
return state.user && state.user.permissions ? !!state.user.permissions.delete : false
|
return !!state.user?.permissions?.delete
|
||||||
},
|
},
|
||||||
getUserCanDownload: (state) => {
|
getUserCanDownload: (state) => {
|
||||||
return state.user && state.user.permissions ? !!state.user.permissions.download : false
|
return !!state.user?.permissions?.download
|
||||||
},
|
},
|
||||||
getUserCanUpload: (state) => {
|
getUserCanUpload: (state) => {
|
||||||
return state.user && state.user.permissions ? !!state.user.permissions.upload : false
|
return !!state.user?.permissions?.upload
|
||||||
},
|
},
|
||||||
getUserCanAccessAllLibraries: (state) => {
|
getUserCanAccessAllLibraries: (state) => {
|
||||||
return state.user && state.user.permissions ? !!state.user.permissions.accessAllLibraries : false
|
return !!state.user?.permissions?.accessAllLibraries
|
||||||
},
|
},
|
||||||
getLibrariesAccessible: (state, getters) => {
|
getLibrariesAccessible: (state, getters) => {
|
||||||
if (!state.user) return []
|
if (!state.user) return []
|
||||||
|
|||||||
+18
-2
@@ -102,7 +102,8 @@
|
|||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Email Settings",
|
||||||
"HeaderEpisodes": "Episoden",
|
"HeaderEpisodes": "Episoden",
|
||||||
"HeaderEReaderDevices": "E-Reader Devices",
|
"HeaderEreaderDevices": "Ereader Devices",
|
||||||
|
"HeaderEreaderSettings": "Ereader Settings",
|
||||||
"HeaderFiles": "Dateien",
|
"HeaderFiles": "Dateien",
|
||||||
"HeaderFindChapters": "Kapitel suchen",
|
"HeaderFindChapters": "Kapitel suchen",
|
||||||
"HeaderIgnoredFiles": "Ignorierte Dateien",
|
"HeaderIgnoredFiles": "Ignorierte Dateien",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Neueste Ereignisse",
|
"HeaderStatsRecentSessions": "Neueste Ereignisse",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Autoren",
|
"HeaderStatsTop10Authors": "Top 10 Autoren",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Kategorien",
|
"HeaderStatsTop5Genres": "Top 5 Kategorien",
|
||||||
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Werkzeuge",
|
"HeaderTools": "Werkzeuge",
|
||||||
"HeaderUpdateAccount": "Konto aktualisieren",
|
"HeaderUpdateAccount": "Konto aktualisieren",
|
||||||
"HeaderUpdateAuthor": "Autor aktualisieren",
|
"HeaderUpdateAuthor": "Autor aktualisieren",
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
"LabelDiscFromFilename": "CD aus dem Dateinamen",
|
"LabelDiscFromFilename": "CD aus dem Dateinamen",
|
||||||
"LabelDiscFromMetadata": "CD aus den Metadaten",
|
"LabelDiscFromMetadata": "CD aus den Metadaten",
|
||||||
"LabelDownload": "Herunterladen",
|
"LabelDownload": "Herunterladen",
|
||||||
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Laufzeit",
|
"LabelDuration": "Laufzeit",
|
||||||
"LabelDurationFound": "Gefundene Laufzeit:",
|
"LabelDurationFound": "Gefundene Laufzeit:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "Ebook",
|
||||||
@@ -230,6 +233,7 @@
|
|||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "From Address",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Secure",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Test Address",
|
||||||
"LabelEmbeddedCover": "Eingebettetes Cover",
|
"LabelEmbeddedCover": "Eingebettetes Cover",
|
||||||
"LabelEnable": "Aktivieren",
|
"LabelEnable": "Aktivieren",
|
||||||
"LabelEnd": "Ende",
|
"LabelEnd": "Ende",
|
||||||
@@ -248,6 +252,7 @@
|
|||||||
"LabelFinished": "beendet",
|
"LabelFinished": "beendet",
|
||||||
"LabelFolder": "Ordner",
|
"LabelFolder": "Ordner",
|
||||||
"LabelFolders": "Verzeichnisse",
|
"LabelFolders": "Verzeichnisse",
|
||||||
|
"LabelFontScale": "Font scale",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Kategorie",
|
"LabelGenre": "Kategorie",
|
||||||
"LabelGenres": "Kategorien",
|
"LabelGenres": "Kategorien",
|
||||||
@@ -279,12 +284,16 @@
|
|||||||
"LabelLastSeen": "Zuletzt angesehen",
|
"LabelLastSeen": "Zuletzt angesehen",
|
||||||
"LabelLastTime": "Letztes Mal",
|
"LabelLastTime": "Letztes Mal",
|
||||||
"LabelLastUpdate": "Letzte Aktualisierung",
|
"LabelLastUpdate": "Letzte Aktualisierung",
|
||||||
|
"LabelLayout": "Layout",
|
||||||
|
"LabelLayoutSinglePage": "Single page",
|
||||||
|
"LabelLayoutSplitPage": "Split page",
|
||||||
"LabelLess": "Weniger",
|
"LabelLess": "Weniger",
|
||||||
"LabelLibrariesAccessibleToUser": "Für Benutzer zugängliche Bibliotheken",
|
"LabelLibrariesAccessibleToUser": "Für Benutzer zugängliche Bibliotheken",
|
||||||
"LabelLibrary": "Bibliothek",
|
"LabelLibrary": "Bibliothek",
|
||||||
"LabelLibraryItem": "Bibliothekseintrag",
|
"LabelLibraryItem": "Bibliothekseintrag",
|
||||||
"LabelLibraryName": "Bibliotheksname",
|
"LabelLibraryName": "Bibliotheksname",
|
||||||
"LabelLimit": "Begrenzung",
|
"LabelLimit": "Begrenzung",
|
||||||
|
"LabelLineSpacing": "Line spacing",
|
||||||
"LabelListenAgain": "Erneut anhören",
|
"LabelListenAgain": "Erneut anhören",
|
||||||
"LabelLogLevelDebug": "Fehlersuche",
|
"LabelLogLevelDebug": "Fehlersuche",
|
||||||
"LabelLogLevelInfo": "Informationen",
|
"LabelLogLevelInfo": "Informationen",
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
"LabelNewPassword": "Neues Passwort",
|
"LabelNewPassword": "Neues Passwort",
|
||||||
"LabelNextBackupDate": "Nächstes Sicherungsdatum",
|
"LabelNextBackupDate": "Nächstes Sicherungsdatum",
|
||||||
"LabelNextScheduledRun": "Nächster planmäßiger Durchlauf",
|
"LabelNextScheduledRun": "Nächster planmäßiger Durchlauf",
|
||||||
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotes": "Hinweise",
|
"LabelNotes": "Hinweise",
|
||||||
"LabelNotFinished": "nicht beendet",
|
"LabelNotFinished": "nicht beendet",
|
||||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||||
@@ -368,6 +378,8 @@
|
|||||||
"LabelSearchTitle": "Titel",
|
"LabelSearchTitle": "Titel",
|
||||||
"LabelSearchTitleOrASIN": "Titel oder ASIN",
|
"LabelSearchTitleOrASIN": "Titel oder ASIN",
|
||||||
"LabelSeason": "Staffel",
|
"LabelSeason": "Staffel",
|
||||||
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Reihenfolge",
|
"LabelSequence": "Reihenfolge",
|
||||||
"LabelSeries": "Serien",
|
"LabelSeries": "Serien",
|
||||||
@@ -387,6 +399,8 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.",
|
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.",
|
||||||
"LabelSettingsFindCovers": "Suche Titelbilder",
|
"LabelSettingsFindCovers": "Suche Titelbilder",
|
||||||
"LabelSettingsFindCoversHelp": "Wenn Ihr Medium kein eingebettetes Titelbild oder kein Titelbild im Ordner hat, versucht der Scanner, ein Titelbild online zu finden.<br>Hinweis: Dies verlängert die Scandauer",
|
"LabelSettingsFindCoversHelp": "Wenn Ihr Medium kein eingebettetes Titelbild oder kein Titelbild im Ordner hat, versucht der Scanner, ein Titelbild online zu finden.<br>Hinweis: Dies verlängert die Scandauer",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Startseite verwendet die Bücherregalansicht",
|
"LabelSettingsHomePageBookshelfView": "Startseite verwendet die Bücherregalansicht",
|
||||||
"LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht",
|
"LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Verwende Overdrive Media Marker für Kapitel",
|
"LabelSettingsOverdriveMediaMarkers": "Verwende Overdrive Media Marker für Kapitel",
|
||||||
@@ -439,6 +453,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
|
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
|
||||||
"LabelTagsNotAccessibleToUser": "Für Benutzer nicht zugängliche Schlagwörter",
|
"LabelTagsNotAccessibleToUser": "Für Benutzer nicht zugängliche Schlagwörter",
|
||||||
"LabelTasks": "Laufende Aufgaben",
|
"LabelTasks": "Laufende Aufgaben",
|
||||||
|
"LabelTheme": "Theme",
|
||||||
|
"LabelThemeDark": "Dark",
|
||||||
|
"LabelThemeLight": "Light",
|
||||||
"LabelTimeBase": "Basiszeit",
|
"LabelTimeBase": "Basiszeit",
|
||||||
"LabelTimeListened": "Gehörte Zeit",
|
"LabelTimeListened": "Gehörte Zeit",
|
||||||
"LabelTimeListenedToday": "Heute gehörte Zeit",
|
"LabelTimeListenedToday": "Heute gehörte Zeit",
|
||||||
@@ -575,7 +592,6 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
|
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
|
"MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
|
||||||
"MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
|
"MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
|
||||||
"MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?",
|
|
||||||
"MessageRemoveChapter": "Kapitel löschen",
|
"MessageRemoveChapter": "Kapitel löschen",
|
||||||
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
|
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
|
||||||
"MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen",
|
"MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen",
|
||||||
|
|||||||
@@ -102,7 +102,8 @@
|
|||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Email Settings",
|
||||||
"HeaderEpisodes": "Episodes",
|
"HeaderEpisodes": "Episodes",
|
||||||
"HeaderEReaderDevices": "E-Reader Devices",
|
"HeaderEreaderDevices": "Ereader Devices",
|
||||||
|
"HeaderEreaderSettings": "Ereader Settings",
|
||||||
"HeaderFiles": "Files",
|
"HeaderFiles": "Files",
|
||||||
"HeaderFindChapters": "Find Chapters",
|
"HeaderFindChapters": "Find Chapters",
|
||||||
"HeaderIgnoredFiles": "Ignored Files",
|
"HeaderIgnoredFiles": "Ignored Files",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Recent Sessions",
|
"HeaderStatsRecentSessions": "Recent Sessions",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Authors",
|
"HeaderStatsTop10Authors": "Top 10 Authors",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Genres",
|
"HeaderStatsTop5Genres": "Top 5 Genres",
|
||||||
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Tools",
|
"HeaderTools": "Tools",
|
||||||
"HeaderUpdateAccount": "Update Account",
|
"HeaderUpdateAccount": "Update Account",
|
||||||
"HeaderUpdateAuthor": "Update Author",
|
"HeaderUpdateAuthor": "Update Author",
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
"LabelDiscFromFilename": "Disc from Filename",
|
"LabelDiscFromFilename": "Disc from Filename",
|
||||||
"LabelDiscFromMetadata": "Disc from Metadata",
|
"LabelDiscFromMetadata": "Disc from Metadata",
|
||||||
"LabelDownload": "Download",
|
"LabelDownload": "Download",
|
||||||
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Duration",
|
"LabelDuration": "Duration",
|
||||||
"LabelDurationFound": "Duration found:",
|
"LabelDurationFound": "Duration found:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "Ebook",
|
||||||
@@ -230,6 +233,7 @@
|
|||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "From Address",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Secure",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Test Address",
|
||||||
"LabelEmbeddedCover": "Embedded Cover",
|
"LabelEmbeddedCover": "Embedded Cover",
|
||||||
"LabelEnable": "Enable",
|
"LabelEnable": "Enable",
|
||||||
"LabelEnd": "End",
|
"LabelEnd": "End",
|
||||||
@@ -248,6 +252,7 @@
|
|||||||
"LabelFinished": "Finished",
|
"LabelFinished": "Finished",
|
||||||
"LabelFolder": "Folder",
|
"LabelFolder": "Folder",
|
||||||
"LabelFolders": "Folders",
|
"LabelFolders": "Folders",
|
||||||
|
"LabelFontScale": "Font scale",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Genres",
|
"LabelGenres": "Genres",
|
||||||
@@ -279,12 +284,16 @@
|
|||||||
"LabelLastSeen": "Last Seen",
|
"LabelLastSeen": "Last Seen",
|
||||||
"LabelLastTime": "Last Time",
|
"LabelLastTime": "Last Time",
|
||||||
"LabelLastUpdate": "Last Update",
|
"LabelLastUpdate": "Last Update",
|
||||||
|
"LabelLayout": "Layout",
|
||||||
|
"LabelLayoutSinglePage": "Single page",
|
||||||
|
"LabelLayoutSplitPage": "Split page",
|
||||||
"LabelLess": "Less",
|
"LabelLess": "Less",
|
||||||
"LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
|
"LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
|
||||||
"LabelLibrary": "Library",
|
"LabelLibrary": "Library",
|
||||||
"LabelLibraryItem": "Library Item",
|
"LabelLibraryItem": "Library Item",
|
||||||
"LabelLibraryName": "Library Name",
|
"LabelLibraryName": "Library Name",
|
||||||
"LabelLimit": "Limit",
|
"LabelLimit": "Limit",
|
||||||
|
"LabelLineSpacing": "Line spacing",
|
||||||
"LabelListenAgain": "Listen Again",
|
"LabelListenAgain": "Listen Again",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
"LabelNewPassword": "New Password",
|
"LabelNewPassword": "New Password",
|
||||||
"LabelNextBackupDate": "Next backup date",
|
"LabelNextBackupDate": "Next backup date",
|
||||||
"LabelNextScheduledRun": "Next scheduled run",
|
"LabelNextScheduledRun": "Next scheduled run",
|
||||||
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotes": "Notes",
|
"LabelNotes": "Notes",
|
||||||
"LabelNotFinished": "Not Finished",
|
"LabelNotFinished": "Not Finished",
|
||||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||||
@@ -368,6 +378,8 @@
|
|||||||
"LabelSearchTitle": "Search Title",
|
"LabelSearchTitle": "Search Title",
|
||||||
"LabelSearchTitleOrASIN": "Search Title or ASIN",
|
"LabelSearchTitleOrASIN": "Search Title or ASIN",
|
||||||
"LabelSeason": "Season",
|
"LabelSeason": "Season",
|
||||||
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Sequence",
|
"LabelSequence": "Sequence",
|
||||||
"LabelSeries": "Series",
|
"LabelSeries": "Series",
|
||||||
@@ -387,6 +399,8 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
||||||
"LabelSettingsFindCovers": "Find covers",
|
"LabelSettingsFindCovers": "Find covers",
|
||||||
"LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time",
|
"LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
|
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
|
||||||
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
|
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
|
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
|
||||||
@@ -439,6 +453,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
||||||
"LabelTasks": "Tasks Running",
|
"LabelTasks": "Tasks Running",
|
||||||
|
"LabelTheme": "Theme",
|
||||||
|
"LabelThemeDark": "Dark",
|
||||||
|
"LabelThemeLight": "Light",
|
||||||
"LabelTimeBase": "Time Base",
|
"LabelTimeBase": "Time Base",
|
||||||
"LabelTimeListened": "Time Listened",
|
"LabelTimeListened": "Time Listened",
|
||||||
"LabelTimeListenedToday": "Time Listened Today",
|
"LabelTimeListenedToday": "Time Listened Today",
|
||||||
@@ -575,7 +592,6 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
|
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
|
||||||
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
|
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
|
||||||
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
|
|
||||||
"MessageRemoveChapter": "Remove chapter",
|
"MessageRemoveChapter": "Remove chapter",
|
||||||
"MessageRemoveEpisodes": "Remove {0} episode(s)",
|
"MessageRemoveEpisodes": "Remove {0} episode(s)",
|
||||||
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
||||||
|
|||||||
+18
-2
@@ -102,7 +102,8 @@
|
|||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Email Settings",
|
||||||
"HeaderEpisodes": "Episodios",
|
"HeaderEpisodes": "Episodios",
|
||||||
"HeaderEReaderDevices": "E-Reader Devices",
|
"HeaderEreaderDevices": "Ereader Devices",
|
||||||
|
"HeaderEreaderSettings": "Ereader Settings",
|
||||||
"HeaderFiles": "Elemento",
|
"HeaderFiles": "Elemento",
|
||||||
"HeaderFindChapters": "Buscar Capitulo",
|
"HeaderFindChapters": "Buscar Capitulo",
|
||||||
"HeaderIgnoredFiles": "Ignorar Elemento",
|
"HeaderIgnoredFiles": "Ignorar Elemento",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Sesiones Recientes",
|
"HeaderStatsRecentSessions": "Sesiones Recientes",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Autores",
|
"HeaderStatsTop10Authors": "Top 10 Autores",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Géneros",
|
"HeaderStatsTop5Genres": "Top 5 Géneros",
|
||||||
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Herramientas",
|
"HeaderTools": "Herramientas",
|
||||||
"HeaderUpdateAccount": "Actualizar Cuenta",
|
"HeaderUpdateAccount": "Actualizar Cuenta",
|
||||||
"HeaderUpdateAuthor": "Actualizar Autor",
|
"HeaderUpdateAuthor": "Actualizar Autor",
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
"LabelDiscFromFilename": "Disco a partir del Nombre del Archivo",
|
"LabelDiscFromFilename": "Disco a partir del Nombre del Archivo",
|
||||||
"LabelDiscFromMetadata": "Disco a partir de Metadata",
|
"LabelDiscFromMetadata": "Disco a partir de Metadata",
|
||||||
"LabelDownload": "Descargar",
|
"LabelDownload": "Descargar",
|
||||||
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Duración",
|
"LabelDuration": "Duración",
|
||||||
"LabelDurationFound": "Duración Comprobada:",
|
"LabelDurationFound": "Duración Comprobada:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "Ebook",
|
||||||
@@ -230,6 +233,7 @@
|
|||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "From Address",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Secure",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Test Address",
|
||||||
"LabelEmbeddedCover": "Portada Integrada",
|
"LabelEmbeddedCover": "Portada Integrada",
|
||||||
"LabelEnable": "Habilitar",
|
"LabelEnable": "Habilitar",
|
||||||
"LabelEnd": "Fin",
|
"LabelEnd": "Fin",
|
||||||
@@ -248,6 +252,7 @@
|
|||||||
"LabelFinished": "Terminado",
|
"LabelFinished": "Terminado",
|
||||||
"LabelFolder": "Carpeta",
|
"LabelFolder": "Carpeta",
|
||||||
"LabelFolders": "Carpetas",
|
"LabelFolders": "Carpetas",
|
||||||
|
"LabelFontScale": "Font scale",
|
||||||
"LabelFormat": "Formato",
|
"LabelFormat": "Formato",
|
||||||
"LabelGenre": "Genero",
|
"LabelGenre": "Genero",
|
||||||
"LabelGenres": "Géneros",
|
"LabelGenres": "Géneros",
|
||||||
@@ -279,12 +284,16 @@
|
|||||||
"LabelLastSeen": "Ultima Vez Visto",
|
"LabelLastSeen": "Ultima Vez Visto",
|
||||||
"LabelLastTime": "Ultima Vez",
|
"LabelLastTime": "Ultima Vez",
|
||||||
"LabelLastUpdate": "Ultima Actualización",
|
"LabelLastUpdate": "Ultima Actualización",
|
||||||
|
"LabelLayout": "Layout",
|
||||||
|
"LabelLayoutSinglePage": "Single page",
|
||||||
|
"LabelLayoutSplitPage": "Split page",
|
||||||
"LabelLess": "Menos",
|
"LabelLess": "Menos",
|
||||||
"LabelLibrariesAccessibleToUser": "Bibliotecas Disponibles para el Usuario",
|
"LabelLibrariesAccessibleToUser": "Bibliotecas Disponibles para el Usuario",
|
||||||
"LabelLibrary": "Biblioteca",
|
"LabelLibrary": "Biblioteca",
|
||||||
"LabelLibraryItem": "Elemento de Biblioteca",
|
"LabelLibraryItem": "Elemento de Biblioteca",
|
||||||
"LabelLibraryName": "Nombre de Biblioteca",
|
"LabelLibraryName": "Nombre de Biblioteca",
|
||||||
"LabelLimit": "Limites",
|
"LabelLimit": "Limites",
|
||||||
|
"LabelLineSpacing": "Line spacing",
|
||||||
"LabelListenAgain": "Escuchar Otra Vez",
|
"LabelListenAgain": "Escuchar Otra Vez",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
"LabelNewPassword": "Nueva Contraseña",
|
"LabelNewPassword": "Nueva Contraseña",
|
||||||
"LabelNextBackupDate": "Fecha del Siguiente Respaldo",
|
"LabelNextBackupDate": "Fecha del Siguiente Respaldo",
|
||||||
"LabelNextScheduledRun": "Próxima Ejecución Programada",
|
"LabelNextScheduledRun": "Próxima Ejecución Programada",
|
||||||
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotes": "Notas",
|
"LabelNotes": "Notas",
|
||||||
"LabelNotFinished": "No Terminado",
|
"LabelNotFinished": "No Terminado",
|
||||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||||
@@ -368,6 +378,8 @@
|
|||||||
"LabelSearchTitle": "Buscar Titulo",
|
"LabelSearchTitle": "Buscar Titulo",
|
||||||
"LabelSearchTitleOrASIN": "Buscar Titulo o ASIN",
|
"LabelSearchTitleOrASIN": "Buscar Titulo o ASIN",
|
||||||
"LabelSeason": "Temporada",
|
"LabelSeason": "Temporada",
|
||||||
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Secuencia",
|
"LabelSequence": "Secuencia",
|
||||||
"LabelSeries": "Series",
|
"LabelSeries": "Series",
|
||||||
@@ -387,6 +399,8 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo sobre las que esperamos sus comentarios y experiencia. Haga click aquí para abrir una conversación en Github.",
|
"LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo sobre las que esperamos sus comentarios y experiencia. Haga click aquí para abrir una conversación en Github.",
|
||||||
"LabelSettingsFindCovers": "Buscar Portadas",
|
"LabelSettingsFindCovers": "Buscar Portadas",
|
||||||
"LabelSettingsFindCoversHelp": "Si tu audiolibro no tiene una portada incluida o la portada no esta dentro de la carpeta, el escaneador tratara de encontrar una portada.<br>Nota: Esto extenderá el tiempo de escaneo",
|
"LabelSettingsFindCoversHelp": "Si tu audiolibro no tiene una portada incluida o la portada no esta dentro de la carpeta, el escaneador tratara de encontrar una portada.<br>Nota: Esto extenderá el tiempo de escaneo",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||||
"LabelSettingsHomePageBookshelfView": "La pagina de inicio usa la vista de librero",
|
"LabelSettingsHomePageBookshelfView": "La pagina de inicio usa la vista de librero",
|
||||||
"LabelSettingsLibraryBookshelfView": "La biblioteca usa la vista de librero",
|
"LabelSettingsLibraryBookshelfView": "La biblioteca usa la vista de librero",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Usar Markers de multimedia en Overdrive para estos capítulos",
|
"LabelSettingsOverdriveMediaMarkers": "Usar Markers de multimedia en Overdrive para estos capítulos",
|
||||||
@@ -439,6 +453,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "Etiquetas Accessible para el Usuario",
|
"LabelTagsAccessibleToUser": "Etiquetas Accessible para el Usuario",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
||||||
"LabelTasks": "Tareas Corriendo",
|
"LabelTasks": "Tareas Corriendo",
|
||||||
|
"LabelTheme": "Theme",
|
||||||
|
"LabelThemeDark": "Dark",
|
||||||
|
"LabelThemeLight": "Light",
|
||||||
"LabelTimeBase": "Time Base",
|
"LabelTimeBase": "Time Base",
|
||||||
"LabelTimeListened": "Tiempo Escuchando",
|
"LabelTimeListened": "Tiempo Escuchando",
|
||||||
"LabelTimeListenedToday": "Tiempo Escuchando Hoy",
|
"LabelTimeListenedToday": "Tiempo Escuchando Hoy",
|
||||||
@@ -575,7 +592,6 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "Crear lista de reproducción a partir de colección",
|
"MessagePlaylistCreateFromCollection": "Crear lista de reproducción a partir de colección",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "El podcast no tiene una URL de fuente RSS que pueda usar que coincida",
|
"MessagePodcastHasNoRSSFeedForMatching": "El podcast no tiene una URL de fuente RSS que pueda usar que coincida",
|
||||||
"MessageQuickMatchDescription": "Rellenar detalles de elementos vacíos y portada con los primeros resultados de '{0}'. No sobrescribe los detalles a menos que la configuración 'Prefer matched metadata' del servidor este habilita.",
|
"MessageQuickMatchDescription": "Rellenar detalles de elementos vacíos y portada con los primeros resultados de '{0}'. No sobrescribe los detalles a menos que la configuración 'Prefer matched metadata' del servidor este habilita.",
|
||||||
"MessageRemoveAllItemsWarning": "ADVERTENCIA! Esta acción eliminará todos los elementos de la biblioteca de la base de datos incluyendo cualquier actualización o match. Esto no hace nada a sus archivos reales. Esta seguro que desea continuar?",
|
|
||||||
"MessageRemoveChapter": "Remover capítulos",
|
"MessageRemoveChapter": "Remover capítulos",
|
||||||
"MessageRemoveEpisodes": "Remover {0} episodio(s)",
|
"MessageRemoveEpisodes": "Remover {0} episodio(s)",
|
||||||
"MessageRemoveFromPlayerQueue": "Romover la cola de reporduccion",
|
"MessageRemoveFromPlayerQueue": "Romover la cola de reporduccion",
|
||||||
|
|||||||
+18
-2
@@ -102,7 +102,8 @@
|
|||||||
"HeaderEmail": "E-mails",
|
"HeaderEmail": "E-mails",
|
||||||
"HeaderEmailSettings": "Configuration des e-mails",
|
"HeaderEmailSettings": "Configuration des e-mails",
|
||||||
"HeaderEpisodes": "Épisodes",
|
"HeaderEpisodes": "Épisodes",
|
||||||
"HeaderEReaderDevices": "Lecteurs d'e-books",
|
"HeaderEreaderDevices": "Lecteurs d'e-books",
|
||||||
|
"HeaderEreaderSettings": "Ereader Settings",
|
||||||
"HeaderFiles": "Fichiers",
|
"HeaderFiles": "Fichiers",
|
||||||
"HeaderFindChapters": "Trouver les chapitres",
|
"HeaderFindChapters": "Trouver les chapitres",
|
||||||
"HeaderIgnoredFiles": "Fichiers Ignorés",
|
"HeaderIgnoredFiles": "Fichiers Ignorés",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Sessions récentes",
|
"HeaderStatsRecentSessions": "Sessions récentes",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Auteurs",
|
"HeaderStatsTop10Authors": "Top 10 Auteurs",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Genres",
|
"HeaderStatsTop5Genres": "Top 5 Genres",
|
||||||
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Outils",
|
"HeaderTools": "Outils",
|
||||||
"HeaderUpdateAccount": "Mettre à jour le compte",
|
"HeaderUpdateAccount": "Mettre à jour le compte",
|
||||||
"HeaderUpdateAuthor": "Mettre à jour l’auteur",
|
"HeaderUpdateAuthor": "Mettre à jour l’auteur",
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
"LabelDiscFromFilename": "Disque depuis le fichier",
|
"LabelDiscFromFilename": "Disque depuis le fichier",
|
||||||
"LabelDiscFromMetadata": "Disque depuis les métadonnées",
|
"LabelDiscFromMetadata": "Disque depuis les métadonnées",
|
||||||
"LabelDownload": "Téléchargement",
|
"LabelDownload": "Téléchargement",
|
||||||
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Durée",
|
"LabelDuration": "Durée",
|
||||||
"LabelDurationFound": "Durée trouvée :",
|
"LabelDurationFound": "Durée trouvée :",
|
||||||
"LabelEbook": "E-book",
|
"LabelEbook": "E-book",
|
||||||
@@ -230,6 +233,7 @@
|
|||||||
"LabelEmailSettingsFromAddress": "Expéditeur",
|
"LabelEmailSettingsFromAddress": "Expéditeur",
|
||||||
"LabelEmailSettingsSecure": "Sécurisé",
|
"LabelEmailSettingsSecure": "Sécurisé",
|
||||||
"LabelEmailSettingsSecureHelp": "Si coché, la connexion utilisera TLS lors de la connexion au serveur. Sinon TLS est utilisé si le serveur prend en charge l'extension STARTTLS. Dans la plupart des cas, cochez si vous vous connectez au port 465. Décochez pour le port 587 ou 25. (source: nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "Si coché, la connexion utilisera TLS lors de la connexion au serveur. Sinon TLS est utilisé si le serveur prend en charge l'extension STARTTLS. Dans la plupart des cas, cochez si vous vous connectez au port 465. Décochez pour le port 587 ou 25. (source: nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Test Address",
|
||||||
"LabelEmbeddedCover": "Couverture du livre intégrée",
|
"LabelEmbeddedCover": "Couverture du livre intégrée",
|
||||||
"LabelEnable": "Activer",
|
"LabelEnable": "Activer",
|
||||||
"LabelEnd": "Fin",
|
"LabelEnd": "Fin",
|
||||||
@@ -248,6 +252,7 @@
|
|||||||
"LabelFinished": "Fini(e)",
|
"LabelFinished": "Fini(e)",
|
||||||
"LabelFolder": "Dossier",
|
"LabelFolder": "Dossier",
|
||||||
"LabelFolders": "Dossiers",
|
"LabelFolders": "Dossiers",
|
||||||
|
"LabelFontScale": "Font scale",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Genres",
|
"LabelGenres": "Genres",
|
||||||
@@ -279,12 +284,16 @@
|
|||||||
"LabelLastSeen": "Vu dernièrement",
|
"LabelLastSeen": "Vu dernièrement",
|
||||||
"LabelLastTime": "Progression",
|
"LabelLastTime": "Progression",
|
||||||
"LabelLastUpdate": "Dernière mise à jour",
|
"LabelLastUpdate": "Dernière mise à jour",
|
||||||
|
"LabelLayout": "Layout",
|
||||||
|
"LabelLayoutSinglePage": "Single page",
|
||||||
|
"LabelLayoutSplitPage": "Split page",
|
||||||
"LabelLess": "Moins",
|
"LabelLess": "Moins",
|
||||||
"LabelLibrariesAccessibleToUser": "Bibliothèque accessible à l’utilisateur",
|
"LabelLibrariesAccessibleToUser": "Bibliothèque accessible à l’utilisateur",
|
||||||
"LabelLibrary": "Bibliothèque",
|
"LabelLibrary": "Bibliothèque",
|
||||||
"LabelLibraryItem": "Article de bibliothèque",
|
"LabelLibraryItem": "Article de bibliothèque",
|
||||||
"LabelLibraryName": "Nom de la bibliothèque",
|
"LabelLibraryName": "Nom de la bibliothèque",
|
||||||
"LabelLimit": "Limite",
|
"LabelLimit": "Limite",
|
||||||
|
"LabelLineSpacing": "Line spacing",
|
||||||
"LabelListenAgain": "Écouter à nouveau",
|
"LabelListenAgain": "Écouter à nouveau",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
"LabelNewPassword": "Nouveau mot de passe",
|
"LabelNewPassword": "Nouveau mot de passe",
|
||||||
"LabelNextBackupDate": "Date de la prochaine sauvegarde",
|
"LabelNextBackupDate": "Date de la prochaine sauvegarde",
|
||||||
"LabelNextScheduledRun": "Prochain lancement prévu",
|
"LabelNextScheduledRun": "Prochain lancement prévu",
|
||||||
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotes": "Notes",
|
"LabelNotes": "Notes",
|
||||||
"LabelNotFinished": "Non terminé(e)",
|
"LabelNotFinished": "Non terminé(e)",
|
||||||
"LabelNotificationAppriseURL": "URL(s) d’Apprise",
|
"LabelNotificationAppriseURL": "URL(s) d’Apprise",
|
||||||
@@ -368,6 +378,8 @@
|
|||||||
"LabelSearchTitle": "Titre de recherche",
|
"LabelSearchTitle": "Titre de recherche",
|
||||||
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
|
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
|
||||||
"LabelSeason": "Saison",
|
"LabelSeason": "Saison",
|
||||||
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
"LabelSendEbookToDevice": "Envoyer l'e-book à...",
|
"LabelSendEbookToDevice": "Envoyer l'e-book à...",
|
||||||
"LabelSequence": "Séquence",
|
"LabelSequence": "Séquence",
|
||||||
"LabelSeries": "Séries",
|
"LabelSeries": "Séries",
|
||||||
@@ -387,6 +399,8 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion Github.",
|
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion Github.",
|
||||||
"LabelSettingsFindCovers": "Chercher des couvertures de livre",
|
"LabelSettingsFindCovers": "Chercher des couvertures de livre",
|
||||||
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, l’analyseur tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps d’analyse.",
|
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, l’analyseur tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps d’analyse.",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||||
"LabelSettingsHomePageBookshelfView": "La page d’accueil utilise la vue étagère",
|
"LabelSettingsHomePageBookshelfView": "La page d’accueil utilise la vue étagère",
|
||||||
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
|
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres",
|
"LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres",
|
||||||
@@ -439,6 +453,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "Étiquettes accessibles à l’utilisateur",
|
"LabelTagsAccessibleToUser": "Étiquettes accessibles à l’utilisateur",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
||||||
"LabelTasks": "Tâches en cours",
|
"LabelTasks": "Tâches en cours",
|
||||||
|
"LabelTheme": "Theme",
|
||||||
|
"LabelThemeDark": "Dark",
|
||||||
|
"LabelThemeLight": "Light",
|
||||||
"LabelTimeBase": "Base de temps",
|
"LabelTimeBase": "Base de temps",
|
||||||
"LabelTimeListened": "Temps d’écoute",
|
"LabelTimeListened": "Temps d’écoute",
|
||||||
"LabelTimeListenedToday": "Nombres d’écoutes Aujourd’hui",
|
"LabelTimeListenedToday": "Nombres d’écoutes Aujourd’hui",
|
||||||
@@ -575,7 +592,6 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "Créer une liste de lecture depuis la collection",
|
"MessagePlaylistCreateFromCollection": "Créer une liste de lecture depuis la collection",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Le Podcast n’a pas d’URL de flux RSS à utiliser pour la correspondance",
|
"MessagePodcastHasNoRSSFeedForMatching": "Le Podcast n’a pas d’URL de flux RSS à utiliser pour la correspondance",
|
||||||
"MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de « {0} ». N’écrase pas les données présentes à moins que le paramètre « Préférer les Métadonnées par correspondance » soit activé.",
|
"MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de « {0} ». N’écrase pas les données présentes à moins que le paramètre « Préférer les Métadonnées par correspondance » soit activé.",
|
||||||
"MessageRemoveAllItemsWarning": "ATTENTION ! Cette action supprimera toute la base de données de la bibliothèque ainsi que les mises à jour ou correspondances qui auraient été effectuées. Cela n’a aucune incidence sur les fichiers de la bibliothèque. Souhaitez-vous continuer ?",
|
|
||||||
"MessageRemoveChapter": "Supprimer le chapitre",
|
"MessageRemoveChapter": "Supprimer le chapitre",
|
||||||
"MessageRemoveEpisodes": "Suppression de {0} épisode(s)",
|
"MessageRemoveEpisodes": "Suppression de {0} épisode(s)",
|
||||||
"MessageRemoveFromPlayerQueue": "Supprimer de la liste d’écoute",
|
"MessageRemoveFromPlayerQueue": "Supprimer de la liste d’écoute",
|
||||||
|
|||||||
+18
-2
@@ -102,7 +102,8 @@
|
|||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Email Settings",
|
||||||
"HeaderEpisodes": "Episodes",
|
"HeaderEpisodes": "Episodes",
|
||||||
"HeaderEReaderDevices": "E-Reader Devices",
|
"HeaderEreaderDevices": "Ereader Devices",
|
||||||
|
"HeaderEreaderSettings": "Ereader Settings",
|
||||||
"HeaderFiles": "Files",
|
"HeaderFiles": "Files",
|
||||||
"HeaderFindChapters": "Find Chapters",
|
"HeaderFindChapters": "Find Chapters",
|
||||||
"HeaderIgnoredFiles": "Ignored Files",
|
"HeaderIgnoredFiles": "Ignored Files",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Recent Sessions",
|
"HeaderStatsRecentSessions": "Recent Sessions",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Authors",
|
"HeaderStatsTop10Authors": "Top 10 Authors",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Genres",
|
"HeaderStatsTop5Genres": "Top 5 Genres",
|
||||||
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Tools",
|
"HeaderTools": "Tools",
|
||||||
"HeaderUpdateAccount": "Update Account",
|
"HeaderUpdateAccount": "Update Account",
|
||||||
"HeaderUpdateAuthor": "Update Author",
|
"HeaderUpdateAuthor": "Update Author",
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
"LabelDiscFromFilename": "Disc from Filename",
|
"LabelDiscFromFilename": "Disc from Filename",
|
||||||
"LabelDiscFromMetadata": "Disc from Metadata",
|
"LabelDiscFromMetadata": "Disc from Metadata",
|
||||||
"LabelDownload": "Download",
|
"LabelDownload": "Download",
|
||||||
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Duration",
|
"LabelDuration": "Duration",
|
||||||
"LabelDurationFound": "Duration found:",
|
"LabelDurationFound": "Duration found:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "Ebook",
|
||||||
@@ -230,6 +233,7 @@
|
|||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "From Address",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Secure",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Test Address",
|
||||||
"LabelEmbeddedCover": "Embedded Cover",
|
"LabelEmbeddedCover": "Embedded Cover",
|
||||||
"LabelEnable": "Enable",
|
"LabelEnable": "Enable",
|
||||||
"LabelEnd": "End",
|
"LabelEnd": "End",
|
||||||
@@ -248,6 +252,7 @@
|
|||||||
"LabelFinished": "Finished",
|
"LabelFinished": "Finished",
|
||||||
"LabelFolder": "Folder",
|
"LabelFolder": "Folder",
|
||||||
"LabelFolders": "Folders",
|
"LabelFolders": "Folders",
|
||||||
|
"LabelFontScale": "Font scale",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Genres",
|
"LabelGenres": "Genres",
|
||||||
@@ -279,12 +284,16 @@
|
|||||||
"LabelLastSeen": "Last Seen",
|
"LabelLastSeen": "Last Seen",
|
||||||
"LabelLastTime": "Last Time",
|
"LabelLastTime": "Last Time",
|
||||||
"LabelLastUpdate": "Last Update",
|
"LabelLastUpdate": "Last Update",
|
||||||
|
"LabelLayout": "Layout",
|
||||||
|
"LabelLayoutSinglePage": "Single page",
|
||||||
|
"LabelLayoutSplitPage": "Split page",
|
||||||
"LabelLess": "Less",
|
"LabelLess": "Less",
|
||||||
"LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
|
"LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
|
||||||
"LabelLibrary": "Library",
|
"LabelLibrary": "Library",
|
||||||
"LabelLibraryItem": "Library Item",
|
"LabelLibraryItem": "Library Item",
|
||||||
"LabelLibraryName": "Library Name",
|
"LabelLibraryName": "Library Name",
|
||||||
"LabelLimit": "Limit",
|
"LabelLimit": "Limit",
|
||||||
|
"LabelLineSpacing": "Line spacing",
|
||||||
"LabelListenAgain": "Listen Again",
|
"LabelListenAgain": "Listen Again",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
"LabelNewPassword": "New Password",
|
"LabelNewPassword": "New Password",
|
||||||
"LabelNextBackupDate": "Next backup date",
|
"LabelNextBackupDate": "Next backup date",
|
||||||
"LabelNextScheduledRun": "Next scheduled run",
|
"LabelNextScheduledRun": "Next scheduled run",
|
||||||
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotes": "Notes",
|
"LabelNotes": "Notes",
|
||||||
"LabelNotFinished": "Not Finished",
|
"LabelNotFinished": "Not Finished",
|
||||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||||
@@ -368,6 +378,8 @@
|
|||||||
"LabelSearchTitle": "Search Title",
|
"LabelSearchTitle": "Search Title",
|
||||||
"LabelSearchTitleOrASIN": "Search Title or ASIN",
|
"LabelSearchTitleOrASIN": "Search Title or ASIN",
|
||||||
"LabelSeason": "Season",
|
"LabelSeason": "Season",
|
||||||
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Sequence",
|
"LabelSequence": "Sequence",
|
||||||
"LabelSeries": "Series",
|
"LabelSeries": "Series",
|
||||||
@@ -387,6 +399,8 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
||||||
"LabelSettingsFindCovers": "Find covers",
|
"LabelSettingsFindCovers": "Find covers",
|
||||||
"LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time",
|
"LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
|
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
|
||||||
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
|
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
|
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
|
||||||
@@ -439,6 +453,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
||||||
"LabelTasks": "Tasks Running",
|
"LabelTasks": "Tasks Running",
|
||||||
|
"LabelTheme": "Theme",
|
||||||
|
"LabelThemeDark": "Dark",
|
||||||
|
"LabelThemeLight": "Light",
|
||||||
"LabelTimeBase": "Time Base",
|
"LabelTimeBase": "Time Base",
|
||||||
"LabelTimeListened": "Time Listened",
|
"LabelTimeListened": "Time Listened",
|
||||||
"LabelTimeListenedToday": "Time Listened Today",
|
"LabelTimeListenedToday": "Time Listened Today",
|
||||||
@@ -575,7 +592,6 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
|
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
|
||||||
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
|
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
|
||||||
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
|
|
||||||
"MessageRemoveChapter": "Remove chapter",
|
"MessageRemoveChapter": "Remove chapter",
|
||||||
"MessageRemoveEpisodes": "Remove {0} episode(s)",
|
"MessageRemoveEpisodes": "Remove {0} episode(s)",
|
||||||
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
||||||
|
|||||||
+18
-2
@@ -102,7 +102,8 @@
|
|||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Email Settings",
|
||||||
"HeaderEpisodes": "Episodes",
|
"HeaderEpisodes": "Episodes",
|
||||||
"HeaderEReaderDevices": "E-Reader Devices",
|
"HeaderEreaderDevices": "Ereader Devices",
|
||||||
|
"HeaderEreaderSettings": "Ereader Settings",
|
||||||
"HeaderFiles": "Files",
|
"HeaderFiles": "Files",
|
||||||
"HeaderFindChapters": "Find Chapters",
|
"HeaderFindChapters": "Find Chapters",
|
||||||
"HeaderIgnoredFiles": "Ignored Files",
|
"HeaderIgnoredFiles": "Ignored Files",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Recent Sessions",
|
"HeaderStatsRecentSessions": "Recent Sessions",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Authors",
|
"HeaderStatsTop10Authors": "Top 10 Authors",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Genres",
|
"HeaderStatsTop5Genres": "Top 5 Genres",
|
||||||
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Tools",
|
"HeaderTools": "Tools",
|
||||||
"HeaderUpdateAccount": "Update Account",
|
"HeaderUpdateAccount": "Update Account",
|
||||||
"HeaderUpdateAuthor": "Update Author",
|
"HeaderUpdateAuthor": "Update Author",
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
"LabelDiscFromFilename": "Disc from Filename",
|
"LabelDiscFromFilename": "Disc from Filename",
|
||||||
"LabelDiscFromMetadata": "Disc from Metadata",
|
"LabelDiscFromMetadata": "Disc from Metadata",
|
||||||
"LabelDownload": "Download",
|
"LabelDownload": "Download",
|
||||||
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Duration",
|
"LabelDuration": "Duration",
|
||||||
"LabelDurationFound": "Duration found:",
|
"LabelDurationFound": "Duration found:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "Ebook",
|
||||||
@@ -230,6 +233,7 @@
|
|||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "From Address",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Secure",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Test Address",
|
||||||
"LabelEmbeddedCover": "Embedded Cover",
|
"LabelEmbeddedCover": "Embedded Cover",
|
||||||
"LabelEnable": "Enable",
|
"LabelEnable": "Enable",
|
||||||
"LabelEnd": "End",
|
"LabelEnd": "End",
|
||||||
@@ -248,6 +252,7 @@
|
|||||||
"LabelFinished": "Finished",
|
"LabelFinished": "Finished",
|
||||||
"LabelFolder": "Folder",
|
"LabelFolder": "Folder",
|
||||||
"LabelFolders": "Folders",
|
"LabelFolders": "Folders",
|
||||||
|
"LabelFontScale": "Font scale",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Genres",
|
"LabelGenres": "Genres",
|
||||||
@@ -279,12 +284,16 @@
|
|||||||
"LabelLastSeen": "Last Seen",
|
"LabelLastSeen": "Last Seen",
|
||||||
"LabelLastTime": "Last Time",
|
"LabelLastTime": "Last Time",
|
||||||
"LabelLastUpdate": "Last Update",
|
"LabelLastUpdate": "Last Update",
|
||||||
|
"LabelLayout": "Layout",
|
||||||
|
"LabelLayoutSinglePage": "Single page",
|
||||||
|
"LabelLayoutSplitPage": "Split page",
|
||||||
"LabelLess": "Less",
|
"LabelLess": "Less",
|
||||||
"LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
|
"LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
|
||||||
"LabelLibrary": "Library",
|
"LabelLibrary": "Library",
|
||||||
"LabelLibraryItem": "Library Item",
|
"LabelLibraryItem": "Library Item",
|
||||||
"LabelLibraryName": "Library Name",
|
"LabelLibraryName": "Library Name",
|
||||||
"LabelLimit": "Limit",
|
"LabelLimit": "Limit",
|
||||||
|
"LabelLineSpacing": "Line spacing",
|
||||||
"LabelListenAgain": "Listen Again",
|
"LabelListenAgain": "Listen Again",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
"LabelNewPassword": "New Password",
|
"LabelNewPassword": "New Password",
|
||||||
"LabelNextBackupDate": "Next backup date",
|
"LabelNextBackupDate": "Next backup date",
|
||||||
"LabelNextScheduledRun": "Next scheduled run",
|
"LabelNextScheduledRun": "Next scheduled run",
|
||||||
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotes": "Notes",
|
"LabelNotes": "Notes",
|
||||||
"LabelNotFinished": "Not Finished",
|
"LabelNotFinished": "Not Finished",
|
||||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||||
@@ -368,6 +378,8 @@
|
|||||||
"LabelSearchTitle": "Search Title",
|
"LabelSearchTitle": "Search Title",
|
||||||
"LabelSearchTitleOrASIN": "Search Title or ASIN",
|
"LabelSearchTitleOrASIN": "Search Title or ASIN",
|
||||||
"LabelSeason": "Season",
|
"LabelSeason": "Season",
|
||||||
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Sequence",
|
"LabelSequence": "Sequence",
|
||||||
"LabelSeries": "Series",
|
"LabelSeries": "Series",
|
||||||
@@ -387,6 +399,8 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
||||||
"LabelSettingsFindCovers": "Find covers",
|
"LabelSettingsFindCovers": "Find covers",
|
||||||
"LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time",
|
"LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
|
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
|
||||||
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
|
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
|
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
|
||||||
@@ -439,6 +453,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
||||||
"LabelTasks": "Tasks Running",
|
"LabelTasks": "Tasks Running",
|
||||||
|
"LabelTheme": "Theme",
|
||||||
|
"LabelThemeDark": "Dark",
|
||||||
|
"LabelThemeLight": "Light",
|
||||||
"LabelTimeBase": "Time Base",
|
"LabelTimeBase": "Time Base",
|
||||||
"LabelTimeListened": "Time Listened",
|
"LabelTimeListened": "Time Listened",
|
||||||
"LabelTimeListenedToday": "Time Listened Today",
|
"LabelTimeListenedToday": "Time Listened Today",
|
||||||
@@ -575,7 +592,6 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
|
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
|
||||||
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
|
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
|
||||||
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
|
|
||||||
"MessageRemoveChapter": "Remove chapter",
|
"MessageRemoveChapter": "Remove chapter",
|
||||||
"MessageRemoveEpisodes": "Remove {0} episode(s)",
|
"MessageRemoveEpisodes": "Remove {0} episode(s)",
|
||||||
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
||||||
|
|||||||
+18
-2
@@ -102,7 +102,8 @@
|
|||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Email Settings",
|
||||||
"HeaderEpisodes": "Epizode",
|
"HeaderEpisodes": "Epizode",
|
||||||
"HeaderEReaderDevices": "E-Reader Devices",
|
"HeaderEreaderDevices": "Ereader Devices",
|
||||||
|
"HeaderEreaderSettings": "Ereader Settings",
|
||||||
"HeaderFiles": "Datoteke",
|
"HeaderFiles": "Datoteke",
|
||||||
"HeaderFindChapters": "Pronađi poglavlja",
|
"HeaderFindChapters": "Pronađi poglavlja",
|
||||||
"HeaderIgnoredFiles": "Zanemarene datoteke",
|
"HeaderIgnoredFiles": "Zanemarene datoteke",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Nedavne sesije",
|
"HeaderStatsRecentSessions": "Nedavne sesije",
|
||||||
"HeaderStatsTop10Authors": "Top 10 autora",
|
"HeaderStatsTop10Authors": "Top 10 autora",
|
||||||
"HeaderStatsTop5Genres": "Top 5 žanrova",
|
"HeaderStatsTop5Genres": "Top 5 žanrova",
|
||||||
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Alati",
|
"HeaderTools": "Alati",
|
||||||
"HeaderUpdateAccount": "Aktualiziraj Korisnički račun",
|
"HeaderUpdateAccount": "Aktualiziraj Korisnički račun",
|
||||||
"HeaderUpdateAuthor": "Aktualiziraj autora",
|
"HeaderUpdateAuthor": "Aktualiziraj autora",
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
"LabelDiscFromFilename": "CD iz imena datoteke",
|
"LabelDiscFromFilename": "CD iz imena datoteke",
|
||||||
"LabelDiscFromMetadata": "CD iz metapodataka",
|
"LabelDiscFromMetadata": "CD iz metapodataka",
|
||||||
"LabelDownload": "Preuzmi",
|
"LabelDownload": "Preuzmi",
|
||||||
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Trajanje",
|
"LabelDuration": "Trajanje",
|
||||||
"LabelDurationFound": "Pronađeno trajanje:",
|
"LabelDurationFound": "Pronađeno trajanje:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "Ebook",
|
||||||
@@ -230,6 +233,7 @@
|
|||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "From Address",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Secure",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Test Address",
|
||||||
"LabelEmbeddedCover": "Embedded Cover",
|
"LabelEmbeddedCover": "Embedded Cover",
|
||||||
"LabelEnable": "Uključi",
|
"LabelEnable": "Uključi",
|
||||||
"LabelEnd": "Kraj",
|
"LabelEnd": "Kraj",
|
||||||
@@ -248,6 +252,7 @@
|
|||||||
"LabelFinished": "Finished",
|
"LabelFinished": "Finished",
|
||||||
"LabelFolder": "Folder",
|
"LabelFolder": "Folder",
|
||||||
"LabelFolders": "Folderi",
|
"LabelFolders": "Folderi",
|
||||||
|
"LabelFontScale": "Font scale",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Žanrovi",
|
"LabelGenres": "Žanrovi",
|
||||||
@@ -279,12 +284,16 @@
|
|||||||
"LabelLastSeen": "Zadnje pogledano",
|
"LabelLastSeen": "Zadnje pogledano",
|
||||||
"LabelLastTime": "Prošli put",
|
"LabelLastTime": "Prošli put",
|
||||||
"LabelLastUpdate": "Zadnja aktualizacija",
|
"LabelLastUpdate": "Zadnja aktualizacija",
|
||||||
|
"LabelLayout": "Layout",
|
||||||
|
"LabelLayoutSinglePage": "Single page",
|
||||||
|
"LabelLayoutSplitPage": "Split page",
|
||||||
"LabelLess": "Manje",
|
"LabelLess": "Manje",
|
||||||
"LabelLibrariesAccessibleToUser": "Biblioteke pristupačne korisniku",
|
"LabelLibrariesAccessibleToUser": "Biblioteke pristupačne korisniku",
|
||||||
"LabelLibrary": "Biblioteka",
|
"LabelLibrary": "Biblioteka",
|
||||||
"LabelLibraryItem": "Stavka biblioteke",
|
"LabelLibraryItem": "Stavka biblioteke",
|
||||||
"LabelLibraryName": "Ime biblioteke",
|
"LabelLibraryName": "Ime biblioteke",
|
||||||
"LabelLimit": "Limit",
|
"LabelLimit": "Limit",
|
||||||
|
"LabelLineSpacing": "Line spacing",
|
||||||
"LabelListenAgain": "Slušaj ponovno",
|
"LabelListenAgain": "Slušaj ponovno",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
"LabelNewPassword": "Nova lozinka",
|
"LabelNewPassword": "Nova lozinka",
|
||||||
"LabelNextBackupDate": "Next backup date",
|
"LabelNextBackupDate": "Next backup date",
|
||||||
"LabelNextScheduledRun": "Next scheduled run",
|
"LabelNextScheduledRun": "Next scheduled run",
|
||||||
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotes": "Bilješke",
|
"LabelNotes": "Bilješke",
|
||||||
"LabelNotFinished": "Nedovršeno",
|
"LabelNotFinished": "Nedovršeno",
|
||||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||||
@@ -368,6 +378,8 @@
|
|||||||
"LabelSearchTitle": "Traži naslov",
|
"LabelSearchTitle": "Traži naslov",
|
||||||
"LabelSearchTitleOrASIN": "Traži naslov ili ASIN",
|
"LabelSearchTitleOrASIN": "Traži naslov ili ASIN",
|
||||||
"LabelSeason": "Sezona",
|
"LabelSeason": "Sezona",
|
||||||
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Sekvenca",
|
"LabelSequence": "Sekvenca",
|
||||||
"LabelSeries": "Serije",
|
"LabelSeries": "Serije",
|
||||||
@@ -387,6 +399,8 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.",
|
"LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.",
|
||||||
"LabelSettingsFindCovers": "Pronađi covers",
|
"LabelSettingsFindCovers": "Pronađi covers",
|
||||||
"LabelSettingsFindCoversHelp": "Ako audiobook nema embedani cover or a cover sliku unutar foldera, skener će probati pronaći cover.<br>Bilješka: Ovo će produžiti trjanje skeniranja",
|
"LabelSettingsFindCoversHelp": "Ako audiobook nema embedani cover or a cover sliku unutar foldera, skener će probati pronaći cover.<br>Bilješka: Ovo će produžiti trjanje skeniranja",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Koristi bookshelf pogled za početnu stranicu",
|
"LabelSettingsHomePageBookshelfView": "Koristi bookshelf pogled za početnu stranicu",
|
||||||
"LabelSettingsLibraryBookshelfView": "Koristi bookshelf pogled za biblioteku",
|
"LabelSettingsLibraryBookshelfView": "Koristi bookshelf pogled za biblioteku",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Koristi Overdrive Media Markers za poglavlja",
|
"LabelSettingsOverdriveMediaMarkers": "Koristi Overdrive Media Markers za poglavlja",
|
||||||
@@ -439,6 +453,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "Tags dostupni korisniku",
|
"LabelTagsAccessibleToUser": "Tags dostupni korisniku",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
||||||
"LabelTasks": "Tasks Running",
|
"LabelTasks": "Tasks Running",
|
||||||
|
"LabelTheme": "Theme",
|
||||||
|
"LabelThemeDark": "Dark",
|
||||||
|
"LabelThemeLight": "Light",
|
||||||
"LabelTimeBase": "Time Base",
|
"LabelTimeBase": "Time Base",
|
||||||
"LabelTimeListened": "Vremena odslušano",
|
"LabelTimeListened": "Vremena odslušano",
|
||||||
"LabelTimeListenedToday": "Vremena odslušano danas",
|
"LabelTimeListenedToday": "Vremena odslušano danas",
|
||||||
@@ -575,7 +592,6 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nema RSS feed url za matchanje",
|
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nema RSS feed url za matchanje",
|
||||||
"MessageQuickMatchDescription": "Popuni prazne detalje stavki i cover sa prvim match rezultato iz '{0}'. Ne briše detalje osim ako 'Prefer matched metadata' server postavka nije uključena.",
|
"MessageQuickMatchDescription": "Popuni prazne detalje stavki i cover sa prvim match rezultato iz '{0}'. Ne briše detalje osim ako 'Prefer matched metadata' server postavka nije uključena.",
|
||||||
"MessageRemoveAllItemsWarning": "UPOZORENJE! Ova radnja briše sve stavke iz biblioteke uključujući bilokakve aktualizacije ili matcheve. Ovo ne mjenja vaše lokalne datoteke. Jeste li sigurni?",
|
|
||||||
"MessageRemoveChapter": "Remove chapter",
|
"MessageRemoveChapter": "Remove chapter",
|
||||||
"MessageRemoveEpisodes": "ukloni {0} epizoda/-e",
|
"MessageRemoveEpisodes": "ukloni {0} epizoda/-e",
|
||||||
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
||||||
|
|||||||
+18
-2
@@ -102,7 +102,8 @@
|
|||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Email Settings",
|
||||||
"HeaderEpisodes": "Episodi",
|
"HeaderEpisodes": "Episodi",
|
||||||
"HeaderEReaderDevices": "E-Reader Devices",
|
"HeaderEreaderDevices": "Ereader Devices",
|
||||||
|
"HeaderEreaderSettings": "Ereader Settings",
|
||||||
"HeaderFiles": "File",
|
"HeaderFiles": "File",
|
||||||
"HeaderFindChapters": "Trova Capitoli",
|
"HeaderFindChapters": "Trova Capitoli",
|
||||||
"HeaderIgnoredFiles": "File Ignorati",
|
"HeaderIgnoredFiles": "File Ignorati",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Sessioni Recenti",
|
"HeaderStatsRecentSessions": "Sessioni Recenti",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Autori",
|
"HeaderStatsTop10Authors": "Top 10 Autori",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Generi",
|
"HeaderStatsTop5Genres": "Top 5 Generi",
|
||||||
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Strumenti",
|
"HeaderTools": "Strumenti",
|
||||||
"HeaderUpdateAccount": "Aggiorna Account",
|
"HeaderUpdateAccount": "Aggiorna Account",
|
||||||
"HeaderUpdateAuthor": "Aggiorna Autore",
|
"HeaderUpdateAuthor": "Aggiorna Autore",
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
"LabelDiscFromFilename": "Disco dal nome file",
|
"LabelDiscFromFilename": "Disco dal nome file",
|
||||||
"LabelDiscFromMetadata": "Disco dal Metadata",
|
"LabelDiscFromMetadata": "Disco dal Metadata",
|
||||||
"LabelDownload": "Download",
|
"LabelDownload": "Download",
|
||||||
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Durata",
|
"LabelDuration": "Durata",
|
||||||
"LabelDurationFound": "Durata Trovata:",
|
"LabelDurationFound": "Durata Trovata:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "Ebook",
|
||||||
@@ -230,6 +233,7 @@
|
|||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "From Address",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Secure",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Test Address",
|
||||||
"LabelEmbeddedCover": "Embedded Cover",
|
"LabelEmbeddedCover": "Embedded Cover",
|
||||||
"LabelEnable": "Abilita",
|
"LabelEnable": "Abilita",
|
||||||
"LabelEnd": "Fine",
|
"LabelEnd": "Fine",
|
||||||
@@ -248,6 +252,7 @@
|
|||||||
"LabelFinished": "Finita",
|
"LabelFinished": "Finita",
|
||||||
"LabelFolder": "Cartella",
|
"LabelFolder": "Cartella",
|
||||||
"LabelFolders": "Cartelle",
|
"LabelFolders": "Cartelle",
|
||||||
|
"LabelFontScale": "Font scale",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Genere",
|
"LabelGenre": "Genere",
|
||||||
"LabelGenres": "Generi",
|
"LabelGenres": "Generi",
|
||||||
@@ -279,12 +284,16 @@
|
|||||||
"LabelLastSeen": "Ultimi Visti",
|
"LabelLastSeen": "Ultimi Visti",
|
||||||
"LabelLastTime": "Ultima Volta",
|
"LabelLastTime": "Ultima Volta",
|
||||||
"LabelLastUpdate": "Ultimo Aggiornamento",
|
"LabelLastUpdate": "Ultimo Aggiornamento",
|
||||||
|
"LabelLayout": "Layout",
|
||||||
|
"LabelLayoutSinglePage": "Single page",
|
||||||
|
"LabelLayoutSplitPage": "Split page",
|
||||||
"LabelLess": "Poco",
|
"LabelLess": "Poco",
|
||||||
"LabelLibrariesAccessibleToUser": "Librerie Accessibili agli Utenti",
|
"LabelLibrariesAccessibleToUser": "Librerie Accessibili agli Utenti",
|
||||||
"LabelLibrary": "Libreria",
|
"LabelLibrary": "Libreria",
|
||||||
"LabelLibraryItem": "Elementi della Library",
|
"LabelLibraryItem": "Elementi della Library",
|
||||||
"LabelLibraryName": "Nome Libreria",
|
"LabelLibraryName": "Nome Libreria",
|
||||||
"LabelLimit": "Limiti",
|
"LabelLimit": "Limiti",
|
||||||
|
"LabelLineSpacing": "Line spacing",
|
||||||
"LabelListenAgain": "Ri-ascolta",
|
"LabelListenAgain": "Ri-ascolta",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
"LabelNewPassword": "Nuova Password",
|
"LabelNewPassword": "Nuova Password",
|
||||||
"LabelNextBackupDate": "Data Prossimo Backup",
|
"LabelNextBackupDate": "Data Prossimo Backup",
|
||||||
"LabelNextScheduledRun": "Data prossima esecuzione schedulata",
|
"LabelNextScheduledRun": "Data prossima esecuzione schedulata",
|
||||||
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotes": "Note",
|
"LabelNotes": "Note",
|
||||||
"LabelNotFinished": "Da Completare",
|
"LabelNotFinished": "Da Completare",
|
||||||
"LabelNotificationAppriseURL": "Apprendi URL(s)",
|
"LabelNotificationAppriseURL": "Apprendi URL(s)",
|
||||||
@@ -368,6 +378,8 @@
|
|||||||
"LabelSearchTitle": "Cerca Titolo",
|
"LabelSearchTitle": "Cerca Titolo",
|
||||||
"LabelSearchTitleOrASIN": "Cerca titolo o ASIN",
|
"LabelSearchTitleOrASIN": "Cerca titolo o ASIN",
|
||||||
"LabelSeason": "Stagione",
|
"LabelSeason": "Stagione",
|
||||||
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Sequenza",
|
"LabelSequence": "Sequenza",
|
||||||
"LabelSeries": "Serie",
|
"LabelSeries": "Serie",
|
||||||
@@ -387,6 +399,8 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.",
|
"LabelSettingsExperimentalFeaturesHelp": "Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.",
|
||||||
"LabelSettingsFindCovers": "Trova covers",
|
"LabelSettingsFindCovers": "Trova covers",
|
||||||
"LabelSettingsFindCoversHelp": "Se il tuo audiolibro non ha una copertina incorporata o un'immagine di copertina all'interno della cartella, questa funzione tenterà di trovare una copertina.<br>Nota: aumenta il tempo di scansione",
|
"LabelSettingsFindCoversHelp": "Se il tuo audiolibro non ha una copertina incorporata o un'immagine di copertina all'interno della cartella, questa funzione tenterà di trovare una copertina.<br>Nota: aumenta il tempo di scansione",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Home page con sfondo legno",
|
"LabelSettingsHomePageBookshelfView": "Home page con sfondo legno",
|
||||||
"LabelSettingsLibraryBookshelfView": "Libreria con sfondo legno",
|
"LabelSettingsLibraryBookshelfView": "Libreria con sfondo legno",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Usa Overdrive Media Markers per i capitoli",
|
"LabelSettingsOverdriveMediaMarkers": "Usa Overdrive Media Markers per i capitoli",
|
||||||
@@ -439,6 +453,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
|
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
||||||
"LabelTasks": "Processi in esecuzione",
|
"LabelTasks": "Processi in esecuzione",
|
||||||
|
"LabelTheme": "Theme",
|
||||||
|
"LabelThemeDark": "Dark",
|
||||||
|
"LabelThemeLight": "Light",
|
||||||
"LabelTimeBase": "Time Base",
|
"LabelTimeBase": "Time Base",
|
||||||
"LabelTimeListened": "Tempo di Ascolto",
|
"LabelTimeListened": "Tempo di Ascolto",
|
||||||
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
|
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
|
||||||
@@ -575,7 +592,6 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "Crea playlist da una Raccolta",
|
"MessagePlaylistCreateFromCollection": "Crea playlist da una Raccolta",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast non ha l'URL del feed RSS da utilizzare per il match",
|
"MessagePodcastHasNoRSSFeedForMatching": "Podcast non ha l'URL del feed RSS da utilizzare per il match",
|
||||||
"MessageQuickMatchDescription": "Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \"Preferisci metadati corrispondenti\".",
|
"MessageQuickMatchDescription": "Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \"Preferisci metadati corrispondenti\".",
|
||||||
"MessageRemoveAllItemsWarning": "AVVERTIMENTO! Questa azione rimuoverà tutti gli elementi della libreria dal database, inclusi eventuali aggiornamenti o corrispondenze apportate. Questo non fa nulla ai tuoi file effettivi. Sei sicuro?",
|
|
||||||
"MessageRemoveChapter": "Rimuovi Capitolo",
|
"MessageRemoveChapter": "Rimuovi Capitolo",
|
||||||
"MessageRemoveEpisodes": "rimuovi {0} episodio(i)",
|
"MessageRemoveEpisodes": "rimuovi {0} episodio(i)",
|
||||||
"MessageRemoveFromPlayerQueue": "Rimuovi dalla coda di riproduzione",
|
"MessageRemoveFromPlayerQueue": "Rimuovi dalla coda di riproduzione",
|
||||||
|
|||||||
+18
-2
@@ -102,7 +102,8 @@
|
|||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Email Settings",
|
||||||
"HeaderEpisodes": "Afleveringen",
|
"HeaderEpisodes": "Afleveringen",
|
||||||
"HeaderEReaderDevices": "E-Reader Devices",
|
"HeaderEreaderDevices": "Ereader Devices",
|
||||||
|
"HeaderEreaderSettings": "Ereader Settings",
|
||||||
"HeaderFiles": "Bestanden",
|
"HeaderFiles": "Bestanden",
|
||||||
"HeaderFindChapters": "Zoek hoofdstukken",
|
"HeaderFindChapters": "Zoek hoofdstukken",
|
||||||
"HeaderIgnoredFiles": "Genegeerde bestanden",
|
"HeaderIgnoredFiles": "Genegeerde bestanden",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Recente sessies",
|
"HeaderStatsRecentSessions": "Recente sessies",
|
||||||
"HeaderStatsTop10Authors": "Top 10 auteurs",
|
"HeaderStatsTop10Authors": "Top 10 auteurs",
|
||||||
"HeaderStatsTop5Genres": "Top 5 genres",
|
"HeaderStatsTop5Genres": "Top 5 genres",
|
||||||
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Tools",
|
"HeaderTools": "Tools",
|
||||||
"HeaderUpdateAccount": "Account bijwerken",
|
"HeaderUpdateAccount": "Account bijwerken",
|
||||||
"HeaderUpdateAuthor": "Auteur bijwerken",
|
"HeaderUpdateAuthor": "Auteur bijwerken",
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
"LabelDiscFromFilename": "Schijf uit bestandsnaam",
|
"LabelDiscFromFilename": "Schijf uit bestandsnaam",
|
||||||
"LabelDiscFromMetadata": "Schijf uit metadata",
|
"LabelDiscFromMetadata": "Schijf uit metadata",
|
||||||
"LabelDownload": "Download",
|
"LabelDownload": "Download",
|
||||||
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Duur",
|
"LabelDuration": "Duur",
|
||||||
"LabelDurationFound": "Gevonden duur:",
|
"LabelDurationFound": "Gevonden duur:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "Ebook",
|
||||||
@@ -230,6 +233,7 @@
|
|||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "From Address",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Secure",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Test Address",
|
||||||
"LabelEmbeddedCover": "Ingesloten cover",
|
"LabelEmbeddedCover": "Ingesloten cover",
|
||||||
"LabelEnable": "Inschakelen",
|
"LabelEnable": "Inschakelen",
|
||||||
"LabelEnd": "Einde",
|
"LabelEnd": "Einde",
|
||||||
@@ -248,6 +252,7 @@
|
|||||||
"LabelFinished": "Voltooid",
|
"LabelFinished": "Voltooid",
|
||||||
"LabelFolder": "Map",
|
"LabelFolder": "Map",
|
||||||
"LabelFolders": "Mappen",
|
"LabelFolders": "Mappen",
|
||||||
|
"LabelFontScale": "Font scale",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Genres",
|
"LabelGenres": "Genres",
|
||||||
@@ -279,12 +284,16 @@
|
|||||||
"LabelLastSeen": "Laatst gezien",
|
"LabelLastSeen": "Laatst gezien",
|
||||||
"LabelLastTime": "Laatste keer",
|
"LabelLastTime": "Laatste keer",
|
||||||
"LabelLastUpdate": "Laatste update",
|
"LabelLastUpdate": "Laatste update",
|
||||||
|
"LabelLayout": "Layout",
|
||||||
|
"LabelLayoutSinglePage": "Single page",
|
||||||
|
"LabelLayoutSplitPage": "Split page",
|
||||||
"LabelLess": "Minder",
|
"LabelLess": "Minder",
|
||||||
"LabelLibrariesAccessibleToUser": "Voor gebruiker toegankelijke bibliotheken",
|
"LabelLibrariesAccessibleToUser": "Voor gebruiker toegankelijke bibliotheken",
|
||||||
"LabelLibrary": "Bibliotheek",
|
"LabelLibrary": "Bibliotheek",
|
||||||
"LabelLibraryItem": "Library Item",
|
"LabelLibraryItem": "Library Item",
|
||||||
"LabelLibraryName": "Library Name",
|
"LabelLibraryName": "Library Name",
|
||||||
"LabelLimit": "Limiet",
|
"LabelLimit": "Limiet",
|
||||||
|
"LabelLineSpacing": "Line spacing",
|
||||||
"LabelListenAgain": "Luister opnieuw",
|
"LabelListenAgain": "Luister opnieuw",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
"LabelNewPassword": "Nieuw wachtwoord",
|
"LabelNewPassword": "Nieuw wachtwoord",
|
||||||
"LabelNextBackupDate": "Volgende back-up datum",
|
"LabelNextBackupDate": "Volgende back-up datum",
|
||||||
"LabelNextScheduledRun": "Volgende geplande run",
|
"LabelNextScheduledRun": "Volgende geplande run",
|
||||||
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotes": "Notities",
|
"LabelNotes": "Notities",
|
||||||
"LabelNotFinished": "Niet Voltooid",
|
"LabelNotFinished": "Niet Voltooid",
|
||||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||||
@@ -368,6 +378,8 @@
|
|||||||
"LabelSearchTitle": "Zoek titel",
|
"LabelSearchTitle": "Zoek titel",
|
||||||
"LabelSearchTitleOrASIN": "Zoek titel of ASIN",
|
"LabelSearchTitleOrASIN": "Zoek titel of ASIN",
|
||||||
"LabelSeason": "Seizoen",
|
"LabelSeason": "Seizoen",
|
||||||
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Sequentie",
|
"LabelSequence": "Sequentie",
|
||||||
"LabelSeries": "Serie",
|
"LabelSeries": "Serie",
|
||||||
@@ -387,6 +399,8 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
|
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
|
||||||
"LabelSettingsFindCovers": "Zoek covers",
|
"LabelSettingsFindCovers": "Zoek covers",
|
||||||
"LabelSettingsFindCoversHelp": "Als je audioboek geen ingesloten cover of cover in de map heeft, zal de scanner proberen een cover te vinden.<br>Opmerking: Dit zal de scan-duur verlengen",
|
"LabelSettingsFindCoversHelp": "Als je audioboek geen ingesloten cover of cover in de map heeft, zal de scanner proberen een cover te vinden.<br>Opmerking: Dit zal de scan-duur verlengen",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina",
|
"LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina",
|
||||||
"LabelSettingsLibraryBookshelfView": "Boekenplank-view voor bibliotheek",
|
"LabelSettingsLibraryBookshelfView": "Boekenplank-view voor bibliotheek",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Gebruik Overdrive media markers voor hoofdstukken",
|
"LabelSettingsOverdriveMediaMarkers": "Gebruik Overdrive media markers voor hoofdstukken",
|
||||||
@@ -439,6 +453,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "Tags toegankelijk voor de gebruiker",
|
"LabelTagsAccessibleToUser": "Tags toegankelijk voor de gebruiker",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags niet toegankelijk voor de gebruiker",
|
"LabelTagsNotAccessibleToUser": "Tags niet toegankelijk voor de gebruiker",
|
||||||
"LabelTasks": "Lopende taken",
|
"LabelTasks": "Lopende taken",
|
||||||
|
"LabelTheme": "Theme",
|
||||||
|
"LabelThemeDark": "Dark",
|
||||||
|
"LabelThemeLight": "Light",
|
||||||
"LabelTimeBase": "Tijdsbasis",
|
"LabelTimeBase": "Tijdsbasis",
|
||||||
"LabelTimeListened": "Tijd geluisterd",
|
"LabelTimeListened": "Tijd geluisterd",
|
||||||
"LabelTimeListenedToday": "Tijd geluisterd vandaag",
|
"LabelTimeListenedToday": "Tijd geluisterd vandaag",
|
||||||
@@ -575,7 +592,6 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "Afspeellijst aanmaken vanuit collectie",
|
"MessagePlaylistCreateFromCollection": "Afspeellijst aanmaken vanuit collectie",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast heeft geen RSS-feed URL om te gebruiken voor matching",
|
"MessagePodcastHasNoRSSFeedForMatching": "Podcast heeft geen RSS-feed URL om te gebruiken voor matching",
|
||||||
"MessageQuickMatchDescription": "Vul lege onderdeeldetails & cover met eerste matchresultaat van '{0}'. Overschrijft geen details tenzij 'Prefereer gematchte metadata' serverinstelling is ingeschakeld.",
|
"MessageQuickMatchDescription": "Vul lege onderdeeldetails & cover met eerste matchresultaat van '{0}'. Overschrijft geen details tenzij 'Prefereer gematchte metadata' serverinstelling is ingeschakeld.",
|
||||||
"MessageRemoveAllItemsWarning": "WAARSCHUWING! Deze actie zal alle onderdelen in de bibliotheek verwijderen uit de database, inclusief enige bijwerkingen of matches die je hebt gemaakt. Dit doet niets met je onderliggende bestanden. Weet je het zeker?",
|
|
||||||
"MessageRemoveChapter": "Verwijder hoofdstuk",
|
"MessageRemoveChapter": "Verwijder hoofdstuk",
|
||||||
"MessageRemoveEpisodes": "Verwijder {0} aflevering(en)",
|
"MessageRemoveEpisodes": "Verwijder {0} aflevering(en)",
|
||||||
"MessageRemoveFromPlayerQueue": "Verwijder uit afspeelwachtrij",
|
"MessageRemoveFromPlayerQueue": "Verwijder uit afspeelwachtrij",
|
||||||
|
|||||||
+18
-2
@@ -102,7 +102,8 @@
|
|||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Email Settings",
|
||||||
"HeaderEpisodes": "Rozdziały",
|
"HeaderEpisodes": "Rozdziały",
|
||||||
"HeaderEReaderDevices": "E-Reader Devices",
|
"HeaderEreaderDevices": "Ereader Devices",
|
||||||
|
"HeaderEreaderSettings": "Ereader Settings",
|
||||||
"HeaderFiles": "Pliki",
|
"HeaderFiles": "Pliki",
|
||||||
"HeaderFindChapters": "Wyszukaj rozdziały",
|
"HeaderFindChapters": "Wyszukaj rozdziały",
|
||||||
"HeaderIgnoredFiles": "Zignoruj pliki",
|
"HeaderIgnoredFiles": "Zignoruj pliki",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Ostatnie sesje",
|
"HeaderStatsRecentSessions": "Ostatnie sesje",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Autorów",
|
"HeaderStatsTop10Authors": "Top 10 Autorów",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Gatunków",
|
"HeaderStatsTop5Genres": "Top 5 Gatunków",
|
||||||
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Narzędzia",
|
"HeaderTools": "Narzędzia",
|
||||||
"HeaderUpdateAccount": "Zaktualizuj konto",
|
"HeaderUpdateAccount": "Zaktualizuj konto",
|
||||||
"HeaderUpdateAuthor": "Zaktualizuj autorów",
|
"HeaderUpdateAuthor": "Zaktualizuj autorów",
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
"LabelDiscFromFilename": "Oznaczenie dysku z nazwy pliku",
|
"LabelDiscFromFilename": "Oznaczenie dysku z nazwy pliku",
|
||||||
"LabelDiscFromMetadata": "Oznaczenie dysku z metadanych",
|
"LabelDiscFromMetadata": "Oznaczenie dysku z metadanych",
|
||||||
"LabelDownload": "Pobierz",
|
"LabelDownload": "Pobierz",
|
||||||
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Czas trwania",
|
"LabelDuration": "Czas trwania",
|
||||||
"LabelDurationFound": "Znaleziona długość:",
|
"LabelDurationFound": "Znaleziona długość:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "Ebook",
|
||||||
@@ -230,6 +233,7 @@
|
|||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "From Address",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Secure",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Test Address",
|
||||||
"LabelEmbeddedCover": "Embedded Cover",
|
"LabelEmbeddedCover": "Embedded Cover",
|
||||||
"LabelEnable": "Włącz",
|
"LabelEnable": "Włącz",
|
||||||
"LabelEnd": "Zakończ",
|
"LabelEnd": "Zakończ",
|
||||||
@@ -248,6 +252,7 @@
|
|||||||
"LabelFinished": "Zakończone",
|
"LabelFinished": "Zakończone",
|
||||||
"LabelFolder": "Folder",
|
"LabelFolder": "Folder",
|
||||||
"LabelFolders": "Foldery",
|
"LabelFolders": "Foldery",
|
||||||
|
"LabelFontScale": "Font scale",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Gatunek",
|
"LabelGenre": "Gatunek",
|
||||||
"LabelGenres": "Gatunki",
|
"LabelGenres": "Gatunki",
|
||||||
@@ -279,12 +284,16 @@
|
|||||||
"LabelLastSeen": "Ostatnio widziany",
|
"LabelLastSeen": "Ostatnio widziany",
|
||||||
"LabelLastTime": "Ostatni czas",
|
"LabelLastTime": "Ostatni czas",
|
||||||
"LabelLastUpdate": "Ostatnia aktualizacja",
|
"LabelLastUpdate": "Ostatnia aktualizacja",
|
||||||
|
"LabelLayout": "Layout",
|
||||||
|
"LabelLayoutSinglePage": "Single page",
|
||||||
|
"LabelLayoutSplitPage": "Split page",
|
||||||
"LabelLess": "Mniej",
|
"LabelLess": "Mniej",
|
||||||
"LabelLibrariesAccessibleToUser": "Biblioteki dostępne dla użytkownika",
|
"LabelLibrariesAccessibleToUser": "Biblioteki dostępne dla użytkownika",
|
||||||
"LabelLibrary": "Biblioteka",
|
"LabelLibrary": "Biblioteka",
|
||||||
"LabelLibraryItem": "Element biblioteki",
|
"LabelLibraryItem": "Element biblioteki",
|
||||||
"LabelLibraryName": "Nazwa biblioteki",
|
"LabelLibraryName": "Nazwa biblioteki",
|
||||||
"LabelLimit": "Limit",
|
"LabelLimit": "Limit",
|
||||||
|
"LabelLineSpacing": "Line spacing",
|
||||||
"LabelListenAgain": "Słuchaj ponownie",
|
"LabelListenAgain": "Słuchaj ponownie",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Informacja",
|
"LabelLogLevelInfo": "Informacja",
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
"LabelNewPassword": "Nowe hasło",
|
"LabelNewPassword": "Nowe hasło",
|
||||||
"LabelNextBackupDate": "Next backup date",
|
"LabelNextBackupDate": "Next backup date",
|
||||||
"LabelNextScheduledRun": "Next scheduled run",
|
"LabelNextScheduledRun": "Next scheduled run",
|
||||||
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotes": "Uwagi",
|
"LabelNotes": "Uwagi",
|
||||||
"LabelNotFinished": "Nieukończone",
|
"LabelNotFinished": "Nieukończone",
|
||||||
"LabelNotificationAppriseURL": "URLe Apprise",
|
"LabelNotificationAppriseURL": "URLe Apprise",
|
||||||
@@ -368,6 +378,8 @@
|
|||||||
"LabelSearchTitle": "Wyszukaj tytuł",
|
"LabelSearchTitle": "Wyszukaj tytuł",
|
||||||
"LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN",
|
"LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN",
|
||||||
"LabelSeason": "Sezon",
|
"LabelSeason": "Sezon",
|
||||||
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Kolejność",
|
"LabelSequence": "Kolejność",
|
||||||
"LabelSeries": "Serie",
|
"LabelSeries": "Serie",
|
||||||
@@ -387,6 +399,8 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "Funkcje w trakcie rozwoju, które mogą zyskanć na Twojej opinii i pomocy w testowaniu. Kliknij, aby otworzyć dyskusję na githubie.",
|
"LabelSettingsExperimentalFeaturesHelp": "Funkcje w trakcie rozwoju, które mogą zyskanć na Twojej opinii i pomocy w testowaniu. Kliknij, aby otworzyć dyskusję na githubie.",
|
||||||
"LabelSettingsFindCovers": "Szukanie okładek",
|
"LabelSettingsFindCovers": "Szukanie okładek",
|
||||||
"LabelSettingsFindCoversHelp": "Jeśli audiobook nie posiada zintegrowanej okładki albo w folderze nie zostanie znaleziony plik okładki, skaner podejmie próbę pobrania okładki z sieci. <br>Uwaga: może to wydłuzyć proces skanowania",
|
"LabelSettingsFindCoversHelp": "Jeśli audiobook nie posiada zintegrowanej okładki albo w folderze nie zostanie znaleziony plik okładki, skaner podejmie próbę pobrania okładki z sieci. <br>Uwaga: może to wydłuzyć proces skanowania",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Widok półki z książkami na stronie głównej",
|
"LabelSettingsHomePageBookshelfView": "Widok półki z książkami na stronie głównej",
|
||||||
"LabelSettingsLibraryBookshelfView": "Widok półki z książkami na stronie biblioteki",
|
"LabelSettingsLibraryBookshelfView": "Widok półki z książkami na stronie biblioteki",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Użyj markerów Overdrive Media Markers dla rozdziałów",
|
"LabelSettingsOverdriveMediaMarkers": "Użyj markerów Overdrive Media Markers dla rozdziałów",
|
||||||
@@ -439,6 +453,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "Tagi dostępne dla użytkownika",
|
"LabelTagsAccessibleToUser": "Tagi dostępne dla użytkownika",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
||||||
"LabelTasks": "Tasks Running",
|
"LabelTasks": "Tasks Running",
|
||||||
|
"LabelTheme": "Theme",
|
||||||
|
"LabelThemeDark": "Dark",
|
||||||
|
"LabelThemeLight": "Light",
|
||||||
"LabelTimeBase": "Time Base",
|
"LabelTimeBase": "Time Base",
|
||||||
"LabelTimeListened": "Czas odtwarzania",
|
"LabelTimeListened": "Czas odtwarzania",
|
||||||
"LabelTimeListenedToday": "Czas odtwarzania dzisiaj",
|
"LabelTimeListenedToday": "Czas odtwarzania dzisiaj",
|
||||||
@@ -575,7 +592,6 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania",
|
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania",
|
||||||
"MessageQuickMatchDescription": "Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.",
|
"MessageQuickMatchDescription": "Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.",
|
||||||
"MessageRemoveAllItemsWarning": "UWAGA! Ta akcja usunie wszystkie elementy biblioteki z bazy danych, w tym wszystkie aktualizacje lub dopasowania, które zostały wykonane. Pliki pozostaną niezmienione. Czy jesteś pewien?",
|
|
||||||
"MessageRemoveChapter": "Usuń rozdział",
|
"MessageRemoveChapter": "Usuń rozdział",
|
||||||
"MessageRemoveEpisodes": "Usuń {0} odcinków",
|
"MessageRemoveEpisodes": "Usuń {0} odcinków",
|
||||||
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
||||||
|
|||||||
+61
-45
@@ -55,7 +55,7 @@
|
|||||||
"ButtonRemoveAll": "Удалить всё",
|
"ButtonRemoveAll": "Удалить всё",
|
||||||
"ButtonRemoveAllLibraryItems": "Удалить все элементы библиотеки",
|
"ButtonRemoveAllLibraryItems": "Удалить все элементы библиотеки",
|
||||||
"ButtonRemoveFromContinueListening": "Удалить из Продолжить слушать",
|
"ButtonRemoveFromContinueListening": "Удалить из Продолжить слушать",
|
||||||
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
|
"ButtonRemoveFromContinueReading": "Удалить из Продолжить читать",
|
||||||
"ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию",
|
"ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию",
|
||||||
"ButtonReScan": "Пересканировать",
|
"ButtonReScan": "Пересканировать",
|
||||||
"ButtonReset": "Сбросить",
|
"ButtonReset": "Сбросить",
|
||||||
@@ -98,11 +98,12 @@
|
|||||||
"HeaderCurrentDownloads": "Текущие закачки",
|
"HeaderCurrentDownloads": "Текущие закачки",
|
||||||
"HeaderDetails": "Подробности",
|
"HeaderDetails": "Подробности",
|
||||||
"HeaderDownloadQueue": "Очередь скачивания",
|
"HeaderDownloadQueue": "Очередь скачивания",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "Файлы e-книг",
|
||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Настройки Email",
|
||||||
"HeaderEpisodes": "Эпизоды",
|
"HeaderEpisodes": "Эпизоды",
|
||||||
"HeaderEReaderDevices": "E-Reader Devices",
|
"HeaderEreaderDevices": "Устройства E-книга",
|
||||||
|
"HeaderEreaderSettings": "Ereader Settings",
|
||||||
"HeaderFiles": "Файлы",
|
"HeaderFiles": "Файлы",
|
||||||
"HeaderFindChapters": "Найти главы",
|
"HeaderFindChapters": "Найти главы",
|
||||||
"HeaderIgnoredFiles": "Игнорируемые Файлы",
|
"HeaderIgnoredFiles": "Игнорируемые Файлы",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Последние сеансы",
|
"HeaderStatsRecentSessions": "Последние сеансы",
|
||||||
"HeaderStatsTop10Authors": "Топ 10 авторов",
|
"HeaderStatsTop10Authors": "Топ 10 авторов",
|
||||||
"HeaderStatsTop5Genres": "Топ 5 жанров",
|
"HeaderStatsTop5Genres": "Топ 5 жанров",
|
||||||
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Инструменты",
|
"HeaderTools": "Инструменты",
|
||||||
"HeaderUpdateAccount": "Обновить учетную запись",
|
"HeaderUpdateAccount": "Обновить учетную запись",
|
||||||
"HeaderUpdateAuthor": "Обновить автора",
|
"HeaderUpdateAuthor": "Обновить автора",
|
||||||
@@ -167,7 +169,7 @@
|
|||||||
"LabelAccountTypeGuest": "Гость",
|
"LabelAccountTypeGuest": "Гость",
|
||||||
"LabelAccountTypeUser": "Пользователь",
|
"LabelAccountTypeUser": "Пользователь",
|
||||||
"LabelActivity": "Активность",
|
"LabelActivity": "Активность",
|
||||||
"LabelAdded": "Added",
|
"LabelAdded": "Добавили",
|
||||||
"LabelAddedAt": "Дата добавления",
|
"LabelAddedAt": "Дата добавления",
|
||||||
"LabelAddToCollection": "Добавить в коллекцию",
|
"LabelAddToCollection": "Добавить в коллекцию",
|
||||||
"LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию",
|
"LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию",
|
||||||
@@ -189,21 +191,21 @@
|
|||||||
"LabelBackupsMaxBackupSizeHelp": "В качестве защиты процесс бэкапирования будет завершаться ошибкой, если будет превышен настроенный размер.",
|
"LabelBackupsMaxBackupSizeHelp": "В качестве защиты процесс бэкапирования будет завершаться ошибкой, если будет превышен настроенный размер.",
|
||||||
"LabelBackupsNumberToKeep": "Сохранять бэкапов",
|
"LabelBackupsNumberToKeep": "Сохранять бэкапов",
|
||||||
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
|
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
|
||||||
"LabelBitrate": "Bitrate",
|
"LabelBitrate": "Битрейт",
|
||||||
"LabelBooks": "Книги",
|
"LabelBooks": "Книги",
|
||||||
"LabelChangePassword": "Изменить пароль",
|
"LabelChangePassword": "Изменить пароль",
|
||||||
"LabelChannels": "Channels",
|
"LabelChannels": "Каналы",
|
||||||
"LabelChapters": "Chapters",
|
"LabelChapters": "Главы",
|
||||||
"LabelChaptersFound": "глав найдено",
|
"LabelChaptersFound": "глав найдено",
|
||||||
"LabelChapterTitle": "Название главы",
|
"LabelChapterTitle": "Название главы",
|
||||||
"LabelClosePlayer": "Закрыть проигрыватель",
|
"LabelClosePlayer": "Закрыть проигрыватель",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Кодек",
|
||||||
"LabelCollapseSeries": "Свернуть серии",
|
"LabelCollapseSeries": "Свернуть серии",
|
||||||
"LabelCollections": "Коллекции",
|
"LabelCollections": "Коллекции",
|
||||||
"LabelComplete": "Завершить",
|
"LabelComplete": "Завершить",
|
||||||
"LabelConfirmPassword": "Подтвердить пароль",
|
"LabelConfirmPassword": "Подтвердить пароль",
|
||||||
"LabelContinueListening": "Продолжить слушать",
|
"LabelContinueListening": "Продолжить слушать",
|
||||||
"LabelContinueReading": "Continue Reading",
|
"LabelContinueReading": "Продолжить читать",
|
||||||
"LabelContinueSeries": "Продолжить серию",
|
"LabelContinueSeries": "Продолжить серию",
|
||||||
"LabelCover": "Обложка",
|
"LabelCover": "Обложка",
|
||||||
"LabelCoverImageURL": "URL изображения обложки",
|
"LabelCoverImageURL": "URL изображения обложки",
|
||||||
@@ -221,16 +223,18 @@
|
|||||||
"LabelDiscFromFilename": "Диск из Имени файла",
|
"LabelDiscFromFilename": "Диск из Имени файла",
|
||||||
"LabelDiscFromMetadata": "Диск из Метаданных",
|
"LabelDiscFromMetadata": "Диск из Метаданных",
|
||||||
"LabelDownload": "Скачать",
|
"LabelDownload": "Скачать",
|
||||||
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Длина",
|
"LabelDuration": "Длина",
|
||||||
"LabelDurationFound": "Найденная длина:",
|
"LabelDurationFound": "Найденная длина:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "E-книга",
|
||||||
"LabelEbooks": "Ebooks",
|
"LabelEbooks": "E-книги",
|
||||||
"LabelEdit": "Редактировать",
|
"LabelEdit": "Редактировать",
|
||||||
"LabelEmail": "Email",
|
"LabelEmail": "Email",
|
||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "Адрес От",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Безопасность",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "Если значение истинно, то соединение будет использовать TLS при подключении к серверу. Если значение ложно, то TLS будет использован, если сервер поддерживает расширение STARTTLS. В большинстве случаев установите это значение в истину, если вы подключаетесь к порту 465. Для порта 587 или 25 оставьте значение ложным. (из nodemailer.com/smtp/#authentication)",
|
||||||
"LabelEmbeddedCover": "Embedded Cover",
|
"LabelEmailSettingsTestAddress": "Тестовый адрес",
|
||||||
|
"LabelEmbeddedCover": "Встроенная обложка",
|
||||||
"LabelEnable": "Включить",
|
"LabelEnable": "Включить",
|
||||||
"LabelEnd": "Конец",
|
"LabelEnd": "Конец",
|
||||||
"LabelEpisode": "Эпизод",
|
"LabelEpisode": "Эпизод",
|
||||||
@@ -248,13 +252,14 @@
|
|||||||
"LabelFinished": "Закончен",
|
"LabelFinished": "Закончен",
|
||||||
"LabelFolder": "Папка",
|
"LabelFolder": "Папка",
|
||||||
"LabelFolders": "Папки",
|
"LabelFolders": "Папки",
|
||||||
"LabelFormat": "Format",
|
"LabelFontScale": "Font scale",
|
||||||
|
"LabelFormat": "Формат",
|
||||||
"LabelGenre": "Жанр",
|
"LabelGenre": "Жанр",
|
||||||
"LabelGenres": "Жанры",
|
"LabelGenres": "Жанры",
|
||||||
"LabelHardDeleteFile": "Жесткое удаление файла",
|
"LabelHardDeleteFile": "Жесткое удаление файла",
|
||||||
"LabelHasEbook": "Has ebook",
|
"LabelHasEbook": "Есть e-книга",
|
||||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Хост",
|
||||||
"LabelHour": "Часы",
|
"LabelHour": "Часы",
|
||||||
"LabelIcon": "Иконка",
|
"LabelIcon": "Иконка",
|
||||||
"LabelIncludeInTracklist": "Включать в список воспроизведения",
|
"LabelIncludeInTracklist": "Включать в список воспроизведения",
|
||||||
@@ -270,21 +275,25 @@
|
|||||||
"LabelIntervalEveryDay": "Каждый день",
|
"LabelIntervalEveryDay": "Каждый день",
|
||||||
"LabelIntervalEveryHour": "Каждый час",
|
"LabelIntervalEveryHour": "Каждый час",
|
||||||
"LabelInvalidParts": "Неверные части",
|
"LabelInvalidParts": "Неверные части",
|
||||||
"LabelInvert": "Invert",
|
"LabelInvert": "Инвертировать",
|
||||||
"LabelItem": "Элемент",
|
"LabelItem": "Элемент",
|
||||||
"LabelLanguage": "Язык",
|
"LabelLanguage": "Язык",
|
||||||
"LabelLanguageDefaultServer": "Язык сервера по умолчанию",
|
"LabelLanguageDefaultServer": "Язык сервера по умолчанию",
|
||||||
"LabelLastBookAdded": "Last Book Added",
|
"LabelLastBookAdded": "Последняя книга добавлена",
|
||||||
"LabelLastBookUpdated": "Last Book Updated",
|
"LabelLastBookUpdated": "Последняя книга обновлена",
|
||||||
"LabelLastSeen": "Последнее сканирование",
|
"LabelLastSeen": "Последнее сканирование",
|
||||||
"LabelLastTime": "Последний по времени",
|
"LabelLastTime": "Последний по времени",
|
||||||
"LabelLastUpdate": "Последний обновленный",
|
"LabelLastUpdate": "Последний обновленный",
|
||||||
|
"LabelLayout": "Layout",
|
||||||
|
"LabelLayoutSinglePage": "Single page",
|
||||||
|
"LabelLayoutSplitPage": "Split page",
|
||||||
"LabelLess": "Менее",
|
"LabelLess": "Менее",
|
||||||
"LabelLibrariesAccessibleToUser": "Библиотеки доступные для пользователя",
|
"LabelLibrariesAccessibleToUser": "Библиотеки доступные для пользователя",
|
||||||
"LabelLibrary": "Библиотека",
|
"LabelLibrary": "Библиотека",
|
||||||
"LabelLibraryItem": "Элемент библиотеки",
|
"LabelLibraryItem": "Элемент библиотеки",
|
||||||
"LabelLibraryName": "Имя библиотеки",
|
"LabelLibraryName": "Имя библиотеки",
|
||||||
"LabelLimit": "Лимит",
|
"LabelLimit": "Лимит",
|
||||||
|
"LabelLineSpacing": "Line spacing",
|
||||||
"LabelListenAgain": "Послушать снова",
|
"LabelListenAgain": "Послушать снова",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
@@ -294,12 +303,12 @@
|
|||||||
"LabelMediaType": "Тип медиа",
|
"LabelMediaType": "Тип медиа",
|
||||||
"LabelMetadataProvider": "Провайдер",
|
"LabelMetadataProvider": "Провайдер",
|
||||||
"LabelMetaTag": "Мета тег",
|
"LabelMetaTag": "Мета тег",
|
||||||
"LabelMetaTags": "Meta Tags",
|
"LabelMetaTags": "Мета теги",
|
||||||
"LabelMinute": "Минуты",
|
"LabelMinute": "Минуты",
|
||||||
"LabelMissing": "Потеряно",
|
"LabelMissing": "Потеряно",
|
||||||
"LabelMissingParts": "Потерянные части",
|
"LabelMissingParts": "Потерянные части",
|
||||||
"LabelMore": "Еще",
|
"LabelMore": "Еще",
|
||||||
"LabelMoreInfo": "More Info",
|
"LabelMoreInfo": "Больше информации",
|
||||||
"LabelName": "Имя",
|
"LabelName": "Имя",
|
||||||
"LabelNarrator": "Читает",
|
"LabelNarrator": "Читает",
|
||||||
"LabelNarrators": "Чтецы",
|
"LabelNarrators": "Чтецы",
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
"LabelNewPassword": "Новый пароль",
|
"LabelNewPassword": "Новый пароль",
|
||||||
"LabelNextBackupDate": "Следующая дата бэкапирования",
|
"LabelNextBackupDate": "Следующая дата бэкапирования",
|
||||||
"LabelNextScheduledRun": "Следущий запланированный запуск",
|
"LabelNextScheduledRun": "Следущий запланированный запуск",
|
||||||
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotes": "Заметки",
|
"LabelNotes": "Заметки",
|
||||||
"LabelNotFinished": "Не завершено",
|
"LabelNotFinished": "Не завершено",
|
||||||
"LabelNotificationAppriseURL": "URL(ы) для извещений",
|
"LabelNotificationAppriseURL": "URL(ы) для извещений",
|
||||||
@@ -340,18 +350,18 @@
|
|||||||
"LabelPodcast": "Подкаст",
|
"LabelPodcast": "Подкаст",
|
||||||
"LabelPodcasts": "Подкасты",
|
"LabelPodcasts": "Подкасты",
|
||||||
"LabelPodcastType": "Тип подкаста",
|
"LabelPodcastType": "Тип подкаста",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Порт",
|
||||||
"LabelPrefixesToIgnore": "Игнорируемые префиксы (без учета регистра)",
|
"LabelPrefixesToIgnore": "Игнорируемые префиксы (без учета регистра)",
|
||||||
"LabelPreventIndexing": "Запретить индексацию фида каталогами подкастов iTunes и Google",
|
"LabelPreventIndexing": "Запретить индексацию фида каталогами подкастов iTunes и Google",
|
||||||
"LabelPrimaryEbook": "Primary ebook",
|
"LabelPrimaryEbook": "Основная e-книга",
|
||||||
"LabelProgress": "Прогресс",
|
"LabelProgress": "Прогресс",
|
||||||
"LabelProvider": "Провайдер",
|
"LabelProvider": "Провайдер",
|
||||||
"LabelPubDate": "Дата публикации",
|
"LabelPubDate": "Дата публикации",
|
||||||
"LabelPublisher": "Издатель",
|
"LabelPublisher": "Издатель",
|
||||||
"LabelPublishYear": "Год публикации",
|
"LabelPublishYear": "Год публикации",
|
||||||
"LabelRead": "Read",
|
"LabelRead": "Читать",
|
||||||
"LabelReadAgain": "Read Again",
|
"LabelReadAgain": "Читать снова",
|
||||||
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
|
"LabelReadEbookWithoutProgress": "Читать e-книгу без сохранения прогресса",
|
||||||
"LabelRecentlyAdded": "Недавно добавленные",
|
"LabelRecentlyAdded": "Недавно добавленные",
|
||||||
"LabelRecentSeries": "Последние серии",
|
"LabelRecentSeries": "Последние серии",
|
||||||
"LabelRecommended": "Рекомендованное",
|
"LabelRecommended": "Рекомендованное",
|
||||||
@@ -368,15 +378,17 @@
|
|||||||
"LabelSearchTitle": "Поиск по названию",
|
"LabelSearchTitle": "Поиск по названию",
|
||||||
"LabelSearchTitleOrASIN": "Поиск по названию или ASIN",
|
"LabelSearchTitleOrASIN": "Поиск по названию или ASIN",
|
||||||
"LabelSeason": "Сезон",
|
"LabelSeason": "Сезон",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
|
"LabelSendEbookToDevice": "Отправить e-книгу в...",
|
||||||
"LabelSequence": "Последовательность",
|
"LabelSequence": "Последовательность",
|
||||||
"LabelSeries": "Серия",
|
"LabelSeries": "Серия",
|
||||||
"LabelSeriesName": "Имя серии",
|
"LabelSeriesName": "Имя серии",
|
||||||
"LabelSeriesProgress": "Прогресс серии",
|
"LabelSeriesProgress": "Прогресс серии",
|
||||||
"LabelSetEbookAsPrimary": "Set as primary",
|
"LabelSetEbookAsPrimary": "Установить как основную",
|
||||||
"LabelSetEbookAsSupplementary": "Set as supplementary",
|
"LabelSetEbookAsSupplementary": "Установить как дополнительную",
|
||||||
"LabelSettingsAudiobooksOnly": "Audiobooks only",
|
"LabelSettingsAudiobooksOnly": "Только аудиокниги",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
|
"LabelSettingsAudiobooksOnlyHelp": "Если включить эту настройку, файлы электронных книг будут игнорироваться, за исключением случаев, когда они находятся в папке с аудиокнигами, в этом случае они будут рассматриваться как дополнительные электронные книги.",
|
||||||
"LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками",
|
"LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками",
|
||||||
"LabelSettingsChromecastSupport": "Поддержка Chromecast",
|
"LabelSettingsChromecastSupport": "Поддержка Chromecast",
|
||||||
"LabelSettingsDateFormat": "Формат даты",
|
"LabelSettingsDateFormat": "Формат даты",
|
||||||
@@ -387,6 +399,8 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
|
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
|
||||||
"LabelSettingsFindCovers": "Найти обложки",
|
"LabelSettingsFindCovers": "Найти обложки",
|
||||||
"LabelSettingsFindCoversHelp": "Если у Ваших аудиокниг нет встроенной обложки или файла обложки в папке книги, то сканер попробует найти обложку.<br>Примечание: Это увеличит время сканирования",
|
"LabelSettingsFindCoversHelp": "Если у Ваших аудиокниг нет встроенной обложки или файла обложки в папке книги, то сканер попробует найти обложку.<br>Примечание: Это увеличит время сканирования",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней странице",
|
"LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней странице",
|
||||||
"LabelSettingsLibraryBookshelfView": "Вид книжной полки в Библиотеке",
|
"LabelSettingsLibraryBookshelfView": "Вид книжной полки в Библиотеке",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Overdrive Media Markers для глав",
|
"LabelSettingsOverdriveMediaMarkers": "Overdrive Media Markers для глав",
|
||||||
@@ -431,15 +445,18 @@
|
|||||||
"LabelStatsMinutesListening": "Минут прослушано",
|
"LabelStatsMinutesListening": "Минут прослушано",
|
||||||
"LabelStatsOverallDays": "Всего дней",
|
"LabelStatsOverallDays": "Всего дней",
|
||||||
"LabelStatsOverallHours": "Всего часов",
|
"LabelStatsOverallHours": "Всего часов",
|
||||||
"LabelStatsWeekListening": "Недель прослушано",
|
"LabelStatsWeekListening": "Прослушано за неделю",
|
||||||
"LabelSubtitle": "Подзаголовок",
|
"LabelSubtitle": "Подзаголовок",
|
||||||
"LabelSupportedFileTypes": "Поддерживаемые типы файлов",
|
"LabelSupportedFileTypes": "Поддерживаемые типы файлов",
|
||||||
"LabelTag": "Тег",
|
"LabelTag": "Тег",
|
||||||
"LabelTags": "Теги",
|
"LabelTags": "Теги",
|
||||||
"LabelTagsAccessibleToUser": "Теги доступные для пользователя",
|
"LabelTagsAccessibleToUser": "Теги доступные для пользователя",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
"LabelTagsNotAccessibleToUser": "Теги не доступные для пользователя",
|
||||||
"LabelTasks": "Запущенные задачи",
|
"LabelTasks": "Запущенные задачи",
|
||||||
"LabelTimeBase": "Time Base",
|
"LabelTheme": "Theme",
|
||||||
|
"LabelThemeDark": "Dark",
|
||||||
|
"LabelThemeLight": "Light",
|
||||||
|
"LabelTimeBase": "Временная база",
|
||||||
"LabelTimeListened": "Время прослушивания",
|
"LabelTimeListened": "Время прослушивания",
|
||||||
"LabelTimeListenedToday": "Время прослушивания сегодня",
|
"LabelTimeListenedToday": "Время прослушивания сегодня",
|
||||||
"LabelTimeRemaining": "{0} осталось",
|
"LabelTimeRemaining": "{0} осталось",
|
||||||
@@ -498,17 +515,17 @@
|
|||||||
"MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги",
|
"MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги",
|
||||||
"MessageCheckingCron": "Проверка cron...",
|
"MessageCheckingCron": "Проверка cron...",
|
||||||
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
|
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?",
|
||||||
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
|
||||||
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
|
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
|
||||||
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
|
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как законченные?",
|
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как законченные?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как незаконченные?",
|
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как незаконченные?",
|
||||||
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
|
"MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?",
|
||||||
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
|
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
|
||||||
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Вы уверены, что хотите удалить чтеца \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?",
|
"MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?",
|
||||||
"MessageConfirmRenameGenreMergeNote": "Примечание: Этот жанр уже существует, поэтому они будут объединены.",
|
"MessageConfirmRenameGenreMergeNote": "Примечание: Этот жанр уже существует, поэтому они будут объединены.",
|
||||||
@@ -516,7 +533,7 @@
|
|||||||
"MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?",
|
"MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?",
|
||||||
"MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.",
|
"MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.",
|
||||||
"MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".",
|
"MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".",
|
||||||
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?",
|
||||||
"MessageDownloadingEpisode": "Эпизод скачивается",
|
"MessageDownloadingEpisode": "Эпизод скачивается",
|
||||||
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
|
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
|
||||||
"MessageEmbedFinished": "Встраивание завершено!",
|
"MessageEmbedFinished": "Встраивание завершено!",
|
||||||
@@ -575,7 +592,6 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "Создать плейлист из коллекции",
|
"MessagePlaylistCreateFromCollection": "Создать плейлист из коллекции",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Подкаст не имеет URL-адреса RSS-канала, который можно использовать для поиска",
|
"MessagePodcastHasNoRSSFeedForMatching": "Подкаст не имеет URL-адреса RSS-канала, который можно использовать для поиска",
|
||||||
"MessageQuickMatchDescription": "Заполняет пустые детали элемента и обложку первым результатом поиска из «{0}». Не перезаписывает сведения, если не включен параметр сервера 'Предпочитать метаданные поиска'.",
|
"MessageQuickMatchDescription": "Заполняет пустые детали элемента и обложку первым результатом поиска из «{0}». Не перезаписывает сведения, если не включен параметр сервера 'Предпочитать метаданные поиска'.",
|
||||||
"MessageRemoveAllItemsWarning": "ПРЕДУПРЕЖДЕНИЕ! Это действие удалит все элементы библиотеки из базы данных, включая все сделанные обновления или совпадения. Ничего не произойдет с вашими фактическими файлами. Уверены?",
|
|
||||||
"MessageRemoveChapter": "Удалить главу",
|
"MessageRemoveChapter": "Удалить главу",
|
||||||
"MessageRemoveEpisodes": "Удалить {0} эпизод(ов)",
|
"MessageRemoveEpisodes": "Удалить {0} эпизод(ов)",
|
||||||
"MessageRemoveFromPlayerQueue": "Удалить из очереди воспроизведения",
|
"MessageRemoveFromPlayerQueue": "Удалить из очереди воспроизведения",
|
||||||
@@ -671,8 +687,8 @@
|
|||||||
"ToastRemoveItemFromCollectionSuccess": "Элемент удален из коллекции",
|
"ToastRemoveItemFromCollectionSuccess": "Элемент удален из коллекции",
|
||||||
"ToastRSSFeedCloseFailed": "Не удалось закрыть RSS-канал",
|
"ToastRSSFeedCloseFailed": "Не удалось закрыть RSS-канал",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS-канал закрыт",
|
"ToastRSSFeedCloseSuccess": "RSS-канал закрыт",
|
||||||
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
|
"ToastSendEbookToDeviceFailed": "Не удалось отправить e-книгу на устройство",
|
||||||
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
|
"ToastSendEbookToDeviceSuccess": "E-книга отправлена на устройство \"{0}\"",
|
||||||
"ToastSeriesUpdateFailed": "Не удалось обновить серию",
|
"ToastSeriesUpdateFailed": "Не удалось обновить серию",
|
||||||
"ToastSeriesUpdateSuccess": "Успешное обновление серии",
|
"ToastSeriesUpdateSuccess": "Успешное обновление серии",
|
||||||
"ToastSessionDeleteFailed": "Не удалось удалить сеанс",
|
"ToastSessionDeleteFailed": "Не удалось удалить сеанс",
|
||||||
|
|||||||
+48
-32
@@ -55,7 +55,7 @@
|
|||||||
"ButtonRemoveAll": "移除所有",
|
"ButtonRemoveAll": "移除所有",
|
||||||
"ButtonRemoveAllLibraryItems": "移除所有媒体库项目",
|
"ButtonRemoveAllLibraryItems": "移除所有媒体库项目",
|
||||||
"ButtonRemoveFromContinueListening": "从继续收听中删除",
|
"ButtonRemoveFromContinueListening": "从继续收听中删除",
|
||||||
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
|
"ButtonRemoveFromContinueReading": "从继续阅读中删除",
|
||||||
"ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除",
|
"ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除",
|
||||||
"ButtonReScan": "重新扫描",
|
"ButtonReScan": "重新扫描",
|
||||||
"ButtonReset": "重置",
|
"ButtonReset": "重置",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"ButtonStartM4BEncode": "开始 M4B 编码",
|
"ButtonStartM4BEncode": "开始 M4B 编码",
|
||||||
"ButtonStartMetadataEmbed": "开始嵌入元数据",
|
"ButtonStartMetadataEmbed": "开始嵌入元数据",
|
||||||
"ButtonSubmit": "提交",
|
"ButtonSubmit": "提交",
|
||||||
"ButtonTest": "Test",
|
"ButtonTest": "测试",
|
||||||
"ButtonUpload": "上传",
|
"ButtonUpload": "上传",
|
||||||
"ButtonUploadBackup": "上传备份",
|
"ButtonUploadBackup": "上传备份",
|
||||||
"ButtonUploadCover": "上传封面",
|
"ButtonUploadCover": "上传封面",
|
||||||
@@ -98,11 +98,12 @@
|
|||||||
"HeaderCurrentDownloads": "当前下载",
|
"HeaderCurrentDownloads": "当前下载",
|
||||||
"HeaderDetails": "详情",
|
"HeaderDetails": "详情",
|
||||||
"HeaderDownloadQueue": "下载队列",
|
"HeaderDownloadQueue": "下载队列",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "电子书文件",
|
||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "邮箱",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "邮箱设置",
|
||||||
"HeaderEpisodes": "剧集",
|
"HeaderEpisodes": "剧集",
|
||||||
"HeaderEReaderDevices": "E-Reader Devices",
|
"HeaderEreaderDevices": "Ereader 设备",
|
||||||
|
"HeaderEreaderSettings": "Ereader 设置",
|
||||||
"HeaderFiles": "文件",
|
"HeaderFiles": "文件",
|
||||||
"HeaderFindChapters": "查找章节",
|
"HeaderFindChapters": "查找章节",
|
||||||
"HeaderIgnoredFiles": "忽略的文件",
|
"HeaderIgnoredFiles": "忽略的文件",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"HeaderStatsRecentSessions": "历史会话",
|
"HeaderStatsRecentSessions": "历史会话",
|
||||||
"HeaderStatsTop10Authors": "前 10 位作者",
|
"HeaderStatsTop10Authors": "前 10 位作者",
|
||||||
"HeaderStatsTop5Genres": "前 5 种流派",
|
"HeaderStatsTop5Genres": "前 5 种流派",
|
||||||
|
"HeaderTableOfContents": "目录",
|
||||||
"HeaderTools": "工具",
|
"HeaderTools": "工具",
|
||||||
"HeaderUpdateAccount": "更新帐户",
|
"HeaderUpdateAccount": "更新帐户",
|
||||||
"HeaderUpdateAuthor": "更新作者",
|
"HeaderUpdateAuthor": "更新作者",
|
||||||
@@ -203,7 +205,7 @@
|
|||||||
"LabelComplete": "已完成",
|
"LabelComplete": "已完成",
|
||||||
"LabelConfirmPassword": "确认密码",
|
"LabelConfirmPassword": "确认密码",
|
||||||
"LabelContinueListening": "继续收听",
|
"LabelContinueListening": "继续收听",
|
||||||
"LabelContinueReading": "Continue Reading",
|
"LabelContinueReading": "继续阅读",
|
||||||
"LabelContinueSeries": "继续收听系列",
|
"LabelContinueSeries": "继续收听系列",
|
||||||
"LabelCover": "封面",
|
"LabelCover": "封面",
|
||||||
"LabelCoverImageURL": "封面图像 URL",
|
"LabelCoverImageURL": "封面图像 URL",
|
||||||
@@ -221,15 +223,17 @@
|
|||||||
"LabelDiscFromFilename": "从文件名获取光盘",
|
"LabelDiscFromFilename": "从文件名获取光盘",
|
||||||
"LabelDiscFromMetadata": "从元数据获取光盘",
|
"LabelDiscFromMetadata": "从元数据获取光盘",
|
||||||
"LabelDownload": "下载",
|
"LabelDownload": "下载",
|
||||||
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "持续时间",
|
"LabelDuration": "持续时间",
|
||||||
"LabelDurationFound": "找到持续时间:",
|
"LabelDurationFound": "找到持续时间:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "电子书",
|
||||||
"LabelEbooks": "Ebooks",
|
"LabelEbooks": "电子书",
|
||||||
"LabelEdit": "编辑",
|
"LabelEdit": "编辑",
|
||||||
"LabelEmail": "Email",
|
"LabelEmail": "邮箱",
|
||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "发件人地址",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "安全",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "如果选是, 则连接将在连接到服务器时使用TLS. 如果选否, 则若服务器支持STARTTLS扩展, 则使用TLS. 在大多数情况下, 如果连接到端口465, 请将该值设置为是. 对于端口587或25, 请保持为否. (来自nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "测试地址",
|
||||||
"LabelEmbeddedCover": "嵌入封面",
|
"LabelEmbeddedCover": "嵌入封面",
|
||||||
"LabelEnable": "启用",
|
"LabelEnable": "启用",
|
||||||
"LabelEnd": "结束",
|
"LabelEnd": "结束",
|
||||||
@@ -248,13 +252,14 @@
|
|||||||
"LabelFinished": "已听完",
|
"LabelFinished": "已听完",
|
||||||
"LabelFolder": "文件夹",
|
"LabelFolder": "文件夹",
|
||||||
"LabelFolders": "文件夹",
|
"LabelFolders": "文件夹",
|
||||||
|
"LabelFontScale": "字体比例",
|
||||||
"LabelFormat": "编码格式",
|
"LabelFormat": "编码格式",
|
||||||
"LabelGenre": "流派",
|
"LabelGenre": "流派",
|
||||||
"LabelGenres": "流派",
|
"LabelGenres": "流派",
|
||||||
"LabelHardDeleteFile": "完全删除文件",
|
"LabelHardDeleteFile": "完全删除文件",
|
||||||
"LabelHasEbook": "Has ebook",
|
"LabelHasEbook": "有电子书",
|
||||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
"LabelHasSupplementaryEbook": "有补充电子书",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "主机",
|
||||||
"LabelHour": "小时",
|
"LabelHour": "小时",
|
||||||
"LabelIcon": "图标",
|
"LabelIcon": "图标",
|
||||||
"LabelIncludeInTracklist": "包含在音轨列表中",
|
"LabelIncludeInTracklist": "包含在音轨列表中",
|
||||||
@@ -279,12 +284,16 @@
|
|||||||
"LabelLastSeen": "上次查看时间",
|
"LabelLastSeen": "上次查看时间",
|
||||||
"LabelLastTime": "最近一次",
|
"LabelLastTime": "最近一次",
|
||||||
"LabelLastUpdate": "最近更新",
|
"LabelLastUpdate": "最近更新",
|
||||||
|
"LabelLayout": "布局",
|
||||||
|
"LabelLayoutSinglePage": "单页",
|
||||||
|
"LabelLayoutSplitPage": "分页",
|
||||||
"LabelLess": "较少",
|
"LabelLess": "较少",
|
||||||
"LabelLibrariesAccessibleToUser": "用户可访问的媒体库",
|
"LabelLibrariesAccessibleToUser": "用户可访问的媒体库",
|
||||||
"LabelLibrary": "媒体库",
|
"LabelLibrary": "媒体库",
|
||||||
"LabelLibraryItem": "媒体库项目",
|
"LabelLibraryItem": "媒体库项目",
|
||||||
"LabelLibraryName": "媒体库名称",
|
"LabelLibraryName": "媒体库名称",
|
||||||
"LabelLimit": "限制",
|
"LabelLimit": "限制",
|
||||||
|
"LabelLineSpacing": "行间距",
|
||||||
"LabelListenAgain": "再次收听",
|
"LabelListenAgain": "再次收听",
|
||||||
"LabelLogLevelDebug": "调试",
|
"LabelLogLevelDebug": "调试",
|
||||||
"LabelLogLevelInfo": "信息",
|
"LabelLogLevelInfo": "信息",
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
"LabelNewPassword": "新密码",
|
"LabelNewPassword": "新密码",
|
||||||
"LabelNextBackupDate": "下次备份日期",
|
"LabelNextBackupDate": "下次备份日期",
|
||||||
"LabelNextScheduledRun": "下次任务运行",
|
"LabelNextScheduledRun": "下次任务运行",
|
||||||
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotes": "注释",
|
"LabelNotes": "注释",
|
||||||
"LabelNotFinished": "未听完",
|
"LabelNotFinished": "未听完",
|
||||||
"LabelNotificationAppriseURL": "通知 URL(s)",
|
"LabelNotificationAppriseURL": "通知 URL(s)",
|
||||||
@@ -340,18 +350,18 @@
|
|||||||
"LabelPodcast": "播客",
|
"LabelPodcast": "播客",
|
||||||
"LabelPodcasts": "播客",
|
"LabelPodcasts": "播客",
|
||||||
"LabelPodcastType": "播客类型",
|
"LabelPodcastType": "播客类型",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "端口",
|
||||||
"LabelPrefixesToIgnore": "忽略的前缀 (不区分大小写)",
|
"LabelPrefixesToIgnore": "忽略的前缀 (不区分大小写)",
|
||||||
"LabelPreventIndexing": "防止 iTunes 和 Google 播客目录对你的源进行索引",
|
"LabelPreventIndexing": "防止 iTunes 和 Google 播客目录对你的源进行索引",
|
||||||
"LabelPrimaryEbook": "Primary ebook",
|
"LabelPrimaryEbook": "主电子书",
|
||||||
"LabelProgress": "进度",
|
"LabelProgress": "进度",
|
||||||
"LabelProvider": "供应商",
|
"LabelProvider": "供应商",
|
||||||
"LabelPubDate": "出版日期",
|
"LabelPubDate": "出版日期",
|
||||||
"LabelPublisher": "出版商",
|
"LabelPublisher": "出版商",
|
||||||
"LabelPublishYear": "发布年份",
|
"LabelPublishYear": "发布年份",
|
||||||
"LabelRead": "Read",
|
"LabelRead": "阅读",
|
||||||
"LabelReadAgain": "Read Again",
|
"LabelReadAgain": "再次阅读",
|
||||||
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
|
"LabelReadEbookWithoutProgress": "阅读电子书而不保存进度",
|
||||||
"LabelRecentlyAdded": "最近添加",
|
"LabelRecentlyAdded": "最近添加",
|
||||||
"LabelRecentSeries": "最近添加系列",
|
"LabelRecentSeries": "最近添加系列",
|
||||||
"LabelRecommended": "推荐内容",
|
"LabelRecommended": "推荐内容",
|
||||||
@@ -368,15 +378,17 @@
|
|||||||
"LabelSearchTitle": "搜索标题",
|
"LabelSearchTitle": "搜索标题",
|
||||||
"LabelSearchTitleOrASIN": "搜索标题或 ASIN",
|
"LabelSearchTitleOrASIN": "搜索标题或 ASIN",
|
||||||
"LabelSeason": "季",
|
"LabelSeason": "季",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
|
"LabelSendEbookToDevice": "发送电子书到...",
|
||||||
"LabelSequence": "序列",
|
"LabelSequence": "序列",
|
||||||
"LabelSeries": "系列",
|
"LabelSeries": "系列",
|
||||||
"LabelSeriesName": "系列名称",
|
"LabelSeriesName": "系列名称",
|
||||||
"LabelSeriesProgress": "系列进度",
|
"LabelSeriesProgress": "系列进度",
|
||||||
"LabelSetEbookAsPrimary": "Set as primary",
|
"LabelSetEbookAsPrimary": "设置为主",
|
||||||
"LabelSetEbookAsSupplementary": "Set as supplementary",
|
"LabelSetEbookAsSupplementary": "设置为补充",
|
||||||
"LabelSettingsAudiobooksOnly": "Audiobooks only",
|
"LabelSettingsAudiobooksOnly": "只有有声读物",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
|
"LabelSettingsAudiobooksOnlyHelp": "启用此设置将忽略电子书文件, 除非它们位于有声读物文件夹中, 在这种情况下, 它们将被设置为补充电子书",
|
||||||
"LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计",
|
"LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计",
|
||||||
"LabelSettingsChromecastSupport": "Chromecast 支持",
|
"LabelSettingsChromecastSupport": "Chromecast 支持",
|
||||||
"LabelSettingsDateFormat": "日期格式",
|
"LabelSettingsDateFormat": "日期格式",
|
||||||
@@ -387,6 +399,8 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.",
|
"LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.",
|
||||||
"LabelSettingsFindCovers": "查找封面",
|
"LabelSettingsFindCovers": "查找封面",
|
||||||
"LabelSettingsFindCoversHelp": "如果你的有声读物在文件夹中没有嵌入封面或封面图像, 扫描将尝试查找封面.<br>注意: 这将延长扫描时间",
|
"LabelSettingsFindCoversHelp": "如果你的有声读物在文件夹中没有嵌入封面或封面图像, 扫描将尝试查找封面.<br>注意: 这将延长扫描时间",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||||
"LabelSettingsHomePageBookshelfView": "首页使用书架视图",
|
"LabelSettingsHomePageBookshelfView": "首页使用书架视图",
|
||||||
"LabelSettingsLibraryBookshelfView": "媒体库使用书架视图",
|
"LabelSettingsLibraryBookshelfView": "媒体库使用书架视图",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "对章节使用 Overdrive 媒体标记",
|
"LabelSettingsOverdriveMediaMarkers": "对章节使用 Overdrive 媒体标记",
|
||||||
@@ -439,6 +453,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "用户可访问的标签",
|
"LabelTagsAccessibleToUser": "用户可访问的标签",
|
||||||
"LabelTagsNotAccessibleToUser": "用户无法访问标签",
|
"LabelTagsNotAccessibleToUser": "用户无法访问标签",
|
||||||
"LabelTasks": "正在运行的任务",
|
"LabelTasks": "正在运行的任务",
|
||||||
|
"LabelTheme": "主题",
|
||||||
|
"LabelThemeDark": "黑暗",
|
||||||
|
"LabelThemeLight": "明亮",
|
||||||
"LabelTimeBase": "时间基准",
|
"LabelTimeBase": "时间基准",
|
||||||
"LabelTimeListened": "收听时间",
|
"LabelTimeListened": "收听时间",
|
||||||
"LabelTimeListenedToday": "今日收听的时间",
|
"LabelTimeListenedToday": "今日收听的时间",
|
||||||
@@ -498,7 +515,7 @@
|
|||||||
"MessageChapterStartIsAfter": "章节开始是在有声读物结束之后",
|
"MessageChapterStartIsAfter": "章节开始是在有声读物结束之后",
|
||||||
"MessageCheckingCron": "检查计划任务...",
|
"MessageCheckingCron": "检查计划任务...",
|
||||||
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
|
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
|
||||||
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
|
||||||
"MessageConfirmDeleteSession": "你确定要删除此会话吗?",
|
"MessageConfirmDeleteSession": "你确定要删除此会话吗?",
|
||||||
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
|
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
|
||||||
@@ -508,7 +525,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?",
|
"MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
|
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
|
||||||
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "你确定要删除演播者 \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "你确定要将所有项目流派 \"{0}\" 重命名到 \"{1}\"?",
|
"MessageConfirmRenameGenre": "你确定要将所有项目流派 \"{0}\" 重命名到 \"{1}\"?",
|
||||||
"MessageConfirmRenameGenreMergeNote": "注意: 该流派已经存在, 因此它们将被合并.",
|
"MessageConfirmRenameGenreMergeNote": "注意: 该流派已经存在, 因此它们将被合并.",
|
||||||
@@ -516,7 +533,7 @@
|
|||||||
"MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?",
|
"MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?",
|
||||||
"MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.",
|
"MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.",
|
||||||
"MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".",
|
"MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".",
|
||||||
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?",
|
||||||
"MessageDownloadingEpisode": "正在下载剧集",
|
"MessageDownloadingEpisode": "正在下载剧集",
|
||||||
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
|
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
|
||||||
"MessageEmbedFinished": "嵌入完成!",
|
"MessageEmbedFinished": "嵌入完成!",
|
||||||
@@ -575,7 +592,6 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "从收藏中创建播放列表",
|
"MessagePlaylistCreateFromCollection": "从收藏中创建播放列表",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url",
|
"MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url",
|
||||||
"MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.",
|
"MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.",
|
||||||
"MessageRemoveAllItemsWarning": "警告! 此操作将从数据库中删除所有的媒体库项, 包括您所做的任何更新或匹配. 这不会对实际文件产生任何影响. 你确定吗?",
|
|
||||||
"MessageRemoveChapter": "移除章节",
|
"MessageRemoveChapter": "移除章节",
|
||||||
"MessageRemoveEpisodes": "移除 {0} 剧集",
|
"MessageRemoveEpisodes": "移除 {0} 剧集",
|
||||||
"MessageRemoveFromPlayerQueue": "从播放队列中移除",
|
"MessageRemoveFromPlayerQueue": "从播放队列中移除",
|
||||||
@@ -671,8 +687,8 @@
|
|||||||
"ToastRemoveItemFromCollectionSuccess": "项目已从收藏中删除",
|
"ToastRemoveItemFromCollectionSuccess": "项目已从收藏中删除",
|
||||||
"ToastRSSFeedCloseFailed": "关闭 RSS 源失败",
|
"ToastRSSFeedCloseFailed": "关闭 RSS 源失败",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS 源已关闭",
|
"ToastRSSFeedCloseSuccess": "RSS 源已关闭",
|
||||||
"ToastSendEbookToDeviceFailed": "Failed to send Ebook to device",
|
"ToastSendEbookToDeviceFailed": "发送电子书到设备失败",
|
||||||
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
|
"ToastSendEbookToDeviceSuccess": "电子书已经发送到设备 \"{0}\"",
|
||||||
"ToastSeriesUpdateFailed": "更新系列失败",
|
"ToastSeriesUpdateFailed": "更新系列失败",
|
||||||
"ToastSeriesUpdateSuccess": "系列已更新",
|
"ToastSeriesUpdateSuccess": "系列已更新",
|
||||||
"ToastSessionDeleteFailed": "删除会话失败",
|
"ToastSessionDeleteFailed": "删除会话失败",
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
/*
|
|
||||||
This is an example of a fully expanded book library item
|
|
||||||
*/
|
|
||||||
|
|
||||||
const LibraryItem = require('../server/objects/LibraryItem')
|
|
||||||
|
|
||||||
new LibraryItem({
|
|
||||||
id: 'li_abai123wir',
|
|
||||||
ino: "55450570412017066",
|
|
||||||
libraryId: 'lib_1239p1d8',
|
|
||||||
folderId: 'fol_192ab8901',
|
|
||||||
path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule',
|
|
||||||
relPath: '/Terry Goodkind/Sword of Truth/1 - Wizards First Rule',
|
|
||||||
mtimeMs: 1646784672127,
|
|
||||||
ctimeMs: 1646784672127,
|
|
||||||
birthtimeMs: 1646784672127,
|
|
||||||
addedAt: 1646784672127,
|
|
||||||
updatedAt: 1646784672127,
|
|
||||||
lastScan: 1646784672127,
|
|
||||||
scanVersion: 1.72,
|
|
||||||
isMissing: false,
|
|
||||||
isInvalid: false,
|
|
||||||
mediaType: 'book',
|
|
||||||
media: { // Book.js
|
|
||||||
coverPath: '/metadata/items/li_abai123wir/cover.webp',
|
|
||||||
tags: ['favorites'],
|
|
||||||
lastCoverSearch: null,
|
|
||||||
lastCoverSearchQuery: null,
|
|
||||||
metadata: { // BookMetadata.js
|
|
||||||
title: 'Wizards First Rule',
|
|
||||||
subtitle: null,
|
|
||||||
authors: [
|
|
||||||
{
|
|
||||||
id: 'au_42908lkajsfdk',
|
|
||||||
name: 'Terry Goodkind'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
narrators: ['Sam Tsoutsouvas'],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
id: 'se_902384lansf',
|
|
||||||
name: 'Sword of Truth',
|
|
||||||
sequence: 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
genres: ['Fantasy', 'Adventure'],
|
|
||||||
publishedYear: '1994',
|
|
||||||
publishedDate: '1994-01-01',
|
|
||||||
publisher: 'Brilliance Audio',
|
|
||||||
description: 'In the aftermath of the brutal murder of his father, a mysterious woman...',
|
|
||||||
isbn: '289374092834',
|
|
||||||
asin: '19023819203',
|
|
||||||
language: 'english',
|
|
||||||
explicit: false
|
|
||||||
},
|
|
||||||
audioFiles: [
|
|
||||||
{ // AudioFile.js
|
|
||||||
ino: "55450570412017066",
|
|
||||||
index: 1,
|
|
||||||
metadata: { // FileMetadata.js
|
|
||||||
filename: 'audiofile.mp3',
|
|
||||||
ext: '.mp3',
|
|
||||||
path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule/CD01/audiofile.mp3',
|
|
||||||
relPath: '/CD01/audiofile.mp3',
|
|
||||||
mtimeMs: 1646784672127,
|
|
||||||
ctimeMs: 1646784672127,
|
|
||||||
birthtimeMs: 1646784672127,
|
|
||||||
size: 1197449516
|
|
||||||
},
|
|
||||||
trackNumFromMeta: 1,
|
|
||||||
discNumFromMeta: null,
|
|
||||||
trackNumFromFilename: null,
|
|
||||||
discNumFromFilename: 1,
|
|
||||||
manuallyVerified: false,
|
|
||||||
exclude: false,
|
|
||||||
invalid: false,
|
|
||||||
format: "MP2/3 (MPEG audio layer 2/3)",
|
|
||||||
duration: 2342342,
|
|
||||||
bitRate: 324234,
|
|
||||||
language: null,
|
|
||||||
codec: 'mp3',
|
|
||||||
timeBase: "1/14112000",
|
|
||||||
channels: 1,
|
|
||||||
channelLayout: "mono",
|
|
||||||
chapters: [],
|
|
||||||
embeddedCoverArt: 'jpeg', // Video stream codec ['mjpeg', 'jpeg', 'png'] or null
|
|
||||||
metaTags: { // AudioMetaTags.js
|
|
||||||
tagAlbum: '',
|
|
||||||
tagArtist: '',
|
|
||||||
tagGenre: '',
|
|
||||||
tagTitle: '',
|
|
||||||
tagSeries: '',
|
|
||||||
tagSeriesPart: '',
|
|
||||||
tagTrack: '',
|
|
||||||
tagDisc: '',
|
|
||||||
tagSubtitle: '',
|
|
||||||
tagAlbumArtist: '',
|
|
||||||
tagDate: '',
|
|
||||||
tagComposer: '',
|
|
||||||
tagPublisher: '',
|
|
||||||
tagComment: '',
|
|
||||||
tagDescription: '',
|
|
||||||
tagEncoder: '',
|
|
||||||
tagEncodedBy: '',
|
|
||||||
tagIsbn: '',
|
|
||||||
tagLanguage: '',
|
|
||||||
tagASIN: ''
|
|
||||||
},
|
|
||||||
addedAt: 1646784672127,
|
|
||||||
updatedAt: 1646784672127
|
|
||||||
}
|
|
||||||
],
|
|
||||||
chapters: [
|
|
||||||
{
|
|
||||||
id: 0,
|
|
||||||
title: 'Chapter 01',
|
|
||||||
start: 0,
|
|
||||||
end: 2467.753
|
|
||||||
}
|
|
||||||
],
|
|
||||||
missingParts: [4, 10], // Array of missing parts in tracklist
|
|
||||||
ebookFile: { // EBookFile.js
|
|
||||||
ino: "55450570412017066",
|
|
||||||
metadata: { // FileMetadata.js
|
|
||||||
filename: 'ebookfile.mobi',
|
|
||||||
ext: '.mobi',
|
|
||||||
path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule/ebookfile.mobi',
|
|
||||||
relPath: '/ebookfile.mobi',
|
|
||||||
mtimeMs: 1646784672127,
|
|
||||||
ctimeMs: 1646784672127,
|
|
||||||
birthtimeMs: 1646784672127,
|
|
||||||
size: 1197449516
|
|
||||||
},
|
|
||||||
ebookFormat: 'mobi',
|
|
||||||
addedAt: 1646784672127,
|
|
||||||
updatedAt: 1646784672127
|
|
||||||
}
|
|
||||||
},
|
|
||||||
libraryFiles: [
|
|
||||||
{ // LibraryFile.js
|
|
||||||
ino: "55450570412017066",
|
|
||||||
metadata: { // FileMetadata.js
|
|
||||||
filename: 'cover.png',
|
|
||||||
ext: '.png',
|
|
||||||
path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule/subfolder/cover.png',
|
|
||||||
relPath: '/subfolder/cover.png',
|
|
||||||
mtimeMs: 1646784672127,
|
|
||||||
ctimeMs: 1646784672127,
|
|
||||||
birthtimeMs: 1646784672127,
|
|
||||||
size: 1197449516
|
|
||||||
},
|
|
||||||
addedAt: 1646784672127,
|
|
||||||
updatedAt: 1646784672127
|
|
||||||
},
|
|
||||||
{ // LibraryFile.js
|
|
||||||
ino: "55450570412017066",
|
|
||||||
metadata: { // FileMetadata.js
|
|
||||||
filename: 'cover.png',
|
|
||||||
ext: '.mobi',
|
|
||||||
path: '/audiobooks/Terry Goodkind/Sword of Truth/1 - Wizards First Rule/ebookfile.mobi',
|
|
||||||
relPath: '/ebookfile.mobi',
|
|
||||||
mtimeMs: 1646784672127,
|
|
||||||
ctimeMs: 1646784672127,
|
|
||||||
birthtimeMs: 1646784672127,
|
|
||||||
size: 1197449516
|
|
||||||
},
|
|
||||||
addedAt: 1646784672127,
|
|
||||||
updatedAt: 1646784672127
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
/*
|
|
||||||
This is an example of a fully expanded podcast library item (under construction)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const LibraryItem = require('../server/objects/LibraryItem')
|
|
||||||
|
|
||||||
new LibraryItem({
|
|
||||||
id: 'li_abai123wir',
|
|
||||||
ino: "55450570412017066",
|
|
||||||
libraryId: 'lib_1239p1d8',
|
|
||||||
folderId: 'fol_192ab8901',
|
|
||||||
path: '/podcasts/Great Podcast Name',
|
|
||||||
relPath: '/Great Podcast Name',
|
|
||||||
mtimeMs: 1646784672127,
|
|
||||||
ctimeMs: 1646784672127,
|
|
||||||
birthtimeMs: 1646784672127,
|
|
||||||
addedAt: 1646784672127,
|
|
||||||
updatedAt: 1646784672127,
|
|
||||||
lastScan: 1646784672127,
|
|
||||||
scanVersion: 1.72,
|
|
||||||
isMissing: false,
|
|
||||||
isInvalid: false,
|
|
||||||
mediaType: 'podcast',
|
|
||||||
media: { // Podcast.js
|
|
||||||
coverPath: '/metadata/items/li_abai123wir/cover.webp',
|
|
||||||
tags: ['favorites'],
|
|
||||||
lastCoverSearch: null,
|
|
||||||
lastCoverSearchQuery: null,
|
|
||||||
metadata: { // PodcastMetadata.js
|
|
||||||
title: 'Great Podcast Name',
|
|
||||||
artist: 'Some Artist Name',
|
|
||||||
genres: ['Fantasy', 'Adventure'],
|
|
||||||
publishedDate: '1994-01-01',
|
|
||||||
description: 'In the aftermath of the brutal murder of his father, a mysterious woman...',
|
|
||||||
feedUrl: '',
|
|
||||||
itunesPageUrl: '',
|
|
||||||
itunesId: '',
|
|
||||||
itunesArtistId: '',
|
|
||||||
explicit: false
|
|
||||||
},
|
|
||||||
episodes: [
|
|
||||||
{ // PodcastEpisode.js
|
|
||||||
id: 'ep_289374asf0a98',
|
|
||||||
index: 1,
|
|
||||||
// TODO: podcast episode data and PodcastEpisodeMetadata
|
|
||||||
addedAt: 1646784672127,
|
|
||||||
updatedAt: 1646784672127
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
libraryFiles: [
|
|
||||||
{ // LibraryFile.js
|
|
||||||
ino: "55450570412017066",
|
|
||||||
metadata: { // FileMetadata.js
|
|
||||||
filename: 'cover.png',
|
|
||||||
ext: '.png',
|
|
||||||
path: '/podcasts/Great Podcast Name/cover.png',
|
|
||||||
relPath: '/cover.png',
|
|
||||||
mtimeMs: 1646784672127,
|
|
||||||
ctimeMs: 1646784672127,
|
|
||||||
birthtimeMs: 1646784672127,
|
|
||||||
size: 1197449516
|
|
||||||
},
|
|
||||||
addedAt: 1646784672127,
|
|
||||||
updatedAt: 1646784672127
|
|
||||||
},
|
|
||||||
{ // LibraryFile.js
|
|
||||||
ino: "55450570412017066",
|
|
||||||
metadata: { // FileMetadata.js
|
|
||||||
filename: 'episode_1.mp3',
|
|
||||||
ext: '.mp3',
|
|
||||||
path: '/podcasts/Great Podcast Name/episode_1.mp3',
|
|
||||||
relPath: '/episode_1.mp3',
|
|
||||||
mtimeMs: 1646784672127,
|
|
||||||
ctimeMs: 1646784672127,
|
|
||||||
birthtimeMs: 1646784672127,
|
|
||||||
size: 1197449516
|
|
||||||
},
|
|
||||||
addedAt: 1646784672127,
|
|
||||||
updatedAt: 1646784672127
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
Generated
+2360
-16
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -20,7 +20,7 @@
|
|||||||
"pkg": {
|
"pkg": {
|
||||||
"assets": [
|
"assets": [
|
||||||
"client/dist/**/*",
|
"client/dist/**/*",
|
||||||
"server/Db.js"
|
"node_modules/sqlite3/lib/binding/**/*.node"
|
||||||
],
|
],
|
||||||
"scripts": [
|
"scripts": [
|
||||||
"prod.js",
|
"prod.js",
|
||||||
@@ -36,7 +36,9 @@
|
|||||||
"htmlparser2": "^8.0.1",
|
"htmlparser2": "^8.0.1",
|
||||||
"node-tone": "^1.0.1",
|
"node-tone": "^1.0.1",
|
||||||
"nodemailer": "^6.9.2",
|
"nodemailer": "^6.9.2",
|
||||||
|
"sequelize": "^6.32.1",
|
||||||
"socket.io": "^4.5.4",
|
"socket.io": "^4.5.4",
|
||||||
|
"sqlite3": "^5.1.6",
|
||||||
"xml2js": "^0.5.0"
|
"xml2js": "^0.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+22
-41
@@ -2,21 +2,10 @@ const bcrypt = require('./libs/bcryptjs')
|
|||||||
const jwt = require('./libs/jsonwebtoken')
|
const jwt = require('./libs/jsonwebtoken')
|
||||||
const requestIp = require('./libs/requestIp')
|
const requestIp = require('./libs/requestIp')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
const Database = require('./Database')
|
||||||
|
|
||||||
class Auth {
|
class Auth {
|
||||||
constructor(db) {
|
constructor() { }
|
||||||
this.db = db
|
|
||||||
|
|
||||||
this.user = null
|
|
||||||
}
|
|
||||||
|
|
||||||
get username() {
|
|
||||||
return this.user ? this.user.username : 'nobody'
|
|
||||||
}
|
|
||||||
|
|
||||||
get users() {
|
|
||||||
return this.db.users
|
|
||||||
}
|
|
||||||
|
|
||||||
cors(req, res, next) {
|
cors(req, res, next) {
|
||||||
res.header('Access-Control-Allow-Origin', '*')
|
res.header('Access-Control-Allow-Origin', '*')
|
||||||
@@ -35,20 +24,20 @@ class Auth {
|
|||||||
async initTokenSecret() {
|
async initTokenSecret() {
|
||||||
if (process.env.TOKEN_SECRET) { // User can supply their own token secret
|
if (process.env.TOKEN_SECRET) { // User can supply their own token secret
|
||||||
Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`)
|
Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`)
|
||||||
this.db.serverSettings.tokenSecret = process.env.TOKEN_SECRET
|
Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET
|
||||||
} else {
|
} else {
|
||||||
Logger.debug(`[Auth] Setting token secret - using random bytes`)
|
Logger.debug(`[Auth] Setting token secret - using random bytes`)
|
||||||
this.db.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
|
Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
|
||||||
}
|
}
|
||||||
await this.db.updateServerSettings()
|
await Database.updateServerSettings()
|
||||||
|
|
||||||
// New token secret creation added in v2.1.0 so generate new API tokens for each user
|
// New token secret creation added in v2.1.0 so generate new API tokens for each user
|
||||||
if (this.db.users.length) {
|
if (Database.users.length) {
|
||||||
for (const user of this.db.users) {
|
for (const user of Database.users) {
|
||||||
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
|
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
|
||||||
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
|
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
|
||||||
}
|
}
|
||||||
await this.db.updateEntities('user', this.db.users)
|
await Database.updateBulkUsers(Database.users)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +57,7 @@ class Auth {
|
|||||||
return res.sendStatus(401)
|
return res.sendStatus(401)
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await this.verifyToken(token)
|
const user = await this.verifyToken(token)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
Logger.error('Verify Token User Not Found', token)
|
Logger.error('Verify Token User Not Found', token)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
@@ -95,7 +84,7 @@ class Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
generateAccessToken(payload) {
|
generateAccessToken(payload) {
|
||||||
return jwt.sign(payload, global.ServerSettings.tokenSecret);
|
return jwt.sign(payload, Database.serverSettings.tokenSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticateUser(token) {
|
authenticateUser(token) {
|
||||||
@@ -104,12 +93,12 @@ class Auth {
|
|||||||
|
|
||||||
verifyToken(token) {
|
verifyToken(token) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
jwt.verify(token, global.ServerSettings.tokenSecret, (err, payload) => {
|
jwt.verify(token, Database.serverSettings.tokenSecret, (err, payload) => {
|
||||||
if (!payload || err) {
|
if (!payload || err) {
|
||||||
Logger.error('JWT Verify Token Failed', err)
|
Logger.error('JWT Verify Token Failed', err)
|
||||||
return resolve(null)
|
return resolve(null)
|
||||||
}
|
}
|
||||||
const user = this.users.find(u => u.id === payload.userId && u.username === payload.username)
|
const user = Database.users.find(u => (u.id === payload.userId || u.oldUserId === payload.userId) && u.username === payload.username)
|
||||||
resolve(user || null)
|
resolve(user || null)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -118,9 +107,9 @@ class Auth {
|
|||||||
getUserLoginResponsePayload(user) {
|
getUserLoginResponsePayload(user) {
|
||||||
return {
|
return {
|
||||||
user: user.toJSONForBrowser(),
|
user: user.toJSONForBrowser(),
|
||||||
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
|
userDefaultLibraryId: user.getDefaultLibraryId(Database.libraries),
|
||||||
serverSettings: this.db.serverSettings.toJSONForBrowser(),
|
serverSettings: Database.serverSettings.toJSONForBrowser(),
|
||||||
ereaderDevices: this.db.emailSettings.getEReaderDevices(user),
|
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
|
||||||
Source: global.Source
|
Source: global.Source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +119,7 @@ class Auth {
|
|||||||
const username = (req.body.username || '').toLowerCase()
|
const username = (req.body.username || '').toLowerCase()
|
||||||
const password = req.body.password || ''
|
const password = req.body.password || ''
|
||||||
|
|
||||||
const user = this.users.find(u => u.username.toLowerCase() === username)
|
const user = Database.users.find(u => u.username.toLowerCase() === username)
|
||||||
|
|
||||||
if (!user?.isActive) {
|
if (!user?.isActive) {
|
||||||
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
|
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
|
||||||
@@ -142,7 +131,7 @@ class Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check passwordless root user
|
// Check passwordless root user
|
||||||
if (user.id === 'root' && (!user.pash || user.pash === '')) {
|
if (user.type === 'root' && (!user.pash || user.pash === '')) {
|
||||||
if (password) {
|
if (password) {
|
||||||
return res.status(401).send('Invalid root password (hint: there is none)')
|
return res.status(401).send('Invalid root password (hint: there is none)')
|
||||||
} else {
|
} else {
|
||||||
@@ -166,15 +155,6 @@ class Auth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not in use now
|
|
||||||
lockUser(user) {
|
|
||||||
user.isLocked = true
|
|
||||||
return this.db.updateEntity('user', user).catch((error) => {
|
|
||||||
Logger.error('[Auth] Failed to lock user', user.username, error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
comparePassword(password, user) {
|
comparePassword(password, user) {
|
||||||
if (user.type === 'root' && !password && !user.pash) return true
|
if (user.type === 'root' && !password && !user.pash) return true
|
||||||
if (!password || !user.pash) return false
|
if (!password || !user.pash) return false
|
||||||
@@ -184,7 +164,7 @@ class Auth {
|
|||||||
async userChangePassword(req, res) {
|
async userChangePassword(req, res) {
|
||||||
var { password, newPassword } = req.body
|
var { password, newPassword } = req.body
|
||||||
newPassword = newPassword || ''
|
newPassword = newPassword || ''
|
||||||
var matchingUser = this.users.find(u => u.id === req.user.id)
|
const matchingUser = Database.users.find(u => u.id === req.user.id)
|
||||||
|
|
||||||
// Only root can have an empty password
|
// Only root can have an empty password
|
||||||
if (matchingUser.type !== 'root' && !newPassword) {
|
if (matchingUser.type !== 'root' && !newPassword) {
|
||||||
@@ -193,14 +173,14 @@ class Auth {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var compare = await this.comparePassword(password, matchingUser)
|
const compare = await this.comparePassword(password, matchingUser)
|
||||||
if (!compare) {
|
if (!compare) {
|
||||||
return res.json({
|
return res.json({
|
||||||
error: 'Invalid password'
|
error: 'Invalid password'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var pw = ''
|
let pw = ''
|
||||||
if (newPassword) {
|
if (newPassword) {
|
||||||
pw = await this.hashPass(newPassword)
|
pw = await this.hashPass(newPassword)
|
||||||
if (!pw) {
|
if (!pw) {
|
||||||
@@ -211,7 +191,8 @@ class Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
matchingUser.pash = pw
|
matchingUser.pash = pw
|
||||||
var success = await this.db.updateEntity('user', matchingUser)
|
|
||||||
|
const success = await Database.updateUser(matchingUser)
|
||||||
if (success) {
|
if (success) {
|
||||||
res.json({
|
res.json({
|
||||||
success: true
|
success: true
|
||||||
|
|||||||
@@ -0,0 +1,520 @@
|
|||||||
|
const Path = require('path')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
|
||||||
|
const packageJson = require('../package.json')
|
||||||
|
const fs = require('./libs/fsExtra')
|
||||||
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
|
const dbMigration = require('./utils/migrations/dbMigration')
|
||||||
|
|
||||||
|
class Database {
|
||||||
|
constructor() {
|
||||||
|
this.sequelize = null
|
||||||
|
this.dbPath = null
|
||||||
|
this.isNew = false // New absdatabase.sqlite created
|
||||||
|
|
||||||
|
// Temporarily using format of old DB
|
||||||
|
// TODO: below data should be loaded from the DB as needed
|
||||||
|
this.libraryItems = []
|
||||||
|
this.users = []
|
||||||
|
this.libraries = []
|
||||||
|
this.settings = []
|
||||||
|
this.collections = []
|
||||||
|
this.playlists = []
|
||||||
|
this.authors = []
|
||||||
|
this.series = []
|
||||||
|
this.feeds = []
|
||||||
|
|
||||||
|
this.serverSettings = null
|
||||||
|
this.notificationSettings = null
|
||||||
|
this.emailSettings = null
|
||||||
|
}
|
||||||
|
|
||||||
|
get models() {
|
||||||
|
return this.sequelize?.models || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasRootUser() {
|
||||||
|
return this.users.some(u => u.type === 'root')
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkHasDb() {
|
||||||
|
if (!await fs.pathExists(this.dbPath)) {
|
||||||
|
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(force = false) {
|
||||||
|
this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')
|
||||||
|
|
||||||
|
// First check if this is a new database
|
||||||
|
this.isNew = !(await this.checkHasDb()) || force
|
||||||
|
|
||||||
|
if (!await this.connect()) {
|
||||||
|
throw new Error('Database connection failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.buildModels(force)
|
||||||
|
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
|
||||||
|
|
||||||
|
await this.loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
Logger.info(`[Database] Initializing db at "${this.dbPath}"`)
|
||||||
|
this.sequelize = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
storage: this.dbPath,
|
||||||
|
logging: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper function
|
||||||
|
this.sequelize.uppercaseFirst = str => str ? `${str[0].toUpperCase()}${str.substr(1)}` : ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.sequelize.authenticate()
|
||||||
|
Logger.info(`[Database] Db connection was successful`)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[Database] Failed to connect to db`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
Logger.info(`[Database] Disconnecting sqlite db`)
|
||||||
|
await this.sequelize.close()
|
||||||
|
this.sequelize = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconnect() {
|
||||||
|
Logger.info(`[Database] Reconnecting sqlite db`)
|
||||||
|
await this.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
buildModels(force = false) {
|
||||||
|
require('./models/User')(this.sequelize)
|
||||||
|
require('./models/Library')(this.sequelize)
|
||||||
|
require('./models/LibraryFolder')(this.sequelize)
|
||||||
|
require('./models/Book')(this.sequelize)
|
||||||
|
require('./models/Podcast')(this.sequelize)
|
||||||
|
require('./models/PodcastEpisode')(this.sequelize)
|
||||||
|
require('./models/LibraryItem')(this.sequelize)
|
||||||
|
require('./models/MediaProgress')(this.sequelize)
|
||||||
|
require('./models/Series')(this.sequelize)
|
||||||
|
require('./models/BookSeries')(this.sequelize)
|
||||||
|
require('./models/Author')(this.sequelize)
|
||||||
|
require('./models/BookAuthor')(this.sequelize)
|
||||||
|
require('./models/Collection')(this.sequelize)
|
||||||
|
require('./models/CollectionBook')(this.sequelize)
|
||||||
|
require('./models/Playlist')(this.sequelize)
|
||||||
|
require('./models/PlaylistMediaItem')(this.sequelize)
|
||||||
|
require('./models/Device')(this.sequelize)
|
||||||
|
require('./models/PlaybackSession')(this.sequelize)
|
||||||
|
require('./models/Feed')(this.sequelize)
|
||||||
|
require('./models/FeedEpisode')(this.sequelize)
|
||||||
|
require('./models/Setting')(this.sequelize)
|
||||||
|
|
||||||
|
return this.sequelize.sync({ force, alter: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
if (this.isNew && await dbMigration.checkShouldMigrate()) {
|
||||||
|
Logger.info(`[Database] New database was created and old database was detected - migrating old to new`)
|
||||||
|
await dbMigration.migrate(this.models)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
this.libraryItems = await this.models.libraryItem.getAllOldLibraryItems()
|
||||||
|
this.users = await this.models.user.getOldUsers()
|
||||||
|
this.libraries = await this.models.library.getAllOldLibraries()
|
||||||
|
this.collections = await this.models.collection.getOldCollections()
|
||||||
|
this.playlists = await this.models.playlist.getOldPlaylists()
|
||||||
|
this.authors = await this.models.author.getOldAuthors()
|
||||||
|
this.series = await this.models.series.getAllOldSeries()
|
||||||
|
this.feeds = await this.models.feed.getOldFeeds()
|
||||||
|
|
||||||
|
const settingsData = await this.models.setting.getOldSettings()
|
||||||
|
this.settings = settingsData.settings
|
||||||
|
this.emailSettings = settingsData.emailSettings
|
||||||
|
this.serverSettings = settingsData.serverSettings
|
||||||
|
this.notificationSettings = settingsData.notificationSettings
|
||||||
|
global.ServerSettings = this.serverSettings.toJSON()
|
||||||
|
|
||||||
|
Logger.info(`[Database] Db data loaded in ${Date.now() - startTime}ms`)
|
||||||
|
|
||||||
|
if (packageJson.version !== this.serverSettings.version) {
|
||||||
|
Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`)
|
||||||
|
this.serverSettings.version = packageJson.version
|
||||||
|
await this.updateServerSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRootUser(username, pash, token) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
const newUser = await this.models.user.createRootUser(username, pash, token)
|
||||||
|
if (newUser) {
|
||||||
|
this.users.push(newUser)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
updateServerSettings() {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
global.ServerSettings = this.serverSettings.toJSON()
|
||||||
|
return this.updateSetting(this.serverSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSetting(settings) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.setting.updateSettingObj(settings.toJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(oldUser) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.user.createFromOld(oldUser)
|
||||||
|
this.users.push(oldUser)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUser(oldUser) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.user.updateFromOld(oldUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBulkUsers(oldUsers) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return Promise.all(oldUsers.map(u => this.updateUser(u)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeUser(userId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.user.removeById(userId)
|
||||||
|
this.users = this.users.filter(u => u.id !== userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertMediaProgress(oldMediaProgress) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.mediaProgress.upsertFromOld(oldMediaProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMediaProgress(mediaProgressId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.mediaProgress.removeById(mediaProgressId)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBulkBooks(oldBooks) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLibrary(oldLibrary) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.library.createFromOld(oldLibrary)
|
||||||
|
this.libraries.push(oldLibrary)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLibrary(oldLibrary) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.library.updateFromOld(oldLibrary)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLibrary(libraryId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.library.removeById(libraryId)
|
||||||
|
this.libraries = this.libraries.filter(lib => lib.id !== libraryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCollection(oldCollection) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
const newCollection = await this.models.collection.createFromOld(oldCollection)
|
||||||
|
// Create CollectionBooks
|
||||||
|
if (newCollection) {
|
||||||
|
const collectionBooks = []
|
||||||
|
oldCollection.books.forEach((libraryItemId) => {
|
||||||
|
const libraryItem = this.libraryItems.find(li => li.id === libraryItemId)
|
||||||
|
if (libraryItem) {
|
||||||
|
collectionBooks.push({
|
||||||
|
collectionId: newCollection.id,
|
||||||
|
bookId: libraryItem.media.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (collectionBooks.length) {
|
||||||
|
await this.createBulkCollectionBooks(collectionBooks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.collections.push(oldCollection)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCollection(oldCollection) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
const collectionBooks = []
|
||||||
|
let order = 1
|
||||||
|
oldCollection.books.forEach((libraryItemId) => {
|
||||||
|
const libraryItem = this.getLibraryItem(libraryItemId)
|
||||||
|
if (!libraryItem) return
|
||||||
|
collectionBooks.push({
|
||||||
|
collectionId: oldCollection.id,
|
||||||
|
bookId: libraryItem.media.id,
|
||||||
|
order: order++
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return this.models.collection.fullUpdateFromOld(oldCollection, collectionBooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCollection(collectionId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.collection.removeById(collectionId)
|
||||||
|
this.collections = this.collections.filter(c => c.id !== collectionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
createCollectionBook(collectionBook) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.collectionBook.create(collectionBook)
|
||||||
|
}
|
||||||
|
|
||||||
|
createBulkCollectionBooks(collectionBooks) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.collectionBook.bulkCreate(collectionBooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCollectionBook(collectionId, bookId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.collectionBook.removeByIds(collectionId, bookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPlaylist(oldPlaylist) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
const newPlaylist = await this.models.playlist.createFromOld(oldPlaylist)
|
||||||
|
if (newPlaylist) {
|
||||||
|
const playlistMediaItems = []
|
||||||
|
let order = 1
|
||||||
|
for (const mediaItemObj of oldPlaylist.items) {
|
||||||
|
const libraryItem = this.libraryItems.find(li => li.id === mediaItemObj.libraryItemId)
|
||||||
|
if (!libraryItem) continue
|
||||||
|
|
||||||
|
let mediaItemId = libraryItem.media.id // bookId
|
||||||
|
let mediaItemType = 'book'
|
||||||
|
if (mediaItemObj.episodeId) {
|
||||||
|
mediaItemType = 'podcastEpisode'
|
||||||
|
mediaItemId = mediaItemObj.episodeId
|
||||||
|
}
|
||||||
|
playlistMediaItems.push({
|
||||||
|
playlistId: newPlaylist.id,
|
||||||
|
mediaItemId,
|
||||||
|
mediaItemType,
|
||||||
|
order: order++
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (playlistMediaItems.length) {
|
||||||
|
await this.createBulkPlaylistMediaItems(playlistMediaItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.playlists.push(oldPlaylist)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlaylist(oldPlaylist) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
const playlistMediaItems = []
|
||||||
|
let order = 1
|
||||||
|
oldPlaylist.items.forEach((item) => {
|
||||||
|
const libraryItem = this.getLibraryItem(item.libraryItemId)
|
||||||
|
if (!libraryItem) return
|
||||||
|
playlistMediaItems.push({
|
||||||
|
playlistId: oldPlaylist.id,
|
||||||
|
mediaItemId: item.episodeId || libraryItem.media.id,
|
||||||
|
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||||
|
order: order++
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return this.models.playlist.fullUpdateFromOld(oldPlaylist, playlistMediaItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePlaylist(playlistId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.playlist.removeById(playlistId)
|
||||||
|
this.playlists = this.playlists.filter(p => p.id !== playlistId)
|
||||||
|
}
|
||||||
|
|
||||||
|
createPlaylistMediaItem(playlistMediaItem) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.playlistMediaItem.create(playlistMediaItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
createBulkPlaylistMediaItems(playlistMediaItems) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
removePlaylistMediaItem(playlistId, mediaItemId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.playlistMediaItem.removeByIds(playlistId, mediaItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
getLibraryItem(libraryItemId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.libraryItems.find(li => li.id === libraryItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLibraryItem(oldLibraryItem) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
|
||||||
|
this.libraryItems.push(oldLibraryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLibraryItem(oldLibraryItem) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBulkLibraryItems(oldLibraryItems) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
let updatesMade = 0
|
||||||
|
for (const oldLibraryItem of oldLibraryItems) {
|
||||||
|
const hasUpdates = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
|
||||||
|
if (hasUpdates) updatesMade++
|
||||||
|
}
|
||||||
|
return updatesMade
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBulkLibraryItems(oldLibraryItems) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
for (const oldLibraryItem of oldLibraryItems) {
|
||||||
|
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
|
||||||
|
this.libraryItems.push(oldLibraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLibraryItem(libraryItemId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.libraryItem.removeById(libraryItemId)
|
||||||
|
this.libraryItems = this.libraryItems.filter(li => li.id !== libraryItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFeed(oldFeed) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.feed.fullCreateFromOld(oldFeed)
|
||||||
|
this.feeds.push(oldFeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFeed(oldFeed) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.feed.fullUpdateFromOld(oldFeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFeed(feedId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.feed.removeById(feedId)
|
||||||
|
this.feeds = this.feeds.filter(f => f.id !== feedId)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSeries(oldSeries) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.series.updateFromOld(oldSeries)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSeries(oldSeries) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.series.createFromOld(oldSeries)
|
||||||
|
this.series.push(oldSeries)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBulkSeries(oldSeriesObjs) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.series.createBulkFromOld(oldSeriesObjs)
|
||||||
|
this.series.push(...oldSeriesObjs)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSeries(seriesId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.series.removeById(seriesId)
|
||||||
|
this.series = this.series.filter(se => se.id !== seriesId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAuthor(oldAuthor) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.createFromOld(oldAuthor)
|
||||||
|
this.authors.push(oldAuthor)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBulkAuthors(oldAuthors) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.author.createBulkFromOld(oldAuthors)
|
||||||
|
this.authors.push(...oldAuthors)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAuthor(oldAuthor) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.author.updateFromOld(oldAuthor)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAuthor(authorId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.author.removeById(authorId)
|
||||||
|
this.authors = this.authors.filter(au => au.id !== authorId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBulkBookAuthors(bookAuthors) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
||||||
|
this.authors.push(...bookAuthors)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBulkBookAuthors(authorId = null, bookId = null) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
if (!authorId && !bookId) return
|
||||||
|
await this.models.bookAuthor.removeByIds(authorId, bookId)
|
||||||
|
this.authors = this.authors.filter(au => {
|
||||||
|
if (authorId && au.authorId !== authorId) return true
|
||||||
|
if (bookId && au.bookId !== bookId) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaybackSessions(where = null) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.playbackSession.getOldPlaybackSessions(where)
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaybackSession(sessionId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.playbackSession.getById(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
createPlaybackSession(oldSession) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.playbackSession.createFromOld(oldSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlaybackSession(oldSession) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.playbackSession.updateFromOld(oldSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
removePlaybackSession(sessionId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.playbackSession.removeById(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
getDeviceByDeviceId(deviceId) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.device.getOldDeviceByDeviceId(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDevice(oldDevice) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.device.updateFromOld(oldDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
createDevice(oldDevice) {
|
||||||
|
if (!this.sequelize) return false
|
||||||
|
return this.models.device.createFromOld(oldDevice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new Database()
|
||||||
-503
@@ -1,503 +0,0 @@
|
|||||||
const Path = require('path')
|
|
||||||
const njodb = require('./libs/njodb')
|
|
||||||
const Logger = require('./Logger')
|
|
||||||
const { version } = require('../package.json')
|
|
||||||
const filePerms = require('./utils/filePerms')
|
|
||||||
const LibraryItem = require('./objects/LibraryItem')
|
|
||||||
const User = require('./objects/user/User')
|
|
||||||
const Collection = require('./objects/Collection')
|
|
||||||
const Playlist = require('./objects/Playlist')
|
|
||||||
const Library = require('./objects/Library')
|
|
||||||
const Author = require('./objects/entities/Author')
|
|
||||||
const Series = require('./objects/entities/Series')
|
|
||||||
const ServerSettings = require('./objects/settings/ServerSettings')
|
|
||||||
const NotificationSettings = require('./objects/settings/NotificationSettings')
|
|
||||||
const EmailSettings = require('./objects/settings/EmailSettings')
|
|
||||||
const PlaybackSession = require('./objects/PlaybackSession')
|
|
||||||
|
|
||||||
class Db {
|
|
||||||
constructor() {
|
|
||||||
this.LibraryItemsPath = Path.join(global.ConfigPath, 'libraryItems')
|
|
||||||
this.UsersPath = Path.join(global.ConfigPath, 'users')
|
|
||||||
this.SessionsPath = Path.join(global.ConfigPath, 'sessions')
|
|
||||||
this.LibrariesPath = Path.join(global.ConfigPath, 'libraries')
|
|
||||||
this.SettingsPath = Path.join(global.ConfigPath, 'settings')
|
|
||||||
this.CollectionsPath = Path.join(global.ConfigPath, 'collections')
|
|
||||||
this.PlaylistsPath = Path.join(global.ConfigPath, 'playlists')
|
|
||||||
this.AuthorsPath = Path.join(global.ConfigPath, 'authors')
|
|
||||||
this.SeriesPath = Path.join(global.ConfigPath, 'series')
|
|
||||||
this.FeedsPath = Path.join(global.ConfigPath, 'feeds')
|
|
||||||
|
|
||||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, this.getNjodbOptions())
|
|
||||||
this.usersDb = new njodb.Database(this.UsersPath, this.getNjodbOptions())
|
|
||||||
this.sessionsDb = new njodb.Database(this.SessionsPath, this.getNjodbOptions())
|
|
||||||
this.librariesDb = new njodb.Database(this.LibrariesPath, this.getNjodbOptions())
|
|
||||||
this.settingsDb = new njodb.Database(this.SettingsPath, this.getNjodbOptions())
|
|
||||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, this.getNjodbOptions())
|
|
||||||
this.playlistsDb = new njodb.Database(this.PlaylistsPath, this.getNjodbOptions())
|
|
||||||
this.authorsDb = new njodb.Database(this.AuthorsPath, this.getNjodbOptions())
|
|
||||||
this.seriesDb = new njodb.Database(this.SeriesPath, this.getNjodbOptions())
|
|
||||||
this.feedsDb = new njodb.Database(this.FeedsPath, this.getNjodbOptions())
|
|
||||||
|
|
||||||
this.libraryItems = []
|
|
||||||
this.users = []
|
|
||||||
this.libraries = []
|
|
||||||
this.settings = []
|
|
||||||
this.collections = []
|
|
||||||
this.playlists = []
|
|
||||||
this.authors = []
|
|
||||||
this.series = []
|
|
||||||
|
|
||||||
this.serverSettings = null
|
|
||||||
this.notificationSettings = null
|
|
||||||
this.emailSettings = null
|
|
||||||
|
|
||||||
// Stores previous version only if upgraded
|
|
||||||
this.previousVersion = null
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasRootUser() {
|
|
||||||
return this.users.some(u => u.id === 'root')
|
|
||||||
}
|
|
||||||
|
|
||||||
getNjodbOptions() {
|
|
||||||
return {
|
|
||||||
lockoptions: {
|
|
||||||
stale: 1000 * 20, // 20 seconds
|
|
||||||
update: 2500,
|
|
||||||
retries: {
|
|
||||||
retries: 20,
|
|
||||||
minTimeout: 250,
|
|
||||||
maxTimeout: 5000,
|
|
||||||
factor: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getEntityDb(entityName) {
|
|
||||||
if (entityName === 'user') return this.usersDb
|
|
||||||
else if (entityName === 'session') return this.sessionsDb
|
|
||||||
else if (entityName === 'libraryItem') return this.libraryItemsDb
|
|
||||||
else if (entityName === 'library') return this.librariesDb
|
|
||||||
else if (entityName === 'settings') return this.settingsDb
|
|
||||||
else if (entityName === 'collection') return this.collectionsDb
|
|
||||||
else if (entityName === 'playlist') return this.playlistsDb
|
|
||||||
else if (entityName === 'author') return this.authorsDb
|
|
||||||
else if (entityName === 'series') return this.seriesDb
|
|
||||||
else if (entityName === 'feed') return this.feedsDb
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
getEntityArrayKey(entityName) {
|
|
||||||
if (entityName === 'user') return 'users'
|
|
||||||
else if (entityName === 'session') return 'sessions'
|
|
||||||
else if (entityName === 'libraryItem') return 'libraryItems'
|
|
||||||
else if (entityName === 'library') return 'libraries'
|
|
||||||
else if (entityName === 'settings') return 'settings'
|
|
||||||
else if (entityName === 'collection') return 'collections'
|
|
||||||
else if (entityName === 'playlist') return 'playlists'
|
|
||||||
else if (entityName === 'author') return 'authors'
|
|
||||||
else if (entityName === 'series') return 'series'
|
|
||||||
else if (entityName === 'feed') return 'feeds'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
reinit() {
|
|
||||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, this.getNjodbOptions())
|
|
||||||
this.usersDb = new njodb.Database(this.UsersPath, this.getNjodbOptions())
|
|
||||||
this.sessionsDb = new njodb.Database(this.SessionsPath, this.getNjodbOptions())
|
|
||||||
this.librariesDb = new njodb.Database(this.LibrariesPath, this.getNjodbOptions())
|
|
||||||
this.settingsDb = new njodb.Database(this.SettingsPath, this.getNjodbOptions())
|
|
||||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, this.getNjodbOptions())
|
|
||||||
this.playlistsDb = new njodb.Database(this.PlaylistsPath, this.getNjodbOptions())
|
|
||||||
this.authorsDb = new njodb.Database(this.AuthorsPath, this.getNjodbOptions())
|
|
||||||
this.seriesDb = new njodb.Database(this.SeriesPath, this.getNjodbOptions())
|
|
||||||
this.feedsDb = new njodb.Database(this.FeedsPath, this.getNjodbOptions())
|
|
||||||
return this.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get previous server version before loading DB to check whether a db migration is required
|
|
||||||
// returns null if server was not upgraded
|
|
||||||
checkPreviousVersion() {
|
|
||||||
return this.settingsDb.select(() => true).then((results) => {
|
|
||||||
if (results.data && results.data.length) {
|
|
||||||
const serverSettings = results.data.find(s => s.id === 'server-settings')
|
|
||||||
if (serverSettings && serverSettings.version && serverSettings.version !== version) {
|
|
||||||
return serverSettings.version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createRootUser(username, pash, token) {
|
|
||||||
const newRoot = new User({
|
|
||||||
id: 'root',
|
|
||||||
type: 'root',
|
|
||||||
username,
|
|
||||||
pash,
|
|
||||||
token,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: Date.now()
|
|
||||||
})
|
|
||||||
return this.insertEntity('user', newRoot)
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
await this.load()
|
|
||||||
|
|
||||||
// Set file ownership for all files created by db
|
|
||||||
await filePerms.setDefault(global.ConfigPath, true)
|
|
||||||
|
|
||||||
if (!this.serverSettings) { // Create first load server settings
|
|
||||||
this.serverSettings = new ServerSettings()
|
|
||||||
await this.insertEntity('settings', this.serverSettings)
|
|
||||||
}
|
|
||||||
if (!this.notificationSettings) {
|
|
||||||
this.notificationSettings = new NotificationSettings()
|
|
||||||
await this.insertEntity('settings', this.notificationSettings)
|
|
||||||
}
|
|
||||||
if (!this.emailSettings) {
|
|
||||||
this.emailSettings = new EmailSettings()
|
|
||||||
await this.insertEntity('settings', this.emailSettings)
|
|
||||||
}
|
|
||||||
global.ServerSettings = this.serverSettings.toJSON()
|
|
||||||
}
|
|
||||||
|
|
||||||
async load() {
|
|
||||||
const p1 = this.libraryItemsDb.select(() => true).then((results) => {
|
|
||||||
this.libraryItems = results.data.map(a => new LibraryItem(a))
|
|
||||||
Logger.info(`[DB] ${this.libraryItems.length} Library Items Loaded`)
|
|
||||||
})
|
|
||||||
const p2 = this.usersDb.select(() => true).then((results) => {
|
|
||||||
this.users = results.data.map(u => new User(u))
|
|
||||||
Logger.info(`[DB] ${this.users.length} Users Loaded`)
|
|
||||||
})
|
|
||||||
const p3 = this.librariesDb.select(() => true).then((results) => {
|
|
||||||
this.libraries = results.data.map(l => new Library(l))
|
|
||||||
this.libraries.sort((a, b) => a.displayOrder - b.displayOrder)
|
|
||||||
Logger.info(`[DB] ${this.libraries.length} Libraries Loaded`)
|
|
||||||
})
|
|
||||||
const p4 = this.settingsDb.select(() => true).then(async (results) => {
|
|
||||||
if (results.data && results.data.length) {
|
|
||||||
this.settings = results.data
|
|
||||||
const serverSettings = this.settings.find(s => s.id === 'server-settings')
|
|
||||||
if (serverSettings) {
|
|
||||||
this.serverSettings = new ServerSettings(serverSettings)
|
|
||||||
|
|
||||||
// Check if server was upgraded
|
|
||||||
if (!this.serverSettings.version || this.serverSettings.version !== version) {
|
|
||||||
this.previousVersion = this.serverSettings.version || '1.0.0'
|
|
||||||
|
|
||||||
// Library settings and server settings updated in 2.1.3 - run migration
|
|
||||||
if (this.previousVersion.localeCompare('2.1.3') < 0) {
|
|
||||||
Logger.info(`[Db] Running servers & library settings migration`)
|
|
||||||
for (const library of this.libraries) {
|
|
||||||
if (library.settings.coverAspectRatio !== serverSettings.coverAspectRatio) {
|
|
||||||
library.settings.coverAspectRatio = serverSettings.coverAspectRatio
|
|
||||||
await this.updateEntity('library', library)
|
|
||||||
Logger.debug(`[Db] Library ${library.name} migrated`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notificationSettings = this.settings.find(s => s.id === 'notification-settings')
|
|
||||||
if (notificationSettings) {
|
|
||||||
this.notificationSettings = new NotificationSettings(notificationSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailSettings = this.settings.find(s => s.id === 'email-settings')
|
|
||||||
if (emailSettings) {
|
|
||||||
this.emailSettings = new EmailSettings(emailSettings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const p5 = this.collectionsDb.select(() => true).then((results) => {
|
|
||||||
this.collections = results.data.map(l => new Collection(l))
|
|
||||||
Logger.info(`[DB] ${this.collections.length} Collections Loaded`)
|
|
||||||
})
|
|
||||||
const p6 = this.playlistsDb.select(() => true).then((results) => {
|
|
||||||
this.playlists = results.data.map(l => new Playlist(l))
|
|
||||||
Logger.info(`[DB] ${this.playlists.length} Playlists Loaded`)
|
|
||||||
})
|
|
||||||
const p7 = this.authorsDb.select(() => true).then((results) => {
|
|
||||||
this.authors = results.data.map(l => new Author(l))
|
|
||||||
Logger.info(`[DB] ${this.authors.length} Authors Loaded`)
|
|
||||||
})
|
|
||||||
const p8 = this.seriesDb.select(() => true).then((results) => {
|
|
||||||
this.series = results.data.map(l => new Series(l))
|
|
||||||
Logger.info(`[DB] ${this.series.length} Series Loaded`)
|
|
||||||
})
|
|
||||||
await Promise.all([p1, p2, p3, p4, p5, p6, p7, p8])
|
|
||||||
|
|
||||||
// Update server version in server settings
|
|
||||||
if (this.previousVersion) {
|
|
||||||
this.serverSettings.version = version
|
|
||||||
await this.updateServerSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getLibraryItem(id) {
|
|
||||||
return this.libraryItems.find(li => li.id === id)
|
|
||||||
}
|
|
||||||
getLibraryItemsInLibrary(libraryId) {
|
|
||||||
return this.libraryItems.filter(li => li.libraryId === libraryId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateLibraryItem(libraryItem) {
|
|
||||||
return this.updateLibraryItems([libraryItem])
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateLibraryItems(libraryItems) {
|
|
||||||
await Promise.all(libraryItems.map(async (li) => {
|
|
||||||
if (li && li.saveMetadata) return li.saveMetadata()
|
|
||||||
return null
|
|
||||||
}))
|
|
||||||
|
|
||||||
const libraryItemIds = libraryItems.map(li => li.id)
|
|
||||||
return this.libraryItemsDb.update((record) => libraryItemIds.includes(record.id), (record) => {
|
|
||||||
return libraryItems.find(li => li.id === record.id)
|
|
||||||
}).then((results) => {
|
|
||||||
Logger.debug(`[DB] Library Items updated ${results.updated}`)
|
|
||||||
return true
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[DB] Library Items update failed ${error}`)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async insertLibraryItem(libraryItem) {
|
|
||||||
return this.insertLibraryItems([libraryItem])
|
|
||||||
}
|
|
||||||
|
|
||||||
async insertLibraryItems(libraryItems) {
|
|
||||||
await Promise.all(libraryItems.map(async (li) => {
|
|
||||||
if (li && li.saveMetadata) return li.saveMetadata()
|
|
||||||
return null
|
|
||||||
}))
|
|
||||||
|
|
||||||
return this.libraryItemsDb.insert(libraryItems).then((results) => {
|
|
||||||
Logger.debug(`[DB] Library Items inserted ${results.inserted}`)
|
|
||||||
this.libraryItems = this.libraryItems.concat(libraryItems)
|
|
||||||
return true
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[DB] Library Items insert failed ${error}`)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLibraryItem(id) {
|
|
||||||
return this.libraryItemsDb.delete((record) => record.id === id).then((results) => {
|
|
||||||
Logger.debug(`[DB] Deleted Library Items: ${results.deleted}`)
|
|
||||||
this.libraryItems = this.libraryItems.filter(li => li.id !== id)
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[DB] Remove Library Items Failed: ${error}`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
updateServerSettings() {
|
|
||||||
global.ServerSettings = this.serverSettings.toJSON()
|
|
||||||
return this.updateEntity('settings', this.serverSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllEntities(entityName) {
|
|
||||||
const entityDb = this.getEntityDb(entityName)
|
|
||||||
return entityDb.select(() => true).then((results) => results.data).catch((error) => {
|
|
||||||
Logger.error(`[DB] Failed to get all ${entityName}`, error)
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
insertEntities(entityName, entities) {
|
|
||||||
var entityDb = this.getEntityDb(entityName)
|
|
||||||
return entityDb.insert(entities).then((results) => {
|
|
||||||
Logger.debug(`[DB] Inserted ${results.inserted} ${entityName}`)
|
|
||||||
|
|
||||||
var arrayKey = this.getEntityArrayKey(entityName)
|
|
||||||
if (this[arrayKey]) this[arrayKey] = this[arrayKey].concat(entities)
|
|
||||||
return true
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[DB] Failed to insert ${entityName}`, error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
insertEntity(entityName, entity) {
|
|
||||||
var entityDb = this.getEntityDb(entityName)
|
|
||||||
return entityDb.insert([entity]).then((results) => {
|
|
||||||
Logger.debug(`[DB] Inserted ${results.inserted} ${entityName}`)
|
|
||||||
|
|
||||||
var arrayKey = this.getEntityArrayKey(entityName)
|
|
||||||
if (this[arrayKey]) this[arrayKey].push(entity)
|
|
||||||
return true
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[DB] Failed to insert ${entityName}`, error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async bulkInsertEntities(entityName, entities, batchSize = 500) {
|
|
||||||
// Group entities in batches of size batchSize
|
|
||||||
var entityBatches = []
|
|
||||||
var batch = []
|
|
||||||
var index = 0
|
|
||||||
entities.forEach((ent) => {
|
|
||||||
batch.push(ent)
|
|
||||||
index++
|
|
||||||
if (index >= batchSize) {
|
|
||||||
entityBatches.push(batch)
|
|
||||||
index = 0
|
|
||||||
batch = []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (batch.length) entityBatches.push(batch)
|
|
||||||
|
|
||||||
Logger.info(`[Db] bulkInsertEntities: ${entities.length} ${entityName} to ${entityBatches.length} batches of max size ${batchSize}`)
|
|
||||||
|
|
||||||
// Start inserting batches
|
|
||||||
var batchIndex = 1
|
|
||||||
for (const entityBatch of entityBatches) {
|
|
||||||
Logger.info(`[Db] bulkInsertEntities: Start inserting batch ${batchIndex} of ${entityBatch.length} for ${entityName}`)
|
|
||||||
var success = await this.insertEntities(entityName, entityBatch)
|
|
||||||
if (success) {
|
|
||||||
Logger.info(`[Db] bulkInsertEntities: Success inserting batch ${batchIndex} for ${entityName}`)
|
|
||||||
} else {
|
|
||||||
Logger.info(`[Db] bulkInsertEntities: Failed inserting batch ${batchIndex} for ${entityName}`)
|
|
||||||
}
|
|
||||||
batchIndex++
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
updateEntities(entityName, entities) {
|
|
||||||
var entityDb = this.getEntityDb(entityName)
|
|
||||||
|
|
||||||
var entityIds = entities.map(ent => ent.id)
|
|
||||||
return entityDb.update((record) => entityIds.includes(record.id), (record) => {
|
|
||||||
return entities.find(ent => ent.id === record.id)
|
|
||||||
}).then((results) => {
|
|
||||||
Logger.debug(`[DB] Updated ${entityName}: ${results.updated}`)
|
|
||||||
var arrayKey = this.getEntityArrayKey(entityName)
|
|
||||||
if (this[arrayKey]) {
|
|
||||||
this[arrayKey] = this[arrayKey].map(e => {
|
|
||||||
if (entityIds.includes(e.id)) return entities.find(_e => _e.id === e.id)
|
|
||||||
return e
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[DB] Update ${entityName} Failed: ${error}`)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
updateEntity(entityName, entity) {
|
|
||||||
const entityDb = this.getEntityDb(entityName)
|
|
||||||
|
|
||||||
let jsonEntity = entity
|
|
||||||
if (entity && entity.toJSON) {
|
|
||||||
jsonEntity = entity.toJSON()
|
|
||||||
}
|
|
||||||
|
|
||||||
return entityDb.update((record) => record.id === entity.id, () => jsonEntity).then((results) => {
|
|
||||||
Logger.debug(`[DB] Updated ${entityName}: ${results.updated}`)
|
|
||||||
const arrayKey = this.getEntityArrayKey(entityName)
|
|
||||||
if (this[arrayKey]) {
|
|
||||||
this[arrayKey] = this[arrayKey].map(e => {
|
|
||||||
return e.id === entity.id ? entity : e
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[DB] Update entity ${entityName} Failed: ${error}`)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
removeEntity(entityName, entityId) {
|
|
||||||
var entityDb = this.getEntityDb(entityName)
|
|
||||||
return entityDb.delete((record) => {
|
|
||||||
return record.id === entityId
|
|
||||||
}).then((results) => {
|
|
||||||
Logger.debug(`[DB] Deleted entity ${entityName}: ${results.deleted}`)
|
|
||||||
var arrayKey = this.getEntityArrayKey(entityName)
|
|
||||||
if (this[arrayKey]) {
|
|
||||||
this[arrayKey] = this[arrayKey].filter(e => {
|
|
||||||
return e.id !== entityId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[DB] Remove entity ${entityName} Failed: ${error}`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
removeEntities(entityName, selectFunc, silent = false) {
|
|
||||||
var entityDb = this.getEntityDb(entityName)
|
|
||||||
return entityDb.delete(selectFunc).then((results) => {
|
|
||||||
if (!silent) Logger.debug(`[DB] Deleted entities ${entityName}: ${results.deleted}`)
|
|
||||||
var arrayKey = this.getEntityArrayKey(entityName)
|
|
||||||
if (this[arrayKey]) {
|
|
||||||
this[arrayKey] = this[arrayKey].filter(e => {
|
|
||||||
return !selectFunc(e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return results.deleted
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[DB] Remove entities ${entityName} Failed: ${error}`)
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
recreateLibraryItemsDb() {
|
|
||||||
return this.libraryItemsDb.drop().then((results) => {
|
|
||||||
Logger.info(`[DB] Dropped library items db`, results)
|
|
||||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
|
|
||||||
this.libraryItems = []
|
|
||||||
return true
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[DB] Failed to drop library items db`, error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllSessions(selectFunc = () => true) {
|
|
||||||
return this.sessionsDb.select(selectFunc).then((results) => {
|
|
||||||
return results.data || []
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error('[Db] Failed to select sessions', error)
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getPlaybackSession(id) {
|
|
||||||
return this.sessionsDb.select((pb) => pb.id == id).then((results) => {
|
|
||||||
if (results.data.length) {
|
|
||||||
return new PlaybackSession(results.data[0])
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error('Failed to get session', error)
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
selectUserSessions(userId) {
|
|
||||||
return this.sessionsDb.select((session) => session.userId === userId).then((results) => {
|
|
||||||
return results.data || []
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[Db] Failed to select user sessions "${userId}"`, error)
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if server was updated and previous version was earlier than param
|
|
||||||
checkPreviousVersionIsBefore(version) {
|
|
||||||
if (!this.previousVersion) return false
|
|
||||||
// true if version > previousVersion
|
|
||||||
return version.localeCompare(this.previousVersion) >= 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = Db
|
|
||||||
+11
-1
@@ -3,7 +3,8 @@ const { LogLevel } = require('./utils/constants')
|
|||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
|
this.isDev = process.env.NODE_ENV !== 'production'
|
||||||
|
this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE
|
||||||
this.socketListeners = []
|
this.socketListeners = []
|
||||||
|
|
||||||
this.logManager = null
|
this.logManager = null
|
||||||
@@ -86,6 +87,15 @@ class Logger {
|
|||||||
this.debug(`Set Log Level to ${this.levelString}`)
|
this.debug(`Set Log Level to ${this.levelString}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only to console and only for development
|
||||||
|
* @param {...any} args
|
||||||
|
*/
|
||||||
|
dev(...args) {
|
||||||
|
if (!this.isDev) return
|
||||||
|
console.log(`[${this.timestamp}] DEV:`, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
trace(...args) {
|
trace(...args) {
|
||||||
if (this.logLevel > LogLevel.TRACE) return
|
if (this.logLevel > LogLevel.TRACE) return
|
||||||
console.trace(`[${this.timestamp}] TRACE:`, ...args)
|
console.trace(`[${this.timestamp}] TRACE:`, ...args)
|
||||||
|
|||||||
+61
-104
@@ -8,21 +8,20 @@ const rateLimit = require('./libs/expressRateLimit')
|
|||||||
const { version } = require('../package.json')
|
const { version } = require('../package.json')
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
const dbMigration = require('./utils/dbMigration')
|
|
||||||
const filePerms = require('./utils/filePerms')
|
const filePerms = require('./utils/filePerms')
|
||||||
const fileUtils = require('./utils/fileUtils')
|
const fileUtils = require('./utils/fileUtils')
|
||||||
const globals = require('./utils/globals')
|
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
const Auth = require('./Auth')
|
const Auth = require('./Auth')
|
||||||
const Watcher = require('./Watcher')
|
const Watcher = require('./Watcher')
|
||||||
const Scanner = require('./scanner/Scanner')
|
const Scanner = require('./scanner/Scanner')
|
||||||
const Db = require('./Db')
|
const Database = require('./Database')
|
||||||
const SocketAuthority = require('./SocketAuthority')
|
const SocketAuthority = require('./SocketAuthority')
|
||||||
|
|
||||||
|
const routes = require('./routes/index')
|
||||||
|
|
||||||
const ApiRouter = require('./routers/ApiRouter')
|
const ApiRouter = require('./routers/ApiRouter')
|
||||||
const HlsRouter = require('./routers/HlsRouter')
|
const HlsRouter = require('./routers/HlsRouter')
|
||||||
const StaticRouter = require('./routers/StaticRouter')
|
|
||||||
|
|
||||||
const NotificationManager = require('./managers/NotificationManager')
|
const NotificationManager = require('./managers/NotificationManager')
|
||||||
const EmailManager = require('./managers/EmailManager')
|
const EmailManager = require('./managers/EmailManager')
|
||||||
@@ -60,31 +59,29 @@ class Server {
|
|||||||
filePerms.setDefaultDirSync(global.MetadataPath, false)
|
filePerms.setDefaultDirSync(global.MetadataPath, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.db = new Db()
|
|
||||||
this.watcher = new Watcher()
|
this.watcher = new Watcher()
|
||||||
this.auth = new Auth(this.db)
|
this.auth = new Auth()
|
||||||
|
|
||||||
// Managers
|
// Managers
|
||||||
this.taskManager = new TaskManager()
|
this.taskManager = new TaskManager()
|
||||||
this.notificationManager = new NotificationManager(this.db)
|
this.notificationManager = new NotificationManager()
|
||||||
this.emailManager = new EmailManager(this.db)
|
this.emailManager = new EmailManager()
|
||||||
this.backupManager = new BackupManager(this.db)
|
this.backupManager = new BackupManager()
|
||||||
this.logManager = new LogManager(this.db)
|
this.logManager = new LogManager()
|
||||||
this.cacheManager = new CacheManager()
|
this.cacheManager = new CacheManager()
|
||||||
this.abMergeManager = new AbMergeManager(this.db, this.taskManager)
|
this.abMergeManager = new AbMergeManager(this.taskManager)
|
||||||
this.playbackSessionManager = new PlaybackSessionManager(this.db)
|
this.playbackSessionManager = new PlaybackSessionManager()
|
||||||
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
this.coverManager = new CoverManager(this.cacheManager)
|
||||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager, this.taskManager)
|
this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager)
|
||||||
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
|
this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager)
|
||||||
this.rssFeedManager = new RssFeedManager(this.db)
|
this.rssFeedManager = new RssFeedManager()
|
||||||
|
|
||||||
this.scanner = new Scanner(this.db, this.coverManager, this.taskManager)
|
this.scanner = new Scanner(this.coverManager, this.taskManager)
|
||||||
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
|
this.cronManager = new CronManager(this.scanner, this.podcastManager)
|
||||||
|
|
||||||
// Routers
|
// Routers
|
||||||
this.apiRouter = new ApiRouter(this)
|
this.apiRouter = new ApiRouter(this)
|
||||||
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager)
|
this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager)
|
||||||
this.staticRouter = new StaticRouter(this.db)
|
|
||||||
|
|
||||||
Logger.logManager = this.logManager
|
Logger.logManager = this.logManager
|
||||||
|
|
||||||
@@ -100,38 +97,28 @@ class Server {
|
|||||||
Logger.info('[Server] Init v' + version)
|
Logger.info('[Server] Init v' + version)
|
||||||
await this.playbackSessionManager.removeOrphanStreams()
|
await this.playbackSessionManager.removeOrphanStreams()
|
||||||
|
|
||||||
const previousVersion = await this.db.checkPreviousVersion() // Returns null if same server version
|
await Database.init(false)
|
||||||
if (previousVersion) {
|
|
||||||
Logger.debug(`[Server] Upgraded from previous version ${previousVersion}`)
|
|
||||||
}
|
|
||||||
if (previousVersion && previousVersion.localeCompare('2.0.0') < 0) { // Old version data model migration
|
|
||||||
Logger.debug(`[Server] Previous version was < 2.0.0 - migration required`)
|
|
||||||
await dbMigration.migrate(this.db)
|
|
||||||
} else {
|
|
||||||
await this.db.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create token secret if does not exist (Added v2.1.0)
|
// Create token secret if does not exist (Added v2.1.0)
|
||||||
if (!this.db.serverSettings.tokenSecret) {
|
if (!Database.serverSettings.tokenSecret) {
|
||||||
await this.auth.initTokenSecret()
|
await this.auth.initTokenSecret()
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.cleanUserData() // Remove invalid user item progress
|
await this.cleanUserData() // Remove invalid user item progress
|
||||||
await this.purgeMetadata() // Remove metadata folders without library item
|
await this.purgeMetadata() // Remove metadata folders without library item
|
||||||
await this.playbackSessionManager.removeInvalidSessions()
|
|
||||||
await this.cacheManager.ensureCachePaths()
|
await this.cacheManager.ensureCachePaths()
|
||||||
|
|
||||||
await this.backupManager.init()
|
await this.backupManager.init()
|
||||||
await this.logManager.init()
|
await this.logManager.init()
|
||||||
await this.apiRouter.checkRemoveEmptySeries(this.db.series) // Remove empty series
|
await this.apiRouter.checkRemoveEmptySeries(Database.series) // Remove empty series
|
||||||
await this.rssFeedManager.init()
|
await this.rssFeedManager.init()
|
||||||
this.cronManager.init()
|
this.cronManager.init()
|
||||||
|
|
||||||
if (this.db.serverSettings.scannerDisableWatcher) {
|
if (Database.serverSettings.scannerDisableWatcher) {
|
||||||
Logger.info(`[Server] Watcher is disabled`)
|
Logger.info(`[Server] Watcher is disabled`)
|
||||||
this.watcher.disabled = true
|
this.watcher.disabled = true
|
||||||
} else {
|
} else {
|
||||||
this.watcher.initWatcher(this.db.libraries)
|
this.watcher.initWatcher(Database.libraries)
|
||||||
this.watcher.on('files', this.filesChanged.bind(this))
|
this.watcher.on('files', this.filesChanged.bind(this))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,57 +148,23 @@ class Server {
|
|||||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||||
router.use(express.static(distPath))
|
router.use(express.static(distPath))
|
||||||
|
|
||||||
// Metadata folder static path
|
|
||||||
router.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
|
|
||||||
|
|
||||||
// Static folder
|
// Static folder
|
||||||
router.use(express.static(Path.join(global.appRoot, 'static')))
|
router.use(express.static(Path.join(global.appRoot, 'static')))
|
||||||
|
|
||||||
|
// router.use('/api/v1', routes) // TODO: New routes
|
||||||
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
|
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
|
||||||
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
|
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
|
||||||
|
|
||||||
// TODO: Deprecated as of 2.2.21 edge
|
|
||||||
router.use('/s', this.authMiddleware.bind(this), this.staticRouter.router)
|
|
||||||
|
|
||||||
// EBook static file routes
|
|
||||||
// TODO: Deprecated as of 2.2.21 edge
|
|
||||||
router.get('/ebook/:library/:folder/*', (req, res) => {
|
|
||||||
const library = this.db.libraries.find(lib => lib.id === req.params.library)
|
|
||||||
if (!library) return res.sendStatus(404)
|
|
||||||
const folder = library.folders.find(fol => fol.id === req.params.folder)
|
|
||||||
if (!folder) return res.status(404).send('Folder not found')
|
|
||||||
|
|
||||||
// Replace backslashes with forward slashes
|
|
||||||
const remainingPath = req.params['0'].replace(/\\/g, '/')
|
|
||||||
|
|
||||||
// Prevent path traversal
|
|
||||||
// e.g. ../../etc/passwd
|
|
||||||
if (/\/?\.?\.\//.test(remainingPath)) {
|
|
||||||
Logger.error(`[Server] Invalid path to get ebook "${remainingPath}"`)
|
|
||||||
return res.sendStatus(403)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file ext is a valid ebook file
|
|
||||||
const filext = (Path.extname(remainingPath) || '').slice(1).toLowerCase()
|
|
||||||
if (!globals.SupportedEbookTypes.includes(filext)) {
|
|
||||||
Logger.error(`[Server] Invalid ebook file ext requested "${remainingPath}"`)
|
|
||||||
return res.sendStatus(403)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullPath = Path.join(folder.fullPath, remainingPath)
|
|
||||||
res.sendFile(fullPath)
|
|
||||||
})
|
|
||||||
|
|
||||||
// RSS Feed temp route
|
// RSS Feed temp route
|
||||||
router.get('/feed/:id', (req, res) => {
|
router.get('/feed/:slug', (req, res) => {
|
||||||
Logger.info(`[Server] Requesting rss feed ${req.params.id}`)
|
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
|
||||||
this.rssFeedManager.getFeed(req, res)
|
this.rssFeedManager.getFeed(req, res)
|
||||||
})
|
})
|
||||||
router.get('/feed/:id/cover', (req, res) => {
|
router.get('/feed/:slug/cover', (req, res) => {
|
||||||
this.rssFeedManager.getFeedCover(req, res)
|
this.rssFeedManager.getFeedCover(req, res)
|
||||||
})
|
})
|
||||||
router.get('/feed/:id/item/:episodeId/*', (req, res) => {
|
router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
|
||||||
Logger.debug(`[Server] Requesting rss feed episode ${req.params.id}/${req.params.episodeId}`)
|
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
|
||||||
this.rssFeedManager.getFeedItem(req, res)
|
this.rssFeedManager.getFeedItem(req, res)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -240,7 +193,7 @@ class Server {
|
|||||||
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
|
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
|
||||||
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||||
router.post('/init', (req, res) => {
|
router.post('/init', (req, res) => {
|
||||||
if (this.db.hasRootUser) {
|
if (Database.hasRootUser) {
|
||||||
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
@@ -250,8 +203,8 @@ class Server {
|
|||||||
// status check for client to see if server has been initialized
|
// status check for client to see if server has been initialized
|
||||||
// server has been initialized if a root user exists
|
// server has been initialized if a root user exists
|
||||||
const payload = {
|
const payload = {
|
||||||
isInit: this.db.hasRootUser,
|
isInit: Database.hasRootUser,
|
||||||
language: this.db.serverSettings.language
|
language: Database.serverSettings.language
|
||||||
}
|
}
|
||||||
if (!payload.isInit) {
|
if (!payload.isInit) {
|
||||||
payload.ConfigPath = global.ConfigPath
|
payload.ConfigPath = global.ConfigPath
|
||||||
@@ -277,10 +230,10 @@ class Server {
|
|||||||
async initializeServer(req, res) {
|
async initializeServer(req, res) {
|
||||||
Logger.info(`[Server] Initializing new server`)
|
Logger.info(`[Server] Initializing new server`)
|
||||||
const newRoot = req.body.newRoot
|
const newRoot = req.body.newRoot
|
||||||
let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
const rootUsername = newRoot.username || 'root'
|
||||||
|
const rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
||||||
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
||||||
let rootToken = await this.auth.generateAccessToken({ userId: 'root', username: newRoot.username })
|
await Database.createRootUser(rootUsername, rootPash, this.auth)
|
||||||
await this.db.createRootUser(newRoot.username, rootPash, rootToken)
|
|
||||||
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
@@ -298,15 +251,19 @@ class Server {
|
|||||||
|
|
||||||
let purged = 0
|
let purged = 0
|
||||||
await Promise.all(foldersInItemsMetadata.map(async foldername => {
|
await Promise.all(foldersInItemsMetadata.map(async foldername => {
|
||||||
const hasMatchingItem = this.db.libraryItems.find(ab => ab.id === foldername)
|
const itemFullPath = fileUtils.filePathToPOSIX(Path.join(itemsMetadata, foldername))
|
||||||
if (!hasMatchingItem) {
|
|
||||||
const folderPath = Path.join(itemsMetadata, foldername)
|
|
||||||
Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
|
|
||||||
|
|
||||||
await fs.remove(folderPath).then(() => {
|
const hasMatchingItem = Database.libraryItems.find(li => {
|
||||||
|
if (!li.media.coverPath) return false
|
||||||
|
return itemFullPath === fileUtils.filePathToPOSIX(Path.dirname(li.media.coverPath))
|
||||||
|
})
|
||||||
|
if (!hasMatchingItem) {
|
||||||
|
Logger.debug(`[Server] Purging unused metadata ${itemFullPath}`)
|
||||||
|
|
||||||
|
await fs.remove(itemFullPath).then(() => {
|
||||||
purged++
|
purged++
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
Logger.error(`[Server] Failed to delete folder path ${folderPath}`, err)
|
Logger.error(`[Server] Failed to delete folder path ${itemFullPath}`, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -318,26 +275,26 @@ class Server {
|
|||||||
|
|
||||||
// Remove user media progress with items that no longer exist & remove seriesHideFrom that no longer exist
|
// Remove user media progress with items that no longer exist & remove seriesHideFrom that no longer exist
|
||||||
async cleanUserData() {
|
async cleanUserData() {
|
||||||
for (let i = 0; i < this.db.users.length; i++) {
|
for (const _user of Database.users) {
|
||||||
const _user = this.db.users[i]
|
|
||||||
let hasUpdated = false
|
|
||||||
if (_user.mediaProgress.length) {
|
if (_user.mediaProgress.length) {
|
||||||
const lengthBefore = _user.mediaProgress.length
|
for (const mediaProgress of _user.mediaProgress) {
|
||||||
_user.mediaProgress = _user.mediaProgress.filter(mp => {
|
const libraryItem = Database.libraryItems.find(li => li.id === mediaProgress.libraryItemId)
|
||||||
const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId)
|
if (libraryItem && mediaProgress.episodeId) {
|
||||||
if (!libraryItem) return false
|
const episode = libraryItem.media.checkHasEpisode?.(mediaProgress.episodeId)
|
||||||
if (mp.episodeId && (libraryItem.mediaType !== 'podcast' || !libraryItem.media.checkHasEpisode(mp.episodeId))) return false // Episode not found
|
if (episode) continue
|
||||||
return true
|
} else {
|
||||||
})
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (lengthBefore > _user.mediaProgress.length) {
|
Logger.debug(`[Server] Removing media progress ${mediaProgress.id} data from user ${_user.username}`)
|
||||||
Logger.debug(`[Server] Removing ${_user.mediaProgress.length - lengthBefore} media progress data from user ${_user.username}`)
|
await Database.removeMediaProgress(mediaProgress.id)
|
||||||
hasUpdated = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hasUpdated = false
|
||||||
if (_user.seriesHideFromContinueListening.length) {
|
if (_user.seriesHideFromContinueListening.length) {
|
||||||
_user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => {
|
_user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => {
|
||||||
if (!this.db.series.some(se => se.id === seriesId)) { // Series removed
|
if (!Database.series.some(se => se.id === seriesId)) { // Series removed
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -345,7 +302,7 @@ class Server {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
await this.db.updateEntity('user', _user)
|
await Database.updateUser(_user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,8 +315,8 @@ class Server {
|
|||||||
|
|
||||||
getLoginRateLimiter() {
|
getLoginRateLimiter() {
|
||||||
return rateLimit({
|
return rateLimit({
|
||||||
windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
|
windowMs: Database.serverSettings.rateLimitLoginWindow, // 5 minutes
|
||||||
max: this.db.serverSettings.rateLimitLoginRequests,
|
max: Database.serverSettings.rateLimitLoginRequests,
|
||||||
skipSuccessfulRequests: true,
|
skipSuccessfulRequests: true,
|
||||||
onLimitReached: this.loginLimitReached
|
onLimitReached: this.loginLimitReached
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const SocketIO = require('socket.io')
|
const SocketIO = require('socket.io')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
const Database = require('./Database')
|
||||||
|
|
||||||
class SocketAuthority {
|
class SocketAuthority {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -18,7 +19,7 @@ class SocketAuthority {
|
|||||||
onlineUsersMap[client.user.id].connections++
|
onlineUsersMap[client.user.id].connections++
|
||||||
} else {
|
} else {
|
||||||
onlineUsersMap[client.user.id] = {
|
onlineUsersMap[client.user.id] = {
|
||||||
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems),
|
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems),
|
||||||
connections: 1
|
connections: 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +108,7 @@ class SocketAuthority {
|
|||||||
delete this.clients[socket.id]
|
delete this.clients[socket.id]
|
||||||
} else {
|
} else {
|
||||||
Logger.debug('[Server] User Offline ' + _client.user.username)
|
Logger.debug('[Server] User Offline ' + _client.user.username)
|
||||||
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems))
|
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems))
|
||||||
|
|
||||||
const disconnectTime = Date.now() - _client.connected_at
|
const disconnectTime = Date.now() - _client.connected_at
|
||||||
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
||||||
@@ -160,11 +161,11 @@ class SocketAuthority {
|
|||||||
|
|
||||||
Logger.debug(`[Server] User Online ${client.user.username}`)
|
Logger.debug(`[Server] User Online ${client.user.username}`)
|
||||||
|
|
||||||
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems))
|
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems))
|
||||||
|
|
||||||
// Update user lastSeen
|
// Update user lastSeen
|
||||||
user.lastSeen = Date.now()
|
user.lastSeen = Date.now()
|
||||||
await this.Server.db.updateEntity('user', user)
|
await Database.updateUser(user)
|
||||||
|
|
||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
userId: client.user.id,
|
userId: client.user.id,
|
||||||
@@ -186,7 +187,7 @@ class SocketAuthority {
|
|||||||
|
|
||||||
if (client.user) {
|
if (client.user) {
|
||||||
Logger.debug('[Server] User Offline ' + client.user.username)
|
Logger.debug('[Server] User Offline ' + client.user.username)
|
||||||
this.adminEmitter('user_offline', client.user.toJSONForPublic(null, this.Server.db.libraryItems))
|
this.adminEmitter('user_offline', client.user.toJSONForPublic(null, Database.libraryItems))
|
||||||
}
|
}
|
||||||
|
|
||||||
delete this.clients[socketId].user
|
delete this.clients[socketId].user
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { createNewSortInstance } = require('../libs/fastSort')
|
|||||||
|
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
const { reqSupportsWebp } = require('../utils/index')
|
const { reqSupportsWebp } = require('../utils/index')
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ class AuthorController {
|
|||||||
|
|
||||||
// Used on author landing page to include library items and items grouped in series
|
// Used on author landing page to include library items and items grouped in series
|
||||||
if (include.includes('items')) {
|
if (include.includes('items')) {
|
||||||
authorJson.libraryItems = this.db.libraryItems.filter(li => {
|
authorJson.libraryItems = Database.libraryItems.filter(li => {
|
||||||
if (libraryId && li.libraryId !== libraryId) return false
|
if (libraryId && li.libraryId !== libraryId) return false
|
||||||
if (!req.user.checkCanAccessLibraryItem(li)) return false // filter out library items user cannot access
|
if (!req.user.checkCanAccessLibraryItem(li)) return false // filter out library items user cannot access
|
||||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||||
@@ -97,23 +98,29 @@ class AuthorController {
|
|||||||
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
||||||
|
|
||||||
// Check if author name matches another author and merge the authors
|
// Check if author name matches another author and merge the authors
|
||||||
const existingAuthor = authorNameUpdate ? this.db.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
|
const existingAuthor = authorNameUpdate ? Database.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
|
||||||
if (existingAuthor) {
|
if (existingAuthor) {
|
||||||
const itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
const bookAuthorsToCreate = []
|
||||||
|
const itemsWithAuthor = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
||||||
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
|
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
|
||||||
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
|
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
|
||||||
|
bookAuthorsToCreate.push({
|
||||||
|
bookId: libraryItem.media.id,
|
||||||
|
authorId: existingAuthor.id
|
||||||
|
})
|
||||||
})
|
})
|
||||||
if (itemsWithAuthor.length) {
|
if (itemsWithAuthor.length) {
|
||||||
await this.db.updateLibraryItems(itemsWithAuthor)
|
await Database.removeBulkBookAuthors(req.author.id) // Remove all old BookAuthor
|
||||||
|
await Database.createBulkBookAuthors(bookAuthorsToCreate) // Create all new BookAuthor
|
||||||
SocketAuthority.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
|
SocketAuthority.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove old author
|
// Remove old author
|
||||||
await this.db.removeEntity('author', req.author.id)
|
await Database.removeAuthor(req.author.id)
|
||||||
SocketAuthority.emitter('author_removed', req.author.toJSON())
|
SocketAuthority.emitter('author_removed', req.author.toJSON())
|
||||||
|
|
||||||
// Send updated num books for merged author
|
// Send updated num books for merged author
|
||||||
const numBooks = this.db.libraryItems.filter(li => {
|
const numBooks = Database.libraryItems.filter(li => {
|
||||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(existingAuthor.id)
|
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(existingAuthor.id)
|
||||||
}).length
|
}).length
|
||||||
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
|
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
|
||||||
@@ -131,18 +138,17 @@ class AuthorController {
|
|||||||
req.author.updatedAt = Date.now()
|
req.author.updatedAt = Date.now()
|
||||||
|
|
||||||
if (authorNameUpdate) { // Update author name on all books
|
if (authorNameUpdate) { // Update author name on all books
|
||||||
const itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
const itemsWithAuthor = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
||||||
itemsWithAuthor.forEach(libraryItem => {
|
itemsWithAuthor.forEach(libraryItem => {
|
||||||
libraryItem.media.metadata.updateAuthor(req.author)
|
libraryItem.media.metadata.updateAuthor(req.author)
|
||||||
})
|
})
|
||||||
if (itemsWithAuthor.length) {
|
if (itemsWithAuthor.length) {
|
||||||
await this.db.updateLibraryItems(itemsWithAuthor)
|
|
||||||
SocketAuthority.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
|
SocketAuthority.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.updateEntity('author', req.author)
|
await Database.updateAuthor(req.author)
|
||||||
const numBooks = this.db.libraryItems.filter(li => {
|
const numBooks = Database.libraryItems.filter(li => {
|
||||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||||
}).length
|
}).length
|
||||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||||
@@ -159,7 +165,7 @@ class AuthorController {
|
|||||||
var q = (req.query.q || '').toLowerCase()
|
var q = (req.query.q || '').toLowerCase()
|
||||||
if (!q) return res.json([])
|
if (!q) return res.json([])
|
||||||
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
|
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
|
||||||
var authors = this.db.authors.filter(au => au.name.toLowerCase().includes(q))
|
var authors = Database.authors.filter(au => au.name.toLowerCase().includes(q))
|
||||||
authors = authors.slice(0, limit)
|
authors = authors.slice(0, limit)
|
||||||
res.json({
|
res.json({
|
||||||
results: authors
|
results: authors
|
||||||
@@ -204,8 +210,8 @@ class AuthorController {
|
|||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
req.author.updatedAt = Date.now()
|
req.author.updatedAt = Date.now()
|
||||||
|
|
||||||
await this.db.updateEntity('author', req.author)
|
await Database.updateAuthor(req.author)
|
||||||
const numBooks = this.db.libraryItems.filter(li => {
|
const numBooks = Database.libraryItems.filter(li => {
|
||||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||||
}).length
|
}).length
|
||||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||||
@@ -238,7 +244,7 @@ class AuthorController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
var author = this.db.authors.find(au => au.id === req.params.id)
|
const author = Database.authors.find(au => au.id === req.params.id)
|
||||||
if (!author) return res.sendStatus(404)
|
if (!author) return res.sendStatus(404)
|
||||||
|
|
||||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||||
|
|||||||
@@ -14,18 +14,14 @@ class BackupController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(req, res) {
|
async delete(req, res) {
|
||||||
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
|
await this.backupManager.removeBackup(req.backup)
|
||||||
if (!backup) {
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
await this.backupManager.removeBackup(backup)
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
backups: this.backupManager.backups.map(b => b.toJSON())
|
backups: this.backupManager.backups.map(b => b.toJSON())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload(req, res) {
|
upload(req, res) {
|
||||||
if (!req.files.file) {
|
if (!req.files.file) {
|
||||||
Logger.error('[BackupController] Upload backup invalid')
|
Logger.error('[BackupController] Upload backup invalid')
|
||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
@@ -33,13 +29,22 @@ class BackupController {
|
|||||||
this.backupManager.uploadBackup(req, res)
|
this.backupManager.uploadBackup(req, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
async apply(req, res) {
|
/**
|
||||||
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
|
* api/backups/:id/download
|
||||||
if (!backup) {
|
*
|
||||||
return res.sendStatus(404)
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
|
download(req, res) {
|
||||||
|
if (global.XAccel) {
|
||||||
|
Logger.debug(`Use X-Accel to serve static file ${req.backup.fullPath}`)
|
||||||
|
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + req.backup.fullPath }).send()
|
||||||
}
|
}
|
||||||
await this.backupManager.requestApplyBackup(backup)
|
res.sendFile(req.backup.fullPath)
|
||||||
res.sendStatus(200)
|
}
|
||||||
|
|
||||||
|
apply(req, res) {
|
||||||
|
this.backupManager.requestApplyBackup(req.backup, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
@@ -47,6 +52,14 @@ class BackupController {
|
|||||||
Logger.error(`[BackupController] Non-admin user attempting to access backups`, req.user)
|
Logger.error(`[BackupController] Non-admin user attempting to access backups`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.params.id) {
|
||||||
|
req.backup = this.backupManager.backups.find(b => b.id === req.params.id)
|
||||||
|
if (!req.backup) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ class CacheController {
|
|||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
Logger.info(`[MiscController] Purging all cache`)
|
|
||||||
await this.cacheManager.purgeAll()
|
await this.cacheManager.purgeAll()
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
@@ -18,7 +17,6 @@ class CacheController {
|
|||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
Logger.info(`[MiscController] Purging items cache`)
|
|
||||||
await this.cacheManager.purgeItems()
|
await this.cacheManager.purgeItems()
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
const Collection = require('../objects/Collection')
|
const Collection = require('../objects/Collection')
|
||||||
|
|
||||||
@@ -13,22 +14,22 @@ class CollectionController {
|
|||||||
if (!success) {
|
if (!success) {
|
||||||
return res.status(500).send('Invalid collection data')
|
return res.status(500).send('Invalid collection data')
|
||||||
}
|
}
|
||||||
var jsonExpanded = newCollection.toJSONExpanded(this.db.libraryItems)
|
var jsonExpanded = newCollection.toJSONExpanded(Database.libraryItems)
|
||||||
await this.db.insertEntity('collection', newCollection)
|
await Database.createCollection(newCollection)
|
||||||
SocketAuthority.emitter('collection_added', jsonExpanded)
|
SocketAuthority.emitter('collection_added', jsonExpanded)
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
findAll(req, res) {
|
findAll(req, res) {
|
||||||
res.json({
|
res.json({
|
||||||
collections: this.db.collections.map(c => c.toJSONExpanded(this.db.libraryItems))
|
collections: Database.collections.map(c => c.toJSONExpanded(Database.libraryItems))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
findOne(req, res) {
|
findOne(req, res) {
|
||||||
const includeEntities = (req.query.include || '').split(',')
|
const includeEntities = (req.query.include || '').split(',')
|
||||||
|
|
||||||
const collectionExpanded = req.collection.toJSONExpanded(this.db.libraryItems)
|
const collectionExpanded = req.collection.toJSONExpanded(Database.libraryItems)
|
||||||
|
|
||||||
if (includeEntities.includes('rssfeed')) {
|
if (includeEntities.includes('rssfeed')) {
|
||||||
const feedData = this.rssFeedManager.findFeedForEntityId(collectionExpanded.id)
|
const feedData = this.rssFeedManager.findFeedForEntityId(collectionExpanded.id)
|
||||||
@@ -41,9 +42,9 @@ class CollectionController {
|
|||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
const collection = req.collection
|
const collection = req.collection
|
||||||
const wasUpdated = collection.update(req.body)
|
const wasUpdated = collection.update(req.body)
|
||||||
const jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
await this.db.updateEntity('collection', collection)
|
await Database.updateCollection(collection)
|
||||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||||
}
|
}
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
@@ -51,19 +52,19 @@ class CollectionController {
|
|||||||
|
|
||||||
async delete(req, res) {
|
async delete(req, res) {
|
||||||
const collection = req.collection
|
const collection = req.collection
|
||||||
const jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||||
|
|
||||||
// Close rss feed - remove from db and emit socket event
|
// Close rss feed - remove from db and emit socket event
|
||||||
await this.rssFeedManager.closeFeedForEntityId(collection.id)
|
await this.rssFeedManager.closeFeedForEntityId(collection.id)
|
||||||
|
|
||||||
await this.db.removeEntity('collection', collection.id)
|
await Database.removeCollection(collection.id)
|
||||||
SocketAuthority.emitter('collection_removed', jsonExpanded)
|
SocketAuthority.emitter('collection_removed', jsonExpanded)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
async addBook(req, res) {
|
async addBook(req, res) {
|
||||||
const collection = req.collection
|
const collection = req.collection
|
||||||
const libraryItem = this.db.libraryItems.find(li => li.id === req.body.id)
|
const libraryItem = Database.libraryItems.find(li => li.id === req.body.id)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(500).send('Book not found')
|
return res.status(500).send('Book not found')
|
||||||
}
|
}
|
||||||
@@ -74,8 +75,14 @@ class CollectionController {
|
|||||||
return res.status(500).send('Book already in collection')
|
return res.status(500).send('Book already in collection')
|
||||||
}
|
}
|
||||||
collection.addBook(req.body.id)
|
collection.addBook(req.body.id)
|
||||||
const jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||||
await this.db.updateEntity('collection', collection)
|
|
||||||
|
const collectionBook = {
|
||||||
|
collectionId: collection.id,
|
||||||
|
bookId: libraryItem.media.id,
|
||||||
|
order: collection.books.length
|
||||||
|
}
|
||||||
|
await Database.createCollectionBook(collectionBook)
|
||||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
@@ -83,13 +90,18 @@ class CollectionController {
|
|||||||
// DELETE: api/collections/:id/book/:bookId
|
// DELETE: api/collections/:id/book/:bookId
|
||||||
async removeBook(req, res) {
|
async removeBook(req, res) {
|
||||||
const collection = req.collection
|
const collection = req.collection
|
||||||
|
const libraryItem = Database.libraryItems.find(li => li.id === req.params.bookId)
|
||||||
|
if (!libraryItem) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
if (collection.books.includes(req.params.bookId)) {
|
if (collection.books.includes(req.params.bookId)) {
|
||||||
collection.removeBook(req.params.bookId)
|
collection.removeBook(req.params.bookId)
|
||||||
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||||
await this.db.updateEntity('collection', collection)
|
|
||||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||||
|
await Database.updateCollection(collection)
|
||||||
}
|
}
|
||||||
res.json(collection.toJSONExpanded(this.db.libraryItems))
|
res.json(collection.toJSONExpanded(Database.libraryItems))
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/collections/:id/batch/add
|
// POST: api/collections/:id/batch/add
|
||||||
@@ -98,19 +110,30 @@ class CollectionController {
|
|||||||
if (!req.body.books || !req.body.books.length) {
|
if (!req.body.books || !req.body.books.length) {
|
||||||
return res.status(500).send('Invalid request body')
|
return res.status(500).send('Invalid request body')
|
||||||
}
|
}
|
||||||
var bookIdsToAdd = req.body.books
|
const bookIdsToAdd = req.body.books
|
||||||
var hasUpdated = false
|
const collectionBooksToAdd = []
|
||||||
for (let i = 0; i < bookIdsToAdd.length; i++) {
|
let hasUpdated = false
|
||||||
if (!collection.books.includes(bookIdsToAdd[i])) {
|
|
||||||
collection.addBook(bookIdsToAdd[i])
|
let order = collection.books.length
|
||||||
|
for (const libraryItemId of bookIdsToAdd) {
|
||||||
|
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
|
||||||
|
if (!libraryItem) continue
|
||||||
|
if (!collection.books.includes(libraryItemId)) {
|
||||||
|
collection.addBook(libraryItemId)
|
||||||
|
collectionBooksToAdd.push({
|
||||||
|
collectionId: collection.id,
|
||||||
|
bookId: libraryItem.media.id,
|
||||||
|
order: order++
|
||||||
|
})
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
await this.db.updateEntity('collection', collection)
|
await Database.createBulkCollectionBooks(collectionBooksToAdd)
|
||||||
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems))
|
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
|
||||||
}
|
}
|
||||||
res.json(collection.toJSONExpanded(this.db.libraryItems))
|
res.json(collection.toJSONExpanded(Database.libraryItems))
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/collections/:id/batch/remove
|
// POST: api/collections/:id/batch/remove
|
||||||
@@ -120,23 +143,26 @@ class CollectionController {
|
|||||||
return res.status(500).send('Invalid request body')
|
return res.status(500).send('Invalid request body')
|
||||||
}
|
}
|
||||||
var bookIdsToRemove = req.body.books
|
var bookIdsToRemove = req.body.books
|
||||||
var hasUpdated = false
|
let hasUpdated = false
|
||||||
for (let i = 0; i < bookIdsToRemove.length; i++) {
|
for (const libraryItemId of bookIdsToRemove) {
|
||||||
if (collection.books.includes(bookIdsToRemove[i])) {
|
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
|
||||||
collection.removeBook(bookIdsToRemove[i])
|
if (!libraryItem) continue
|
||||||
|
|
||||||
|
if (collection.books.includes(libraryItemId)) {
|
||||||
|
collection.removeBook(libraryItemId)
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
await this.db.updateEntity('collection', collection)
|
await Database.updateCollection(collection)
|
||||||
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems))
|
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
|
||||||
}
|
}
|
||||||
res.json(collection.toJSONExpanded(this.db.libraryItems))
|
res.json(collection.toJSONExpanded(Database.libraryItems))
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
if (req.params.id) {
|
if (req.params.id) {
|
||||||
const collection = this.db.collections.find(c => c.id === req.params.id)
|
const collection = Database.collections.find(c => c.id === req.params.id)
|
||||||
if (!collection) {
|
if (!collection) {
|
||||||
return res.status(404).send('Collection not found')
|
return res.status(404).send('Collection not found')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
class EmailController {
|
class EmailController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
getSettings(req, res) {
|
getSettings(req, res) {
|
||||||
res.json({
|
res.json({
|
||||||
settings: this.db.emailSettings
|
settings: Database.emailSettings
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSettings(req, res) {
|
async updateSettings(req, res) {
|
||||||
const updated = this.db.emailSettings.update(req.body)
|
const updated = Database.emailSettings.update(req.body)
|
||||||
if (updated) {
|
if (updated) {
|
||||||
await this.db.updateEntity('settings', this.db.emailSettings)
|
await Database.updateSetting(Database.emailSettings)
|
||||||
}
|
}
|
||||||
res.json({
|
res.json({
|
||||||
settings: this.db.emailSettings
|
settings: Database.emailSettings
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,24 +37,24 @@ class EmailController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = this.db.emailSettings.update({
|
const updated = Database.emailSettings.update({
|
||||||
ereaderDevices
|
ereaderDevices
|
||||||
})
|
})
|
||||||
if (updated) {
|
if (updated) {
|
||||||
await this.db.updateEntity('settings', this.db.emailSettings)
|
await Database.updateSetting(Database.emailSettings)
|
||||||
SocketAuthority.adminEmitter('ereader-devices-updated', {
|
SocketAuthority.adminEmitter('ereader-devices-updated', {
|
||||||
ereaderDevices: this.db.emailSettings.ereaderDevices
|
ereaderDevices: Database.emailSettings.ereaderDevices
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
res.json({
|
res.json({
|
||||||
ereaderDevices: this.db.emailSettings.ereaderDevices
|
ereaderDevices: Database.emailSettings.ereaderDevices
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendEBookToDevice(req, res) {
|
async sendEBookToDevice(req, res) {
|
||||||
Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
|
Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
|
||||||
|
|
||||||
const libraryItem = this.db.getLibraryItem(req.body.libraryItemId)
|
const libraryItem = Database.getLibraryItem(req.body.libraryItemId)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(404).send('Library item not found')
|
return res.status(404).send('Library item not found')
|
||||||
}
|
}
|
||||||
@@ -67,7 +68,7 @@ class EmailController {
|
|||||||
return res.status(404).send('EBook file not found')
|
return res.status(404).send('EBook file not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
const device = this.db.emailSettings.getEReaderDevice(req.body.deviceName)
|
const device = Database.emailSettings.getEReaderDevice(req.body.deviceName)
|
||||||
if (!device) {
|
if (!device) {
|
||||||
return res.status(404).send('E-reader device not found')
|
return res.status(404).send('E-reader device not found')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const Database = require('../Database')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
|
||||||
class FileSystemController {
|
class FileSystemController {
|
||||||
@@ -16,7 +17,7 @@ class FileSystemController {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Do not include existing mapped library paths in response
|
// Do not include existing mapped library paths in response
|
||||||
this.db.libraries.forEach(lib => {
|
Database.libraries.forEach(lib => {
|
||||||
lib.folders.forEach((folder) => {
|
lib.folders.forEach((folder) => {
|
||||||
let dir = folder.fullPath
|
let dir = folder.fullPath
|
||||||
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
|
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const { sort, createNewSortInstance } = require('../libs/fastSort')
|
|||||||
const naturalSort = createNewSortInstance({
|
const naturalSort = createNewSortInstance({
|
||||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
class LibraryController {
|
class LibraryController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
@@ -40,13 +43,14 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const library = new Library()
|
const library = new Library()
|
||||||
newLibraryPayload.displayOrder = this.db.libraries.length + 1
|
|
||||||
|
newLibraryPayload.displayOrder = Database.libraries.map(li => li.displayOrder).sort((a, b) => a - b).pop() + 1
|
||||||
library.setData(newLibraryPayload)
|
library.setData(newLibraryPayload)
|
||||||
await this.db.insertEntity('library', library)
|
await Database.createLibrary(library)
|
||||||
|
|
||||||
// Only emit to users with access to library
|
// Only emit to users with access to library
|
||||||
const userFilter = (user) => {
|
const userFilter = (user) => {
|
||||||
return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id)
|
return user.checkCanAccessLibrary?.(library.id)
|
||||||
}
|
}
|
||||||
SocketAuthority.emitter('library_added', library.toJSON(), userFilter)
|
SocketAuthority.emitter('library_added', library.toJSON(), userFilter)
|
||||||
|
|
||||||
@@ -58,14 +62,15 @@ class LibraryController {
|
|||||||
|
|
||||||
findAll(req, res) {
|
findAll(req, res) {
|
||||||
const librariesAccessible = req.user.librariesAccessible || []
|
const librariesAccessible = req.user.librariesAccessible || []
|
||||||
if (librariesAccessible && librariesAccessible.length) {
|
if (librariesAccessible.length) {
|
||||||
return res.json({
|
return res.json({
|
||||||
libraries: this.db.libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON())
|
libraries: Database.libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
libraries: this.db.libraries.map(lib => lib.toJSON())
|
libraries: Database.libraries.map(lib => lib.toJSON())
|
||||||
|
// libraries: Database.libraries.map(lib => lib.toJSON())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +80,7 @@ class LibraryController {
|
|||||||
return res.json({
|
return res.json({
|
||||||
filterdata: libraryHelpers.getDistinctFilterDataNew(req.libraryItems),
|
filterdata: libraryHelpers.getDistinctFilterDataNew(req.libraryItems),
|
||||||
issues: req.libraryItems.filter(li => li.hasIssues).length,
|
issues: req.libraryItems.filter(li => li.hasIssues).length,
|
||||||
numUserPlaylists: this.db.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).length,
|
numUserPlaylists: Database.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).length,
|
||||||
library: req.library
|
library: req.library
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -128,14 +133,14 @@ class LibraryController {
|
|||||||
this.cronManager.updateLibraryScanCron(library)
|
this.cronManager.updateLibraryScanCron(library)
|
||||||
|
|
||||||
// Remove libraryItems no longer in library
|
// Remove libraryItems no longer in library
|
||||||
const itemsToRemove = this.db.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path))
|
const itemsToRemove = Database.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path))
|
||||||
if (itemsToRemove.length) {
|
if (itemsToRemove.length) {
|
||||||
Logger.info(`[Scanner] Updating library, removing ${itemsToRemove.length} items`)
|
Logger.info(`[Scanner] Updating library, removing ${itemsToRemove.length} items`)
|
||||||
for (let i = 0; i < itemsToRemove.length; i++) {
|
for (let i = 0; i < itemsToRemove.length; i++) {
|
||||||
await this.handleDeleteLibraryItem(itemsToRemove[i])
|
await this.handleDeleteLibraryItem(itemsToRemove[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.db.updateEntity('library', library)
|
await Database.updateLibrary(library)
|
||||||
|
|
||||||
// Only emit to users with access to library
|
// Only emit to users with access to library
|
||||||
const userFilter = (user) => {
|
const userFilter = (user) => {
|
||||||
@@ -153,21 +158,21 @@ class LibraryController {
|
|||||||
this.watcher.removeLibrary(library)
|
this.watcher.removeLibrary(library)
|
||||||
|
|
||||||
// Remove collections for library
|
// Remove collections for library
|
||||||
const collections = this.db.collections.filter(c => c.libraryId === library.id)
|
const collections = Database.collections.filter(c => c.libraryId === library.id)
|
||||||
for (const collection of collections) {
|
for (const collection of collections) {
|
||||||
Logger.info(`[Server] deleting collection "${collection.name}" for library "${library.name}"`)
|
Logger.info(`[Server] deleting collection "${collection.name}" for library "${library.name}"`)
|
||||||
await this.db.removeEntity('collection', collection.id)
|
await Database.removeCollection(collection.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove items in this library
|
// Remove items in this library
|
||||||
const libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id)
|
const libraryItems = Database.libraryItems.filter(li => li.libraryId === library.id)
|
||||||
Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`)
|
Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`)
|
||||||
for (let i = 0; i < libraryItems.length; i++) {
|
for (let i = 0; i < libraryItems.length; i++) {
|
||||||
await this.handleDeleteLibraryItem(libraryItems[i])
|
await this.handleDeleteLibraryItem(libraryItems[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryJson = library.toJSON()
|
const libraryJson = library.toJSON()
|
||||||
await this.db.removeEntity('library', library.id)
|
await Database.removeLibrary(library.id)
|
||||||
SocketAuthority.emitter('library_removed', libraryJson)
|
SocketAuthority.emitter('library_removed', libraryJson)
|
||||||
return res.json(libraryJson)
|
return res.json(libraryJson)
|
||||||
}
|
}
|
||||||
@@ -197,7 +202,7 @@ class LibraryController {
|
|||||||
// Step 1 - Filter the retrieved library items
|
// Step 1 - Filter the retrieved library items
|
||||||
let filterSeries = null
|
let filterSeries = null
|
||||||
if (payload.filterBy) {
|
if (payload.filterBy) {
|
||||||
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray)
|
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, Database.feeds)
|
||||||
payload.total = libraryItems.length
|
payload.total = libraryItems.length
|
||||||
|
|
||||||
// Determining if we are filtering titles by a series, and if so, which series
|
// Determining if we are filtering titles by a series, and if so, which series
|
||||||
@@ -209,7 +214,7 @@ class LibraryController {
|
|||||||
// If also filtering by series, will not collapse the filtered series as this would lead
|
// If also filtering by series, will not collapse the filtered series as this would lead
|
||||||
// to series having a collapsed series that is just that series.
|
// to series having a collapsed series that is just that series.
|
||||||
if (payload.collapseseries) {
|
if (payload.collapseseries) {
|
||||||
let collapsedItems = libraryHelpers.collapseBookSeries(libraryItems, this.db.series, filterSeries)
|
let collapsedItems = libraryHelpers.collapseBookSeries(libraryItems, Database.series, filterSeries, req.library.settings.hideSingleBookSeries)
|
||||||
|
|
||||||
if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
|
if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
|
||||||
libraryItems = collapsedItems
|
libraryItems = collapsedItems
|
||||||
@@ -237,7 +242,7 @@ class LibraryController {
|
|||||||
// 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)
|
||||||
sortArray.push({
|
sortArray.push({
|
||||||
asc: (li) => {
|
asc: (li) => {
|
||||||
if (this.db.serverSettings.sortingIgnorePrefix) {
|
if (Database.serverSettings.sortingIgnorePrefix) {
|
||||||
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
|
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
|
||||||
} else {
|
} else {
|
||||||
return li.collapsedSeries?.name || li.media.metadata.title
|
return li.collapsedSeries?.name || li.media.metadata.title
|
||||||
@@ -247,15 +252,11 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.sortBy) {
|
if (payload.sortBy) {
|
||||||
// old sort key TODO: should be mutated in dbMigration
|
|
||||||
let sortKey = payload.sortBy
|
let sortKey = payload.sortBy
|
||||||
if (sortKey.startsWith('book.')) {
|
|
||||||
sortKey = sortKey.replace('book.', 'media.metadata.')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle server setting sortingIgnorePrefix
|
// Handle server setting sortingIgnorePrefix
|
||||||
const sortByTitle = sortKey === 'media.metadata.title'
|
const sortByTitle = sortKey === 'media.metadata.title'
|
||||||
if (sortByTitle && this.db.serverSettings.sortingIgnorePrefix) {
|
if (sortByTitle && Database.serverSettings.sortingIgnorePrefix) {
|
||||||
// BookMetadata.js has titleIgnorePrefix getter
|
// BookMetadata.js has titleIgnorePrefix getter
|
||||||
sortKey += 'IgnorePrefix'
|
sortKey += 'IgnorePrefix'
|
||||||
}
|
}
|
||||||
@@ -267,7 +268,7 @@ class LibraryController {
|
|||||||
sortArray.push({
|
sortArray.push({
|
||||||
asc: (li) => {
|
asc: (li) => {
|
||||||
if (li.collapsedSeries) {
|
if (li.collapsedSeries) {
|
||||||
return this.db.serverSettings.sortingIgnorePrefix ?
|
return Database.serverSettings.sortingIgnorePrefix ?
|
||||||
li.collapsedSeries.nameIgnorePrefix :
|
li.collapsedSeries.nameIgnorePrefix :
|
||||||
li.collapsedSeries.name
|
li.collapsedSeries.name
|
||||||
} else {
|
} else {
|
||||||
@@ -284,7 +285,7 @@ class LibraryController {
|
|||||||
if (mediaIsBook && sortBySequence) {
|
if (mediaIsBook && sortBySequence) {
|
||||||
return li.media.metadata.getSeries(filterSeries).sequence
|
return li.media.metadata.getSeries(filterSeries).sequence
|
||||||
} else if (mediaIsBook && sortByTitle && li.collapsedSeries) {
|
} else if (mediaIsBook && sortByTitle && li.collapsedSeries) {
|
||||||
return this.db.serverSettings.sortingIgnorePrefix ?
|
return Database.serverSettings.sortingIgnorePrefix ?
|
||||||
li.collapsedSeries.nameIgnorePrefix :
|
li.collapsedSeries.nameIgnorePrefix :
|
||||||
li.collapsedSeries.name
|
li.collapsedSeries.name
|
||||||
} else {
|
} else {
|
||||||
@@ -387,7 +388,13 @@ class LibraryController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
// api/libraries/:id/series
|
/**
|
||||||
|
* api/libraries/:id/series
|
||||||
|
* Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open
|
||||||
|
*
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async getAllSeriesForLibrary(req, res) {
|
async getAllSeriesForLibrary(req, res) {
|
||||||
const libraryItems = req.libraryItems
|
const libraryItems = req.libraryItems
|
||||||
|
|
||||||
@@ -405,7 +412,7 @@ class LibraryController {
|
|||||||
include: include.join(',')
|
include: include.join(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
let series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified)
|
let series = libraryHelpers.getSeriesFromBooks(libraryItems, Database.series, null, payload.filterBy, req.user, payload.minified, req.library.settings.hideSingleBookSeries)
|
||||||
|
|
||||||
const direction = payload.sortDesc ? 'desc' : 'asc'
|
const direction = payload.sortDesc ? 'desc' : 'asc'
|
||||||
series = naturalSort(series).by([
|
series = naturalSort(series).by([
|
||||||
@@ -422,7 +429,7 @@ class LibraryController {
|
|||||||
} else if (payload.sortBy === 'lastBookAdded') {
|
} else if (payload.sortBy === 'lastBookAdded') {
|
||||||
return Math.max(...(se.books).map(x => x.addedAt), 0)
|
return Math.max(...(se.books).map(x => x.addedAt), 0)
|
||||||
} else { // sort by name
|
} else { // sort by name
|
||||||
return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
|
return Database.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,6 +455,42 @@ class LibraryController {
|
|||||||
res.json(payload)
|
res.json(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* api/libraries/:id/series/:seriesId
|
||||||
|
*
|
||||||
|
* Optional includes (e.g. `?include=rssfeed,progress`)
|
||||||
|
* rssfeed: adds `rssFeed` to series object if a feed is open
|
||||||
|
* progress: adds `progress` to series object with { libraryItemIds:Array<llid>, libraryItemIdsFinished:Array<llid>, isFinished:boolean }
|
||||||
|
*
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res - Series
|
||||||
|
*/
|
||||||
|
async getSeriesForLibrary(req, res) {
|
||||||
|
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
|
||||||
|
|
||||||
|
const series = Database.series.find(se => se.id === req.params.seriesId)
|
||||||
|
if (!series) return res.sendStatus(404)
|
||||||
|
|
||||||
|
const libraryItemsInSeries = req.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
|
||||||
|
|
||||||
|
const seriesJson = series.toJSON()
|
||||||
|
if (include.includes('progress')) {
|
||||||
|
const libraryItemsFinished = libraryItemsInSeries.filter(li => !!req.user.getMediaProgress(li.id)?.isFinished)
|
||||||
|
seriesJson.progress = {
|
||||||
|
libraryItemIds: libraryItemsInSeries.map(li => li.id),
|
||||||
|
libraryItemIdsFinished: libraryItemsFinished.map(li => li.id),
|
||||||
|
isFinished: libraryItemsFinished.length >= libraryItemsInSeries.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (include.includes('rssfeed')) {
|
||||||
|
const feedObj = this.rssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||||
|
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(seriesJson)
|
||||||
|
}
|
||||||
|
|
||||||
// api/libraries/:id/collections
|
// api/libraries/:id/collections
|
||||||
async getCollectionsForLibrary(req, res) {
|
async getCollectionsForLibrary(req, res) {
|
||||||
const libraryItems = req.libraryItems
|
const libraryItems = req.libraryItems
|
||||||
@@ -466,7 +509,7 @@ class LibraryController {
|
|||||||
include: include.join(',')
|
include: include.join(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
let collections = this.db.collections.filter(c => c.libraryId === req.library.id).map(c => {
|
let collections = Database.collections.filter(c => c.libraryId === req.library.id).map(c => {
|
||||||
const expanded = c.toJSONExpanded(libraryItems, payload.minified)
|
const expanded = c.toJSONExpanded(libraryItems, payload.minified)
|
||||||
|
|
||||||
// If all books restricted to user in this collection then hide this collection
|
// If all books restricted to user in this collection then hide this collection
|
||||||
@@ -493,7 +536,7 @@ class LibraryController {
|
|||||||
|
|
||||||
// api/libraries/:id/playlists
|
// api/libraries/:id/playlists
|
||||||
async getUserPlaylistsForLibrary(req, res) {
|
async getUserPlaylistsForLibrary(req, res) {
|
||||||
let playlistsForUser = this.db.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).map(p => p.toJSONExpanded(this.db.libraryItems))
|
let playlistsForUser = Database.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).map(p => p.toJSONExpanded(Database.libraryItems))
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
results: [],
|
results: [],
|
||||||
@@ -517,7 +560,7 @@ class LibraryController {
|
|||||||
return res.status(400).send('Invalid library media type')
|
return res.status(400).send('Invalid library media type')
|
||||||
}
|
}
|
||||||
|
|
||||||
let libraryItems = this.db.libraryItems.filter(li => li.libraryId === req.library.id)
|
let libraryItems = Database.libraryItems.filter(li => li.libraryId === req.library.id)
|
||||||
let albums = libraryHelpers.groupMusicLibraryItemsIntoAlbums(libraryItems)
|
let albums = libraryHelpers.groupMusicLibraryItemsIntoAlbums(libraryItems)
|
||||||
albums = naturalSort(albums).asc(a => a.title) // Alphabetical by album title
|
albums = naturalSort(albums).asc(a => a.title) // Alphabetical by album title
|
||||||
|
|
||||||
@@ -544,12 +587,10 @@ class LibraryController {
|
|||||||
// api/libraries/:id/personalized
|
// api/libraries/:id/personalized
|
||||||
// New and improved personalized call only loops through library items once
|
// New and improved personalized call only loops through library items once
|
||||||
async getLibraryUserPersonalizedOptimal(req, res) {
|
async getLibraryUserPersonalizedOptimal(req, res) {
|
||||||
const mediaType = req.library.mediaType
|
|
||||||
const libraryItems = req.libraryItems
|
|
||||||
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10
|
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10
|
||||||
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
|
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
|
||||||
|
|
||||||
const categories = libraryHelpers.buildPersonalizedShelves(this, req.user, libraryItems, mediaType, limitPerShelf, include)
|
const categories = libraryHelpers.buildPersonalizedShelves(this, req.user, req.libraryItems, req.library, limitPerShelf, include)
|
||||||
res.json(categories)
|
res.json(categories)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,26 +604,26 @@ class LibraryController {
|
|||||||
var orderdata = req.body
|
var orderdata = req.body
|
||||||
var hasUpdates = false
|
var hasUpdates = false
|
||||||
for (let i = 0; i < orderdata.length; i++) {
|
for (let i = 0; i < orderdata.length; i++) {
|
||||||
var library = this.db.libraries.find(lib => lib.id === orderdata[i].id)
|
var library = Database.libraries.find(lib => lib.id === orderdata[i].id)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
Logger.error(`[LibraryController] Invalid library not found in reorder ${orderdata[i].id}`)
|
Logger.error(`[LibraryController] Invalid library not found in reorder ${orderdata[i].id}`)
|
||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
if (library.update({ displayOrder: orderdata[i].newOrder })) {
|
if (library.update({ displayOrder: orderdata[i].newOrder })) {
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
await this.db.updateEntity('library', library)
|
await Database.updateLibrary(library)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
this.db.libraries.sort((a, b) => a.displayOrder - b.displayOrder)
|
Database.libraries.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||||
Logger.debug(`[LibraryController] Updated library display orders`)
|
Logger.debug(`[LibraryController] Updated library display orders`)
|
||||||
} else {
|
} else {
|
||||||
Logger.debug(`[LibraryController] Library orders were up to date`)
|
Logger.debug(`[LibraryController] Library orders were up to date`)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
libraries: this.db.libraries.map(lib => lib.toJSON())
|
libraries: Database.libraries.map(lib => lib.toJSON())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,7 +653,7 @@ class LibraryController {
|
|||||||
if (queryResult.series?.length) {
|
if (queryResult.series?.length) {
|
||||||
queryResult.series.forEach((se) => {
|
queryResult.series.forEach((se) => {
|
||||||
if (!seriesMatches[se.id]) {
|
if (!seriesMatches[se.id]) {
|
||||||
const _series = this.db.series.find(_se => _se.id === se.id)
|
const _series = Database.series.find(_se => _se.id === se.id)
|
||||||
if (_series) seriesMatches[se.id] = { series: _series.toJSON(), books: [li.toJSON()] }
|
if (_series) seriesMatches[se.id] = { series: _series.toJSON(), books: [li.toJSON()] }
|
||||||
} else {
|
} else {
|
||||||
seriesMatches[se.id].books.push(li.toJSON())
|
seriesMatches[se.id].books.push(li.toJSON())
|
||||||
@@ -622,7 +663,7 @@ class LibraryController {
|
|||||||
if (queryResult.authors?.length) {
|
if (queryResult.authors?.length) {
|
||||||
queryResult.authors.forEach((au) => {
|
queryResult.authors.forEach((au) => {
|
||||||
if (!authorMatches[au.id]) {
|
if (!authorMatches[au.id]) {
|
||||||
const _author = this.db.authors.find(_au => _au.id === au.id)
|
const _author = Database.authors.find(_au => _au.id === au.id)
|
||||||
if (_author) {
|
if (_author) {
|
||||||
authorMatches[au.id] = _author.toJSON()
|
authorMatches[au.id] = _author.toJSON()
|
||||||
authorMatches[au.id].numBooks = 1
|
authorMatches[au.id].numBooks = 1
|
||||||
@@ -689,7 +730,7 @@ class LibraryController {
|
|||||||
if (li.media.metadata.authors && li.media.metadata.authors.length) {
|
if (li.media.metadata.authors && li.media.metadata.authors.length) {
|
||||||
li.media.metadata.authors.forEach((au) => {
|
li.media.metadata.authors.forEach((au) => {
|
||||||
if (!authors[au.id]) {
|
if (!authors[au.id]) {
|
||||||
const _author = this.db.authors.find(_au => _au.id === au.id)
|
const _author = Database.authors.find(_au => _au.id === au.id)
|
||||||
if (_author) {
|
if (_author) {
|
||||||
authors[au.id] = _author.toJSON()
|
authors[au.id] = _author.toJSON()
|
||||||
authors[au.id].numBooks = 1
|
authors[au.id].numBooks = 1
|
||||||
@@ -751,7 +792,7 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (itemsUpdated.length) {
|
if (itemsUpdated.length) {
|
||||||
await this.db.updateLibraryItems(itemsUpdated)
|
await Database.updateBulkBooks(itemsUpdated.map(i => i.media))
|
||||||
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
|
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,7 +817,7 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (itemsUpdated.length) {
|
if (itemsUpdated.length) {
|
||||||
await this.db.updateLibraryItems(itemsUpdated)
|
await Database.updateBulkBooks(itemsUpdated.map(i => i.media))
|
||||||
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
|
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,15 +898,15 @@ class LibraryController {
|
|||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
if (!req.user.checkCanAccessLibrary(req.params.id)) {
|
if (!req.user.checkCanAccessLibrary(req.params.id)) {
|
||||||
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
|
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
const library = this.db.libraries.find(lib => lib.id === req.params.id)
|
const library = Database.libraries.find(lib => lib.id === req.params.id)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return res.status(404).send('Library not found')
|
return res.status(404).send('Library not found')
|
||||||
}
|
}
|
||||||
req.library = library
|
req.library = library
|
||||||
req.libraryItems = this.db.libraryItems.filter(li => {
|
req.libraryItems = Database.libraryItems.filter(li => {
|
||||||
return li.libraryId === library.id && req.user.checkCanAccessLibraryItem(li)
|
return li.libraryId === library.id && req.user.checkCanAccessLibraryItem(li)
|
||||||
})
|
})
|
||||||
next()
|
next()
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ const Path = require('path')
|
|||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
const zipHelpers = require('../utils/zipHelpers')
|
const zipHelpers = require('../utils/zipHelpers')
|
||||||
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
|
const { reqSupportsWebp } = require('../utils/index')
|
||||||
const { ScanResult } = require('../utils/constants')
|
const { ScanResult } = require('../utils/constants')
|
||||||
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ class LibraryItemController {
|
|||||||
if (item.mediaType == 'book') {
|
if (item.mediaType == 'book') {
|
||||||
if (includeEntities.includes('authors')) {
|
if (includeEntities.includes('authors')) {
|
||||||
item.media.metadata.authors = item.media.metadata.authors.map(au => {
|
item.media.metadata.authors = item.media.metadata.authors.map(au => {
|
||||||
var author = this.db.authors.find(_au => _au.id === au.id)
|
var author = Database.authors.find(_au => _au.id === au.id)
|
||||||
if (!author) return null
|
if (!author) return null
|
||||||
return {
|
return {
|
||||||
...author
|
...author
|
||||||
@@ -61,7 +62,7 @@ class LibraryItemController {
|
|||||||
const hasUpdates = libraryItem.update(req.body)
|
const hasUpdates = libraryItem.update(req.body)
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
Logger.debug(`[LibraryItemController] Updated now saving`)
|
Logger.debug(`[LibraryItemController] Updated now saving`)
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
res.json(libraryItem.toJSON())
|
res.json(libraryItem.toJSON())
|
||||||
@@ -104,7 +105,7 @@ class LibraryItemController {
|
|||||||
|
|
||||||
// Book specific
|
// Book specific
|
||||||
if (libraryItem.isBook) {
|
if (libraryItem.isBook) {
|
||||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload)
|
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Podcast specific
|
// Podcast specific
|
||||||
@@ -139,7 +140,7 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
res.json({
|
res.json({
|
||||||
@@ -174,7 +175,7 @@ class LibraryItemController {
|
|||||||
return res.status(500).send('Unknown error occurred')
|
return res.status(500).send('Unknown error occurred')
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -194,7 +195,7 @@ class LibraryItemController {
|
|||||||
return res.status(500).send(validationResult.error)
|
return res.status(500).send(validationResult.error)
|
||||||
}
|
}
|
||||||
if (validationResult.updated) {
|
if (validationResult.updated) {
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
res.json({
|
res.json({
|
||||||
@@ -210,7 +211,7 @@ class LibraryItemController {
|
|||||||
if (libraryItem.media.coverPath) {
|
if (libraryItem.media.coverPath) {
|
||||||
libraryItem.updateMediaCover('')
|
libraryItem.updateMediaCover('')
|
||||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +283,7 @@ class LibraryItemController {
|
|||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
libraryItem.media.updateAudioTracks(orderedFileData)
|
libraryItem.media.updateAudioTracks(orderedFileData)
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
res.json(libraryItem.toJSON())
|
res.json(libraryItem.toJSON())
|
||||||
}
|
}
|
||||||
@@ -309,7 +310,7 @@ class LibraryItemController {
|
|||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsToDelete = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id))
|
const itemsToDelete = Database.libraryItems.filter(li => libraryItemIds.includes(li.id))
|
||||||
if (!itemsToDelete.length) {
|
if (!itemsToDelete.length) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
@@ -338,15 +339,15 @@ class LibraryItemController {
|
|||||||
|
|
||||||
for (let i = 0; i < updatePayloads.length; i++) {
|
for (let i = 0; i < updatePayloads.length; i++) {
|
||||||
var mediaPayload = updatePayloads[i].mediaPayload
|
var mediaPayload = updatePayloads[i].mediaPayload
|
||||||
var libraryItem = this.db.libraryItems.find(_li => _li.id === updatePayloads[i].id)
|
var libraryItem = Database.libraryItems.find(_li => _li.id === updatePayloads[i].id)
|
||||||
if (!libraryItem) return null
|
if (!libraryItem) return null
|
||||||
|
|
||||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload)
|
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||||
|
|
||||||
var hasUpdates = libraryItem.media.update(mediaPayload)
|
var hasUpdates = libraryItem.media.update(mediaPayload)
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
itemsUpdated++
|
itemsUpdated++
|
||||||
}
|
}
|
||||||
@@ -366,7 +367,7 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
const libraryItems = []
|
const libraryItems = []
|
||||||
libraryItemIds.forEach((lid) => {
|
libraryItemIds.forEach((lid) => {
|
||||||
const li = this.db.libraryItems.find(_li => _li.id === lid)
|
const li = Database.libraryItems.find(_li => _li.id === lid)
|
||||||
if (li) libraryItems.push(li.toJSONExpanded())
|
if (li) libraryItems.push(li.toJSONExpanded())
|
||||||
})
|
})
|
||||||
res.json({
|
res.json({
|
||||||
@@ -389,7 +390,7 @@ class LibraryItemController {
|
|||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryItems = req.body.libraryItemIds.map(lid => this.db.getLibraryItem(lid)).filter(li => li)
|
const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li)
|
||||||
if (!libraryItems?.length) {
|
if (!libraryItems?.length) {
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
@@ -424,7 +425,7 @@ class LibraryItemController {
|
|||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryItems = req.body.libraryItemIds.map(lid => this.db.getLibraryItem(lid)).filter(li => li)
|
const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li)
|
||||||
if (!libraryItems?.length) {
|
if (!libraryItems?.length) {
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
@@ -440,18 +441,6 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: api/items/all
|
|
||||||
async deleteAll(req, res) {
|
|
||||||
if (!req.user.isAdminOrUp) {
|
|
||||||
Logger.warn('User other than admin attempted to delete all library items', req.user)
|
|
||||||
return res.sendStatus(403)
|
|
||||||
}
|
|
||||||
Logger.info('Removing all Library Items')
|
|
||||||
var success = await this.db.recreateLibraryItemsDb()
|
|
||||||
if (success) res.sendStatus(200)
|
|
||||||
else res.sendStatus(500)
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST: api/items/:id/scan (admin)
|
// POST: api/items/:id/scan (admin)
|
||||||
async scan(req, res) {
|
async scan(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
@@ -472,7 +461,7 @@ class LibraryItemController {
|
|||||||
|
|
||||||
getToneMetadataObject(req, res) {
|
getToneMetadataObject(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[LibraryItemController] Non-root user attempted to get tone metadata object`, req.user)
|
Logger.error(`[LibraryItemController] Non-admin user attempted to get tone metadata object`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,7 +493,7 @@ class LibraryItemController {
|
|||||||
const chapters = req.body.chapters || []
|
const chapters = req.body.chapters || []
|
||||||
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
|
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
await this.db.updateLibraryItem(req.libraryItem)
|
await Database.updateLibraryItem(req.libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,20 +503,31 @@ class LibraryItemController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async toneScan(req, res) {
|
/**
|
||||||
if (!req.libraryItem.media.audioFiles.length) {
|
* GET api/items/:id/ffprobe/:fileid
|
||||||
return res.sendStatus(404)
|
* FFProbe JSON result from audio file
|
||||||
|
*
|
||||||
|
* @param {express.Request} req
|
||||||
|
* @param {express.Response} res
|
||||||
|
*/
|
||||||
|
async getFFprobeData(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[LibraryItemController] Non-admin user attempted to get ffprobe data`, req.user)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
if (req.libraryFile.fileType !== 'audio') {
|
||||||
|
Logger.error(`[LibraryItemController] Invalid filetype "${req.libraryFile.fileType}" for fileid "${req.params.fileid}". Expected audio file`)
|
||||||
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioFileIndex = isNullOrNaN(req.params.index) ? 1 : Number(req.params.index)
|
const audioFile = req.libraryItem.media.findFileWithInode(req.params.fileid)
|
||||||
const audioFile = req.libraryItem.media.audioFiles.find(af => af.index === audioFileIndex)
|
|
||||||
if (!audioFile) {
|
if (!audioFile) {
|
||||||
Logger.error(`[LibraryItemController] toneScan: Audio file not found with index ${audioFileIndex}`)
|
Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toneData = await this.scanner.probeAudioFileWithTone(audioFile)
|
const ffprobeData = await this.scanner.probeAudioFile(audioFile)
|
||||||
res.json(toneData)
|
res.json(ffprobeData)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -575,7 +575,7 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
req.libraryItem.updatedAt = Date.now()
|
req.libraryItem.updatedAt = Date.now()
|
||||||
await this.db.updateLibraryItem(req.libraryItem)
|
await Database.updateLibraryItem(req.libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
@@ -671,13 +671,13 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.libraryItem.updatedAt = Date.now()
|
req.libraryItem.updatedAt = Date.now()
|
||||||
await this.db.updateLibraryItem(req.libraryItem)
|
await Database.updateLibraryItem(req.libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
req.libraryItem = this.db.libraryItems.find(li => li.id === req.params.id)
|
req.libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!req.libraryItem?.media) return res.sendStatus(404)
|
if (!req.libraryItem?.media) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check user can access this library item
|
// Check user can access this library item
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const Database = require('../Database')
|
||||||
const { sort } = require('../libs/fastSort')
|
const { sort } = require('../libs/fastSort')
|
||||||
const { isObject, toNumber } = require('../utils/index')
|
const { toNumber } = require('../utils/index')
|
||||||
|
|
||||||
class MeController {
|
class MeController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
@@ -33,7 +34,7 @@ class MeController {
|
|||||||
|
|
||||||
// GET: api/me/listening-stats
|
// GET: api/me/listening-stats
|
||||||
async getListeningStats(req, res) {
|
async getListeningStats(req, res) {
|
||||||
var listeningStats = await this.getUserListeningStatsHelpers(req.user.id)
|
const listeningStats = await this.getUserListeningStatsHelpers(req.user.id)
|
||||||
res.json(listeningStats)
|
res.json(listeningStats)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,21 +52,21 @@ class MeController {
|
|||||||
if (!req.user.removeMediaProgress(req.params.id)) {
|
if (!req.user.removeMediaProgress(req.params.id)) {
|
||||||
return res.sendStatus(200)
|
return res.sendStatus(200)
|
||||||
}
|
}
|
||||||
await this.db.updateEntity('user', req.user)
|
await Database.removeMediaProgress(req.params.id)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH: api/me/progress/:id
|
// PATCH: api/me/progress/:id
|
||||||
async createUpdateMediaProgress(req, res) {
|
async createUpdateMediaProgress(req, res) {
|
||||||
var libraryItem = this.db.libraryItems.find(ab => ab.id === req.params.id)
|
const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(404).send('Item not found')
|
return res.status(404).send('Item not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body)
|
if (req.user.createUpdateMediaProgress(libraryItem, req.body)) {
|
||||||
if (wasUpdated) {
|
const mediaProgress = req.user.getMediaProgress(libraryItem.id)
|
||||||
await this.db.updateEntity('user', req.user)
|
if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
}
|
}
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
@@ -73,8 +74,8 @@ class MeController {
|
|||||||
|
|
||||||
// PATCH: api/me/progress/:id/:episodeId
|
// PATCH: api/me/progress/:id/:episodeId
|
||||||
async createUpdateEpisodeMediaProgress(req, res) {
|
async createUpdateEpisodeMediaProgress(req, res) {
|
||||||
var episodeId = req.params.episodeId
|
const episodeId = req.params.episodeId
|
||||||
var libraryItem = this.db.libraryItems.find(ab => ab.id === req.params.id)
|
const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(404).send('Item not found')
|
return res.status(404).send('Item not found')
|
||||||
}
|
}
|
||||||
@@ -83,9 +84,9 @@ class MeController {
|
|||||||
return res.status(404).send('Episode not found')
|
return res.status(404).send('Episode not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body, episodeId)
|
if (req.user.createUpdateMediaProgress(libraryItem, req.body, episodeId)) {
|
||||||
if (wasUpdated) {
|
const mediaProgress = req.user.getMediaProgress(libraryItem.id, episodeId)
|
||||||
await this.db.updateEntity('user', req.user)
|
if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
}
|
}
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
@@ -93,24 +94,26 @@ class MeController {
|
|||||||
|
|
||||||
// PATCH: api/me/progress/batch/update
|
// PATCH: api/me/progress/batch/update
|
||||||
async batchUpdateMediaProgress(req, res) {
|
async batchUpdateMediaProgress(req, res) {
|
||||||
var itemProgressPayloads = req.body
|
const itemProgressPayloads = req.body
|
||||||
if (!itemProgressPayloads || !itemProgressPayloads.length) {
|
if (!itemProgressPayloads?.length) {
|
||||||
return res.status(400).send('Missing request payload')
|
return res.status(400).send('Missing request payload')
|
||||||
}
|
}
|
||||||
|
|
||||||
var shouldUpdate = false
|
let shouldUpdate = false
|
||||||
itemProgressPayloads.forEach((itemProgress) => {
|
for (const itemProgress of itemProgressPayloads) {
|
||||||
var libraryItem = this.db.libraryItems.find(li => li.id === itemProgress.libraryItemId) // Make sure this library item exists
|
const libraryItem = Database.libraryItems.find(li => li.id === itemProgress.libraryItemId) // Make sure this library item exists
|
||||||
if (libraryItem) {
|
if (libraryItem) {
|
||||||
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)
|
if (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) {
|
||||||
if (wasUpdated) shouldUpdate = true
|
const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId)
|
||||||
|
if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
|
||||||
|
shouldUpdate = true
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.error(`[MeController] batchUpdateMediaProgress: Library Item does not exist ${itemProgress.id}`)
|
Logger.error(`[MeController] batchUpdateMediaProgress: Library Item does not exist ${itemProgress.id}`)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
await this.db.updateEntity('user', req.user)
|
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,18 +122,18 @@ class MeController {
|
|||||||
|
|
||||||
// POST: api/me/item/:id/bookmark
|
// POST: api/me/item/:id/bookmark
|
||||||
async createBookmark(req, res) {
|
async createBookmark(req, res) {
|
||||||
var libraryItem = this.db.libraryItems.find(li => li.id === req.params.id)
|
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!libraryItem) return res.sendStatus(404)
|
if (!libraryItem) return res.sendStatus(404)
|
||||||
const { time, title } = req.body
|
const { time, title } = req.body
|
||||||
var bookmark = req.user.createBookmark(libraryItem.id, time, title)
|
var bookmark = req.user.createBookmark(libraryItem.id, time, title)
|
||||||
await this.db.updateEntity('user', req.user)
|
await Database.updateUser(req.user)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
res.json(bookmark)
|
res.json(bookmark)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH: api/me/item/:id/bookmark
|
// PATCH: api/me/item/:id/bookmark
|
||||||
async updateBookmark(req, res) {
|
async updateBookmark(req, res) {
|
||||||
var libraryItem = this.db.libraryItems.find(li => li.id === req.params.id)
|
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!libraryItem) return res.sendStatus(404)
|
if (!libraryItem) return res.sendStatus(404)
|
||||||
const { time, title } = req.body
|
const { time, title } = req.body
|
||||||
if (!req.user.findBookmark(libraryItem.id, time)) {
|
if (!req.user.findBookmark(libraryItem.id, time)) {
|
||||||
@@ -139,14 +142,14 @@ class MeController {
|
|||||||
}
|
}
|
||||||
var bookmark = req.user.updateBookmark(libraryItem.id, time, title)
|
var bookmark = req.user.updateBookmark(libraryItem.id, time, title)
|
||||||
if (!bookmark) return res.sendStatus(500)
|
if (!bookmark) return res.sendStatus(500)
|
||||||
await this.db.updateEntity('user', req.user)
|
await Database.updateUser(req.user)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
res.json(bookmark)
|
res.json(bookmark)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: api/me/item/:id/bookmark/:time
|
// DELETE: api/me/item/:id/bookmark/:time
|
||||||
async removeBookmark(req, res) {
|
async removeBookmark(req, res) {
|
||||||
var libraryItem = this.db.libraryItems.find(li => li.id === req.params.id)
|
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!libraryItem) return res.sendStatus(404)
|
if (!libraryItem) return res.sendStatus(404)
|
||||||
var time = Number(req.params.time)
|
var time = Number(req.params.time)
|
||||||
if (isNaN(time)) return res.sendStatus(500)
|
if (isNaN(time)) return res.sendStatus(500)
|
||||||
@@ -156,7 +159,7 @@ class MeController {
|
|||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
req.user.removeBookmark(libraryItem.id, time)
|
req.user.removeBookmark(libraryItem.id, time)
|
||||||
await this.db.updateEntity('user', req.user)
|
await Database.updateUser(req.user)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
@@ -178,16 +181,16 @@ class MeController {
|
|||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
const updatedLocalMediaProgress = []
|
const updatedLocalMediaProgress = []
|
||||||
var numServerProgressUpdates = 0
|
let numServerProgressUpdates = 0
|
||||||
const updatedServerMediaProgress = []
|
const updatedServerMediaProgress = []
|
||||||
const localMediaProgress = req.body.localMediaProgress || []
|
const localMediaProgress = req.body.localMediaProgress || []
|
||||||
|
|
||||||
localMediaProgress.forEach(localProgress => {
|
for (const localProgress of localMediaProgress) {
|
||||||
if (!localProgress.libraryItemId) {
|
if (!localProgress.libraryItemId) {
|
||||||
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
|
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var libraryItem = this.db.getLibraryItem(localProgress.libraryItemId)
|
const libraryItem = Database.getLibraryItem(localProgress.libraryItemId)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress)
|
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress)
|
||||||
return
|
return
|
||||||
@@ -199,12 +202,14 @@ class MeController {
|
|||||||
Logger.debug(`[MeController] syncLocalMediaProgress local progress is new - creating ${localProgress.id}`)
|
Logger.debug(`[MeController] syncLocalMediaProgress local progress is new - creating ${localProgress.id}`)
|
||||||
req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId)
|
req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId)
|
||||||
mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
|
mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
|
||||||
|
if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
|
||||||
updatedServerMediaProgress.push(mediaProgress)
|
updatedServerMediaProgress.push(mediaProgress)
|
||||||
numServerProgressUpdates++
|
numServerProgressUpdates++
|
||||||
} else if (mediaProgress.lastUpdate < localProgress.lastUpdate) {
|
} else if (mediaProgress.lastUpdate < localProgress.lastUpdate) {
|
||||||
Logger.debug(`[MeController] syncLocalMediaProgress local progress is more recent - updating ${mediaProgress.id}`)
|
Logger.debug(`[MeController] syncLocalMediaProgress local progress is more recent - updating ${mediaProgress.id}`)
|
||||||
req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId)
|
req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId)
|
||||||
mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
|
mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
|
||||||
|
if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
|
||||||
updatedServerMediaProgress.push(mediaProgress)
|
updatedServerMediaProgress.push(mediaProgress)
|
||||||
numServerProgressUpdates++
|
numServerProgressUpdates++
|
||||||
} else if (mediaProgress.lastUpdate > localProgress.lastUpdate) {
|
} else if (mediaProgress.lastUpdate > localProgress.lastUpdate) {
|
||||||
@@ -222,11 +227,10 @@ class MeController {
|
|||||||
} else {
|
} else {
|
||||||
Logger.debug(`[MeController] syncLocalMediaProgress server and local are in sync - ${mediaProgress.id}`)
|
Logger.debug(`[MeController] syncLocalMediaProgress server and local are in sync - ${mediaProgress.id}`)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
Logger.debug(`[MeController] syncLocalMediaProgress server updates = ${numServerProgressUpdates}, local updates = ${updatedLocalMediaProgress.length}`)
|
Logger.debug(`[MeController] syncLocalMediaProgress server updates = ${numServerProgressUpdates}, local updates = ${updatedLocalMediaProgress.length}`)
|
||||||
if (numServerProgressUpdates > 0) {
|
if (numServerProgressUpdates > 0) {
|
||||||
await this.db.updateEntity('user', req.user)
|
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +248,7 @@ class MeController {
|
|||||||
let itemsInProgress = []
|
let itemsInProgress = []
|
||||||
for (const mediaProgress of req.user.mediaProgress) {
|
for (const mediaProgress of req.user.mediaProgress) {
|
||||||
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
|
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
|
||||||
const libraryItem = this.db.getLibraryItem(mediaProgress.libraryItemId)
|
const libraryItem = Database.getLibraryItem(mediaProgress.libraryItemId)
|
||||||
if (libraryItem) {
|
if (libraryItem) {
|
||||||
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
|
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
|
||||||
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)
|
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)
|
||||||
@@ -274,7 +278,7 @@ class MeController {
|
|||||||
|
|
||||||
// GET: api/me/series/:id/remove-from-continue-listening
|
// GET: api/me/series/:id/remove-from-continue-listening
|
||||||
async removeSeriesFromContinueListening(req, res) {
|
async removeSeriesFromContinueListening(req, res) {
|
||||||
const series = this.db.series.find(se => se.id === req.params.id)
|
const series = Database.series.find(se => se.id === req.params.id)
|
||||||
if (!series) {
|
if (!series) {
|
||||||
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
|
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
@@ -282,7 +286,7 @@ class MeController {
|
|||||||
|
|
||||||
const hasUpdated = req.user.addSeriesToHideFromContinueListening(req.params.id)
|
const hasUpdated = req.user.addSeriesToHideFromContinueListening(req.params.id)
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
await this.db.updateEntity('user', req.user)
|
await Database.updateUser(req.user)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
}
|
}
|
||||||
res.json(req.user.toJSONForBrowser())
|
res.json(req.user.toJSONForBrowser())
|
||||||
@@ -290,7 +294,7 @@ class MeController {
|
|||||||
|
|
||||||
// GET: api/me/series/:id/readd-to-continue-listening
|
// GET: api/me/series/:id/readd-to-continue-listening
|
||||||
async readdSeriesFromContinueListening(req, res) {
|
async readdSeriesFromContinueListening(req, res) {
|
||||||
const series = this.db.series.find(se => se.id === req.params.id)
|
const series = Database.series.find(se => se.id === req.params.id)
|
||||||
if (!series) {
|
if (!series) {
|
||||||
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
|
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
@@ -298,7 +302,7 @@ class MeController {
|
|||||||
|
|
||||||
const hasUpdated = req.user.removeSeriesFromHideFromContinueListening(req.params.id)
|
const hasUpdated = req.user.removeSeriesFromHideFromContinueListening(req.params.id)
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
await this.db.updateEntity('user', req.user)
|
await Database.updateUser(req.user)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
}
|
}
|
||||||
res.json(req.user.toJSONForBrowser())
|
res.json(req.user.toJSONForBrowser())
|
||||||
@@ -308,7 +312,7 @@ class MeController {
|
|||||||
async removeItemFromContinueListening(req, res) {
|
async removeItemFromContinueListening(req, res) {
|
||||||
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
|
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
await this.db.updateEntity('user', req.user)
|
await Database.updateUser(req.user)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
}
|
}
|
||||||
res.json(req.user.toJSONForBrowser())
|
res.json(req.user.toJSONForBrowser())
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const Path = require('path')
|
|||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
const filePerms = require('../utils/filePerms')
|
const filePerms = require('../utils/filePerms')
|
||||||
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
||||||
@@ -30,7 +31,7 @@ class MiscController {
|
|||||||
var libraryId = req.body.library
|
var libraryId = req.body.library
|
||||||
var folderId = req.body.folder
|
var folderId = req.body.folder
|
||||||
|
|
||||||
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
var library = Database.libraries.find(lib => lib.id === libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return res.status(404).send(`Library not found with id ${libraryId}`)
|
return res.status(404).send(`Library not found with id ${libraryId}`)
|
||||||
}
|
}
|
||||||
@@ -111,23 +112,23 @@ class MiscController {
|
|||||||
Logger.error('User other than admin attempting to update server settings', req.user)
|
Logger.error('User other than admin attempting to update server settings', req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
var settingsUpdate = req.body
|
const settingsUpdate = req.body
|
||||||
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
||||||
return res.status(500).send('Invalid settings update object')
|
return res.status(500).send('Invalid settings update object')
|
||||||
}
|
}
|
||||||
|
|
||||||
var madeUpdates = this.db.serverSettings.update(settingsUpdate)
|
const madeUpdates = Database.serverSettings.update(settingsUpdate)
|
||||||
if (madeUpdates) {
|
if (madeUpdates) {
|
||||||
|
await Database.updateServerSettings()
|
||||||
|
|
||||||
// If backup schedule is updated - update backup manager
|
// If backup schedule is updated - update backup manager
|
||||||
if (settingsUpdate.backupSchedule !== undefined) {
|
if (settingsUpdate.backupSchedule !== undefined) {
|
||||||
this.backupManager.updateCronSchedule()
|
this.backupManager.updateCronSchedule()
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.updateServerSettings()
|
|
||||||
}
|
}
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
serverSettings: this.db.serverSettings.toJSONForBrowser()
|
serverSettings: Database.serverSettings.toJSONForBrowser()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +148,7 @@ class MiscController {
|
|||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
const tags = []
|
const tags = []
|
||||||
this.db.libraryItems.forEach((li) => {
|
Database.libraryItems.forEach((li) => {
|
||||||
if (li.media.tags && li.media.tags.length) {
|
if (li.media.tags && li.media.tags.length) {
|
||||||
li.media.tags.forEach((tag) => {
|
li.media.tags.forEach((tag) => {
|
||||||
if (!tags.includes(tag)) tags.push(tag)
|
if (!tags.includes(tag)) tags.push(tag)
|
||||||
@@ -176,7 +177,7 @@ class MiscController {
|
|||||||
let tagMerged = false
|
let tagMerged = false
|
||||||
let numItemsUpdated = 0
|
let numItemsUpdated = 0
|
||||||
|
|
||||||
for (const li of this.db.libraryItems) {
|
for (const li of Database.libraryItems) {
|
||||||
if (!li.media.tags || !li.media.tags.length) continue
|
if (!li.media.tags || !li.media.tags.length) continue
|
||||||
|
|
||||||
if (li.media.tags.includes(newTag)) tagMerged = true // new tag is an existing tag so this is a merge
|
if (li.media.tags.includes(newTag)) tagMerged = true // new tag is an existing tag so this is a merge
|
||||||
@@ -187,7 +188,7 @@ class MiscController {
|
|||||||
li.media.tags.push(newTag) // Add new tag
|
li.media.tags.push(newTag) // Add new tag
|
||||||
}
|
}
|
||||||
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${li.media.metadata.title}"`)
|
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${li.media.metadata.title}"`)
|
||||||
await this.db.updateLibraryItem(li)
|
await Database.updateLibraryItem(li)
|
||||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||||
numItemsUpdated++
|
numItemsUpdated++
|
||||||
}
|
}
|
||||||
@@ -209,13 +210,13 @@ class MiscController {
|
|||||||
const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()
|
const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()
|
||||||
|
|
||||||
let numItemsUpdated = 0
|
let numItemsUpdated = 0
|
||||||
for (const li of this.db.libraryItems) {
|
for (const li of Database.libraryItems) {
|
||||||
if (!li.media.tags || !li.media.tags.length) continue
|
if (!li.media.tags || !li.media.tags.length) continue
|
||||||
|
|
||||||
if (li.media.tags.includes(tag)) {
|
if (li.media.tags.includes(tag)) {
|
||||||
li.media.tags = li.media.tags.filter(t => t !== tag)
|
li.media.tags = li.media.tags.filter(t => t !== tag)
|
||||||
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${li.media.metadata.title}"`)
|
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${li.media.metadata.title}"`)
|
||||||
await this.db.updateLibraryItem(li)
|
await Database.updateLibraryItem(li)
|
||||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||||
numItemsUpdated++
|
numItemsUpdated++
|
||||||
}
|
}
|
||||||
@@ -233,7 +234,7 @@ class MiscController {
|
|||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
const genres = []
|
const genres = []
|
||||||
this.db.libraryItems.forEach((li) => {
|
Database.libraryItems.forEach((li) => {
|
||||||
if (li.media.metadata.genres && li.media.metadata.genres.length) {
|
if (li.media.metadata.genres && li.media.metadata.genres.length) {
|
||||||
li.media.metadata.genres.forEach((genre) => {
|
li.media.metadata.genres.forEach((genre) => {
|
||||||
if (!genres.includes(genre)) genres.push(genre)
|
if (!genres.includes(genre)) genres.push(genre)
|
||||||
@@ -262,7 +263,7 @@ class MiscController {
|
|||||||
let genreMerged = false
|
let genreMerged = false
|
||||||
let numItemsUpdated = 0
|
let numItemsUpdated = 0
|
||||||
|
|
||||||
for (const li of this.db.libraryItems) {
|
for (const li of Database.libraryItems) {
|
||||||
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
|
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
|
||||||
|
|
||||||
if (li.media.metadata.genres.includes(newGenre)) genreMerged = true // new genre is an existing genre so this is a merge
|
if (li.media.metadata.genres.includes(newGenre)) genreMerged = true // new genre is an existing genre so this is a merge
|
||||||
@@ -273,7 +274,7 @@ class MiscController {
|
|||||||
li.media.metadata.genres.push(newGenre) // Add new genre
|
li.media.metadata.genres.push(newGenre) // Add new genre
|
||||||
}
|
}
|
||||||
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${li.media.metadata.title}"`)
|
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${li.media.metadata.title}"`)
|
||||||
await this.db.updateLibraryItem(li)
|
await Database.updateLibraryItem(li)
|
||||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||||
numItemsUpdated++
|
numItemsUpdated++
|
||||||
}
|
}
|
||||||
@@ -295,13 +296,13 @@ class MiscController {
|
|||||||
const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()
|
const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()
|
||||||
|
|
||||||
let numItemsUpdated = 0
|
let numItemsUpdated = 0
|
||||||
for (const li of this.db.libraryItems) {
|
for (const li of Database.libraryItems) {
|
||||||
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
|
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
|
||||||
|
|
||||||
if (li.media.metadata.genres.includes(genre)) {
|
if (li.media.metadata.genres.includes(genre)) {
|
||||||
li.media.metadata.genres = li.media.metadata.genres.filter(t => t !== genre)
|
li.media.metadata.genres = li.media.metadata.genres.filter(t => t !== genre)
|
||||||
Logger.debug(`[MiscController] Remove genre "${genre}" from item "${li.media.metadata.title}"`)
|
Logger.debug(`[MiscController] Remove genre "${genre}" from item "${li.media.metadata.title}"`)
|
||||||
await this.db.updateLibraryItem(li)
|
await Database.updateLibraryItem(li)
|
||||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||||
numItemsUpdated++
|
numItemsUpdated++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const Database = require('../Database')
|
||||||
const { version } = require('../../package.json')
|
const { version } = require('../../package.json')
|
||||||
|
|
||||||
class NotificationController {
|
class NotificationController {
|
||||||
@@ -7,14 +8,14 @@ class NotificationController {
|
|||||||
get(req, res) {
|
get(req, res) {
|
||||||
res.json({
|
res.json({
|
||||||
data: this.notificationManager.getData(),
|
data: this.notificationManager.getData(),
|
||||||
settings: this.db.notificationSettings
|
settings: Database.notificationSettings
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
const updated = this.db.notificationSettings.update(req.body)
|
const updated = Database.notificationSettings.update(req.body)
|
||||||
if (updated) {
|
if (updated) {
|
||||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
await Database.updateSetting(Database.notificationSettings)
|
||||||
}
|
}
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
@@ -29,31 +30,31 @@ class NotificationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createNotification(req, res) {
|
async createNotification(req, res) {
|
||||||
const success = this.db.notificationSettings.createNotification(req.body)
|
const success = Database.notificationSettings.createNotification(req.body)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
await Database.updateSetting(Database.notificationSettings)
|
||||||
}
|
}
|
||||||
res.json(this.db.notificationSettings)
|
res.json(Database.notificationSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteNotification(req, res) {
|
async deleteNotification(req, res) {
|
||||||
if (this.db.notificationSettings.removeNotification(req.notification.id)) {
|
if (Database.notificationSettings.removeNotification(req.notification.id)) {
|
||||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
await Database.updateSetting(Database.notificationSettings)
|
||||||
}
|
}
|
||||||
res.json(this.db.notificationSettings)
|
res.json(Database.notificationSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateNotification(req, res) {
|
async updateNotification(req, res) {
|
||||||
const success = this.db.notificationSettings.updateNotification(req.body)
|
const success = Database.notificationSettings.updateNotification(req.body)
|
||||||
if (success) {
|
if (success) {
|
||||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
await Database.updateSetting(Database.notificationSettings)
|
||||||
}
|
}
|
||||||
res.json(this.db.notificationSettings)
|
res.json(Database.notificationSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendNotificationTest(req, res) {
|
async sendNotificationTest(req, res) {
|
||||||
if (!this.db.notificationSettings.isUseable) return res.status(500).send('Apprise is not configured')
|
if (!Database.notificationSettings.isUseable) return res.status(500).send('Apprise is not configured')
|
||||||
|
|
||||||
const success = await this.notificationManager.sendTestNotification(req.notification)
|
const success = await this.notificationManager.sendTestNotification(req.notification)
|
||||||
if (success) res.sendStatus(200)
|
if (success) res.sendStatus(200)
|
||||||
@@ -66,7 +67,7 @@ class NotificationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.params.id) {
|
if (req.params.id) {
|
||||||
const notification = this.db.notificationSettings.getNotification(req.params.id)
|
const notification = Database.notificationSettings.getNotification(req.params.id)
|
||||||
if (!notification) {
|
if (!notification) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
const Playlist = require('../objects/Playlist')
|
const Playlist = require('../objects/Playlist')
|
||||||
|
|
||||||
@@ -14,8 +15,8 @@ class PlaylistController {
|
|||||||
if (!success) {
|
if (!success) {
|
||||||
return res.status(400).send('Invalid playlist request data')
|
return res.status(400).send('Invalid playlist request data')
|
||||||
}
|
}
|
||||||
const jsonExpanded = newPlaylist.toJSONExpanded(this.db.libraryItems)
|
const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
|
||||||
await this.db.insertEntity('playlist', newPlaylist)
|
await Database.createPlaylist(newPlaylist)
|
||||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
@@ -23,22 +24,22 @@ class PlaylistController {
|
|||||||
// GET: api/playlists
|
// GET: api/playlists
|
||||||
findAllForUser(req, res) {
|
findAllForUser(req, res) {
|
||||||
res.json({
|
res.json({
|
||||||
playlists: this.db.playlists.filter(p => p.userId === req.user.id).map(p => p.toJSONExpanded(this.db.libraryItems))
|
playlists: Database.playlists.filter(p => p.userId === req.user.id).map(p => p.toJSONExpanded(Database.libraryItems))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/playlists/:id
|
// GET: api/playlists/:id
|
||||||
findOne(req, res) {
|
findOne(req, res) {
|
||||||
res.json(req.playlist.toJSONExpanded(this.db.libraryItems))
|
res.json(req.playlist.toJSONExpanded(Database.libraryItems))
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH: api/playlists/:id
|
// PATCH: api/playlists/:id
|
||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
const playlist = req.playlist
|
const playlist = req.playlist
|
||||||
let wasUpdated = playlist.update(req.body)
|
let wasUpdated = playlist.update(req.body)
|
||||||
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
|
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
await this.db.updateEntity('playlist', playlist)
|
await Database.updatePlaylist(playlist)
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||||
}
|
}
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
@@ -47,8 +48,8 @@ class PlaylistController {
|
|||||||
// DELETE: api/playlists/:id
|
// DELETE: api/playlists/:id
|
||||||
async delete(req, res) {
|
async delete(req, res) {
|
||||||
const playlist = req.playlist
|
const playlist = req.playlist
|
||||||
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
|
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||||
await this.db.removeEntity('playlist', playlist.id)
|
await Database.removePlaylist(playlist.id)
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
@@ -62,7 +63,7 @@ class PlaylistController {
|
|||||||
return res.status(400).send('Request body has no libraryItemId')
|
return res.status(400).send('Request body has no libraryItemId')
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryItem = this.db.libraryItems.find(li => li.id === itemToAdd.libraryItemId)
|
const libraryItem = Database.libraryItems.find(li => li.id === itemToAdd.libraryItemId)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(400).send('Library item not found')
|
return res.status(400).send('Library item not found')
|
||||||
}
|
}
|
||||||
@@ -80,8 +81,16 @@ class PlaylistController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
playlist.addItem(itemToAdd.libraryItemId, itemToAdd.episodeId)
|
playlist.addItem(itemToAdd.libraryItemId, itemToAdd.episodeId)
|
||||||
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
|
|
||||||
await this.db.updateEntity('playlist', playlist)
|
const playlistMediaItem = {
|
||||||
|
playlistId: playlist.id,
|
||||||
|
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
|
||||||
|
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
|
||||||
|
order: playlist.items.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||||
|
await Database.createPlaylistMediaItem(playlistMediaItem)
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
@@ -99,15 +108,15 @@ class PlaylistController {
|
|||||||
|
|
||||||
playlist.removeItem(itemToRemove.libraryItemId, itemToRemove.episodeId)
|
playlist.removeItem(itemToRemove.libraryItemId, itemToRemove.episodeId)
|
||||||
|
|
||||||
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
|
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||||
|
|
||||||
// Playlist is removed when there are no items
|
// Playlist is removed when there are no items
|
||||||
if (!playlist.items.length) {
|
if (!playlist.items.length) {
|
||||||
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
|
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
|
||||||
await this.db.removeEntity('playlist', playlist.id)
|
await Database.removePlaylist(playlist.id)
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||||
} else {
|
} else {
|
||||||
await this.db.updateEntity('playlist', playlist)
|
await Database.updatePlaylist(playlist)
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,20 +131,34 @@ class PlaylistController {
|
|||||||
}
|
}
|
||||||
const itemsToAdd = req.body.items
|
const itemsToAdd = req.body.items
|
||||||
let hasUpdated = false
|
let hasUpdated = false
|
||||||
|
|
||||||
|
let order = playlist.items.length
|
||||||
|
const playlistMediaItems = []
|
||||||
for (const item of itemsToAdd) {
|
for (const item of itemsToAdd) {
|
||||||
if (!item.libraryItemId) {
|
if (!item.libraryItemId) {
|
||||||
return res.status(400).send('Item does not have libraryItemId')
|
return res.status(400).send('Item does not have libraryItemId')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const libraryItem = Database.getLibraryItem(item.libraryItemId)
|
||||||
|
if (!libraryItem) {
|
||||||
|
return res.status(400).send('Item not found with id ' + item.libraryItemId)
|
||||||
|
}
|
||||||
|
|
||||||
if (!playlist.containsItem(item)) {
|
if (!playlist.containsItem(item)) {
|
||||||
|
playlistMediaItems.push({
|
||||||
|
playlistId: playlist.id,
|
||||||
|
mediaItemId: item.episodeId || libraryItem.media.id, // podcastEpisodeId or bookId
|
||||||
|
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||||
|
order: order++
|
||||||
|
})
|
||||||
playlist.addItem(item.libraryItemId, item.episodeId)
|
playlist.addItem(item.libraryItemId, item.episodeId)
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
|
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
await this.db.updateEntity('playlist', playlist)
|
await Database.createBulkPlaylistMediaItems(playlistMediaItems)
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||||
}
|
}
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
@@ -153,21 +176,22 @@ class PlaylistController {
|
|||||||
if (!item.libraryItemId) {
|
if (!item.libraryItemId) {
|
||||||
return res.status(400).send('Item does not have libraryItemId')
|
return res.status(400).send('Item does not have libraryItemId')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playlist.containsItem(item)) {
|
if (playlist.containsItem(item)) {
|
||||||
playlist.removeItem(item.libraryItemId, item.episodeId)
|
playlist.removeItem(item.libraryItemId, item.episodeId)
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
|
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
// Playlist is removed when there are no items
|
// Playlist is removed when there are no items
|
||||||
if (!playlist.items.length) {
|
if (!playlist.items.length) {
|
||||||
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
|
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
|
||||||
await this.db.removeEntity('playlist', playlist.id)
|
await Database.removePlaylist(playlist.id)
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||||
} else {
|
} else {
|
||||||
await this.db.updateEntity('playlist', playlist)
|
await Database.updatePlaylist(playlist)
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,12 +200,12 @@ class PlaylistController {
|
|||||||
|
|
||||||
// POST: api/playlists/collection/:collectionId
|
// POST: api/playlists/collection/:collectionId
|
||||||
async createFromCollection(req, res) {
|
async createFromCollection(req, res) {
|
||||||
let collection = this.db.collections.find(c => c.id === req.params.collectionId)
|
let collection = Database.collections.find(c => c.id === req.params.collectionId)
|
||||||
if (!collection) {
|
if (!collection) {
|
||||||
return res.status(404).send('Collection not found')
|
return res.status(404).send('Collection not found')
|
||||||
}
|
}
|
||||||
// Expand collection to get library items
|
// Expand collection to get library items
|
||||||
collection = collection.toJSONExpanded(this.db.libraryItems)
|
collection = collection.toJSONExpanded(Database.libraryItems)
|
||||||
|
|
||||||
// Filter out library items not accessible to user
|
// Filter out library items not accessible to user
|
||||||
const libraryItems = collection.books.filter(item => req.user.checkCanAccessLibraryItem(item))
|
const libraryItems = collection.books.filter(item => req.user.checkCanAccessLibraryItem(item))
|
||||||
@@ -201,15 +225,15 @@ class PlaylistController {
|
|||||||
}
|
}
|
||||||
newPlaylist.setData(newPlaylistData)
|
newPlaylist.setData(newPlaylistData)
|
||||||
|
|
||||||
const jsonExpanded = newPlaylist.toJSONExpanded(this.db.libraryItems)
|
const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
|
||||||
await this.db.insertEntity('playlist', newPlaylist)
|
await Database.createPlaylist(newPlaylist)
|
||||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
if (req.params.id) {
|
if (req.params.id) {
|
||||||
const playlist = this.db.playlists.find(p => p.id === req.params.id)
|
const playlist = Database.playlists.find(p => p.id === req.params.id)
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
return res.status(404).send('Playlist not found')
|
return res.status(404).send('Playlist not found')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
const payload = req.body
|
const payload = req.body
|
||||||
|
|
||||||
const library = this.db.libraries.find(lib => lib.id === payload.libraryId)
|
const library = Database.libraries.find(lib => lib.id === payload.libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
|
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
|
||||||
return res.status(404).send('Library not found')
|
return res.status(404).send('Library not found')
|
||||||
@@ -33,7 +34,7 @@ class PodcastController {
|
|||||||
const podcastPath = filePathToPOSIX(payload.path)
|
const podcastPath = filePathToPOSIX(payload.path)
|
||||||
|
|
||||||
// Check if a library item with this podcast folder exists already
|
// Check if a library item with this podcast folder exists already
|
||||||
const existingLibraryItem = this.db.libraryItems.find(li => li.path === podcastPath && li.libraryId === library.id)
|
const existingLibraryItem = Database.libraryItems.find(li => li.path === podcastPath && li.libraryId === library.id)
|
||||||
if (existingLibraryItem) {
|
if (existingLibraryItem) {
|
||||||
Logger.error(`[PodcastController] Podcast already exists with name "${existingLibraryItem.media.metadata.title}" at path "${podcastPath}"`)
|
Logger.error(`[PodcastController] Podcast already exists with name "${existingLibraryItem.media.metadata.title}" at path "${podcastPath}"`)
|
||||||
return res.status(400).send('Podcast already exists')
|
return res.status(400).send('Podcast already exists')
|
||||||
@@ -80,7 +81,7 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.insertLibraryItem(libraryItem)
|
await Database.createLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
|
||||||
|
|
||||||
res.json(libraryItem.toJSONExpanded())
|
res.json(libraryItem.toJSONExpanded())
|
||||||
@@ -199,7 +200,7 @@ class PodcastController {
|
|||||||
const overrideDetails = req.query.override === '1'
|
const overrideDetails = req.query.override === '1'
|
||||||
const episodesUpdated = await this.scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
|
const episodesUpdated = await this.scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
|
||||||
if (episodesUpdated) {
|
if (episodesUpdated) {
|
||||||
await this.db.updateLibraryItem(req.libraryItem)
|
await Database.updateLibraryItem(req.libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,9 +217,8 @@ class PodcastController {
|
|||||||
return res.status(404).send('Episode not found')
|
return res.status(404).send('Episode not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
var wasUpdated = libraryItem.media.updateEpisode(episodeId, req.body)
|
if (libraryItem.media.updateEpisode(episodeId, req.body)) {
|
||||||
if (wasUpdated) {
|
await Database.updateLibraryItem(libraryItem)
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,13 +267,13 @@ class PodcastController {
|
|||||||
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
|
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
res.json(libraryItem.toJSON())
|
res.json(libraryItem.toJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
const item = this.db.libraryItems.find(li => li.id === req.params.id)
|
const item = Database.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!item || !item.media) return res.sendStatus(404)
|
if (!item || !item.media) return res.sendStatus(404)
|
||||||
|
|
||||||
if (!item.isPodcast) {
|
if (!item.isPodcast) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const Database = require('../Database')
|
||||||
|
|
||||||
class RSSFeedController {
|
class RSSFeedController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
@@ -8,7 +8,7 @@ class RSSFeedController {
|
|||||||
async openRSSFeedForItem(req, res) {
|
async openRSSFeedForItem(req, res) {
|
||||||
const options = req.body || {}
|
const options = req.body || {}
|
||||||
|
|
||||||
const item = this.db.libraryItems.find(li => li.id === req.params.itemId)
|
const item = Database.libraryItems.find(li => li.id === req.params.itemId)
|
||||||
if (!item) return res.sendStatus(404)
|
if (!item) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check user can access this library item
|
// Check user can access this library item
|
||||||
@@ -30,7 +30,7 @@ class RSSFeedController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||||
if (this.rssFeedManager.feeds[options.slug]) {
|
if (this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||||
return res.status(400).send('Slug already in use')
|
return res.status(400).send('Slug already in use')
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ class RSSFeedController {
|
|||||||
async openRSSFeedForCollection(req, res) {
|
async openRSSFeedForCollection(req, res) {
|
||||||
const options = req.body || {}
|
const options = req.body || {}
|
||||||
|
|
||||||
const collection = this.db.collections.find(li => li.id === req.params.collectionId)
|
const collection = Database.collections.find(li => li.id === req.params.collectionId)
|
||||||
if (!collection) return res.sendStatus(404)
|
if (!collection) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check request body options exist
|
// Check request body options exist
|
||||||
@@ -55,12 +55,12 @@ class RSSFeedController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||||
if (this.rssFeedManager.feeds[options.slug]) {
|
if (this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||||
return res.status(400).send('Slug already in use')
|
return res.status(400).send('Slug already in use')
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectionExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
const collectionExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||||
const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length)
|
const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length)
|
||||||
|
|
||||||
// Check collection has audio tracks
|
// Check collection has audio tracks
|
||||||
@@ -79,7 +79,7 @@ class RSSFeedController {
|
|||||||
async openRSSFeedForSeries(req, res) {
|
async openRSSFeedForSeries(req, res) {
|
||||||
const options = req.body || {}
|
const options = req.body || {}
|
||||||
|
|
||||||
const series = this.db.series.find(se => se.id === req.params.seriesId)
|
const series = Database.series.find(se => se.id === req.params.seriesId)
|
||||||
if (!series) return res.sendStatus(404)
|
if (!series) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check request body options exist
|
// Check request body options exist
|
||||||
@@ -89,14 +89,14 @@ class RSSFeedController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||||
if (this.rssFeedManager.feeds[options.slug]) {
|
if (this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||||
return res.status(400).send('Slug already in use')
|
return res.status(400).send('Slug already in use')
|
||||||
}
|
}
|
||||||
|
|
||||||
const seriesJson = series.toJSON()
|
const seriesJson = series.toJSON()
|
||||||
// Get books in series that have audio tracks
|
// Get books in series that have audio tracks
|
||||||
seriesJson.books = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
|
seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
|
||||||
|
|
||||||
// Check series has audio tracks
|
// Check series has audio tracks
|
||||||
if (!seriesJson.books.length) {
|
if (!seriesJson.books.length) {
|
||||||
@@ -111,10 +111,8 @@ class RSSFeedController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/feeds/:id/close
|
// POST: api/feeds/:id/close
|
||||||
async closeRSSFeed(req, res) {
|
closeRSSFeed(req, res) {
|
||||||
await this.rssFeedManager.closeRssFeed(req.params.id)
|
this.rssFeedManager.closeRssFeed(req, res)
|
||||||
|
|
||||||
res.sendStatus(200)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
@@ -123,14 +121,6 @@ class RSSFeedController {
|
|||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.params.id) {
|
|
||||||
const feed = this.rssFeedManager.findFeed(req.params.id)
|
|
||||||
if (!feed) {
|
|
||||||
Logger.error(`[RSSFeedController] RSS feed not found with id "${req.params.id}"`)
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
class SeriesController {
|
class SeriesController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* /api/series/:id
|
||||||
|
*
|
||||||
|
* TODO: Update mobile app to use /api/libraries/:id/series/:seriesId API route instead
|
||||||
|
* Series are not library specific so we need to know what the library id is
|
||||||
|
*
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async findOne(req, res) {
|
async findOne(req, res) {
|
||||||
const include = (req.query.include || '').split(',').map(v => v.trim()).filter(v => !!v)
|
const include = (req.query.include || '').split(',').map(v => v.trim()).filter(v => !!v)
|
||||||
|
|
||||||
@@ -28,14 +39,14 @@ class SeriesController {
|
|||||||
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json(seriesJson)
|
res.json(seriesJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(req, res) {
|
async search(req, res) {
|
||||||
var q = (req.query.q || '').toLowerCase()
|
var q = (req.query.q || '').toLowerCase()
|
||||||
if (!q) return res.json([])
|
if (!q) return res.json([])
|
||||||
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
|
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
|
||||||
var series = this.db.series.filter(se => se.name.toLowerCase().includes(q))
|
var series = Database.series.filter(se => se.name.toLowerCase().includes(q))
|
||||||
series = series.slice(0, limit)
|
series = series.slice(0, limit)
|
||||||
res.json({
|
res.json({
|
||||||
results: series
|
results: series
|
||||||
@@ -45,19 +56,23 @@ class SeriesController {
|
|||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
const hasUpdated = req.series.update(req.body)
|
const hasUpdated = req.series.update(req.body)
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
await this.db.updateEntity('series', req.series)
|
await Database.updateSeries(req.series)
|
||||||
SocketAuthority.emitter('series_updated', req.series.toJSON())
|
SocketAuthority.emitter('series_updated', req.series.toJSON())
|
||||||
}
|
}
|
||||||
res.json(req.series.toJSON())
|
res.json(req.series.toJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
const series = this.db.series.find(se => se.id === req.params.id)
|
const series = Database.series.find(se => se.id === req.params.id)
|
||||||
if (!series) return res.sendStatus(404)
|
if (!series) return res.sendStatus(404)
|
||||||
|
|
||||||
const libraryItemsInSeries = this.db.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
|
/**
|
||||||
if (libraryItemsInSeries.some(li => !req.user.checkCanAccessLibrary(li.libraryId))) {
|
* Filter out any library items not accessible to user
|
||||||
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to the library`, req.user)
|
*/
|
||||||
|
const libraryItems = Database.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
|
||||||
|
const libraryItemsAccessible = libraryItems.filter(req.user.checkCanAccessLibraryItem)
|
||||||
|
if (libraryItems.length && !libraryItemsAccessible.length) {
|
||||||
|
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to any of the books`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +85,7 @@ class SeriesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.series = series
|
req.series = series
|
||||||
req.libraryItemsInSeries = libraryItemsInSeries
|
req.libraryItemsInSeries = libraryItemsAccessible
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const Database = require('../Database')
|
||||||
const { toNumber } = require('../utils/index')
|
const { toNumber } = require('../utils/index')
|
||||||
|
|
||||||
class SessionController {
|
class SessionController {
|
||||||
@@ -49,7 +50,7 @@ class SessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openSessions = this.playbackSessionManager.sessions.map(se => {
|
const openSessions = this.playbackSessionManager.sessions.map(se => {
|
||||||
const user = this.db.users.find(u => u.id === se.userId) || null
|
const user = Database.users.find(u => u.id === se.userId) || null
|
||||||
return {
|
return {
|
||||||
...se.toJSON(),
|
...se.toJSON(),
|
||||||
user: user ? { id: user.id, username: user.username } : null
|
user: user ? { id: user.id, username: user.username } : null
|
||||||
@@ -62,7 +63,7 @@ class SessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getOpenSession(req, res) {
|
getOpenSession(req, res) {
|
||||||
var libraryItem = this.db.getLibraryItem(req.session.libraryItemId)
|
var libraryItem = Database.getLibraryItem(req.session.libraryItemId)
|
||||||
var sessionForClient = req.session.toJSONForClient(libraryItem)
|
var sessionForClient = req.session.toJSONForClient(libraryItem)
|
||||||
res.json(sessionForClient)
|
res.json(sessionForClient)
|
||||||
}
|
}
|
||||||
@@ -87,7 +88,7 @@ class SessionController {
|
|||||||
await this.playbackSessionManager.removeSession(req.session.id)
|
await this.playbackSessionManager.removeSession(req.session.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.removeEntity('session', req.session.id)
|
await Database.removePlaybackSession(req.session.id)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ class SessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
const playbackSession = await this.db.getPlaybackSession(req.params.id)
|
const playbackSession = await Database.getPlaybackSession(req.params.id)
|
||||||
if (!playbackSession) {
|
if (!playbackSession) {
|
||||||
Logger.error(`[SessionController] Unable to find playback session with id=${req.params.id}`)
|
Logger.error(`[SessionController] Unable to find playback session with id=${req.params.id}`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
class ToolsController {
|
class ToolsController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
@@ -65,7 +66,7 @@ class ToolsController {
|
|||||||
|
|
||||||
const libraryItems = []
|
const libraryItems = []
|
||||||
for (const libraryItemId of libraryItemIds) {
|
for (const libraryItemId of libraryItemIds) {
|
||||||
const libraryItem = this.db.getLibraryItem(libraryItemId)
|
const libraryItem = Database.getLibraryItem(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)
|
||||||
@@ -105,7 +106,7 @@ class ToolsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.params.id) {
|
if (req.params.id) {
|
||||||
const item = this.db.libraryItems.find(li => li.id === req.params.id)
|
const item = Database.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!item || !item.media) return res.sendStatus(404)
|
if (!item || !item.media) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check user can access this library item
|
// Check user can access this library item
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
|
const uuidv4 = require("uuid").v4
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
const User = require('../objects/user/User')
|
const User = require('../objects/user/User')
|
||||||
|
|
||||||
const { getId, toNumber } = require('../utils/index')
|
const { toNumber } = require('../utils/index')
|
||||||
|
|
||||||
class UserController {
|
class UserController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
findAll(req, res) {
|
async findAll(req, res) {
|
||||||
if (!req.user.isAdminOrUp) return res.sendStatus(403)
|
if (!req.user.isAdminOrUp) return res.sendStatus(403)
|
||||||
const hideRootToken = !req.user.isRoot
|
const hideRootToken = !req.user.isRoot
|
||||||
|
|
||||||
|
const includes = (req.query.include || '').split(',').map(i => i.trim())
|
||||||
|
|
||||||
|
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
|
||||||
|
const users = Database.users.map(u => u.toJSONForBrowser(hideRootToken, true))
|
||||||
|
|
||||||
|
if (includes.includes('latestSession')) {
|
||||||
|
for (const user of users) {
|
||||||
|
const userSessions = await Database.getPlaybackSessions({ userId: user.id })
|
||||||
|
user.latestSession = userSessions.sort((a, b) => b.updatedAt - a.updatedAt).shift() || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
|
users
|
||||||
users: this.db.users.map(u => u.toJSONForBrowser(hideRootToken, true))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +37,7 @@ class UserController {
|
|||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = this.db.users.find(u => u.id === req.params.id)
|
const user = Database.users.find(u => u.id === req.params.id)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
@@ -35,18 +49,19 @@ class UserController {
|
|||||||
var account = req.body
|
var account = req.body
|
||||||
|
|
||||||
var username = account.username
|
var username = account.username
|
||||||
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase())
|
var usernameExists = Database.users.find(u => u.username.toLowerCase() === username.toLowerCase())
|
||||||
if (usernameExists) {
|
if (usernameExists) {
|
||||||
return res.status(500).send('Username already taken')
|
return res.status(500).send('Username already taken')
|
||||||
}
|
}
|
||||||
|
|
||||||
account.id = getId('usr')
|
account.id = uuidv4()
|
||||||
account.pash = await this.auth.hashPass(account.password)
|
account.pash = await this.auth.hashPass(account.password)
|
||||||
delete account.password
|
delete account.password
|
||||||
account.token = await this.auth.generateAccessToken({ userId: account.id, username })
|
account.token = await this.auth.generateAccessToken({ userId: account.id, username })
|
||||||
account.createdAt = Date.now()
|
account.createdAt = Date.now()
|
||||||
var newUser = new User(account)
|
const newUser = new User(account)
|
||||||
var success = await this.db.insertEntity('user', newUser)
|
|
||||||
|
const success = await Database.createUser(newUser)
|
||||||
if (success) {
|
if (success) {
|
||||||
SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser())
|
SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser())
|
||||||
res.json({
|
res.json({
|
||||||
@@ -69,7 +84,7 @@ class UserController {
|
|||||||
var shouldUpdateToken = false
|
var shouldUpdateToken = false
|
||||||
|
|
||||||
if (account.username !== undefined && account.username !== user.username) {
|
if (account.username !== undefined && account.username !== user.username) {
|
||||||
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === account.username.toLowerCase())
|
var usernameExists = Database.users.find(u => u.username.toLowerCase() === account.username.toLowerCase())
|
||||||
if (usernameExists) {
|
if (usernameExists) {
|
||||||
return res.status(500).send('Username already taken')
|
return res.status(500).send('Username already taken')
|
||||||
}
|
}
|
||||||
@@ -82,13 +97,12 @@ class UserController {
|
|||||||
delete account.password
|
delete account.password
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasUpdated = user.update(account)
|
if (user.update(account)) {
|
||||||
if (hasUpdated) {
|
|
||||||
if (shouldUpdateToken) {
|
if (shouldUpdateToken) {
|
||||||
user.token = await this.auth.generateAccessToken({ userId: user.id, username: user.username })
|
user.token = await this.auth.generateAccessToken({ userId: user.id, username: user.username })
|
||||||
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
|
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
|
||||||
}
|
}
|
||||||
await this.db.updateEntity('user', user)
|
await Database.updateUser(user)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,13 +126,13 @@ class UserController {
|
|||||||
// Todo: check if user is logged in and cancel streams
|
// Todo: check if user is logged in and cancel streams
|
||||||
|
|
||||||
// Remove user playlists
|
// Remove user playlists
|
||||||
const userPlaylists = this.db.playlists.filter(p => p.userId === user.id)
|
const userPlaylists = Database.playlists.filter(p => p.userId === user.id)
|
||||||
for (const playlist of userPlaylists) {
|
for (const playlist of userPlaylists) {
|
||||||
await this.db.removeEntity('playlist', playlist.id)
|
await Database.removePlaylist(playlist.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const userJson = user.toJSONForBrowser()
|
const userJson = user.toJSONForBrowser()
|
||||||
await this.db.removeEntity('user', user.id)
|
await Database.removeUser(user.id)
|
||||||
SocketAuthority.adminEmitter('user_removed', userJson)
|
SocketAuthority.adminEmitter('user_removed', userJson)
|
||||||
res.json({
|
res.json({
|
||||||
success: true
|
success: true
|
||||||
@@ -152,40 +166,6 @@ class UserController {
|
|||||||
res.json(listeningStats)
|
res.json(listeningStats)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/users/:id/purge-media-progress
|
|
||||||
async purgeMediaProgress(req, res) {
|
|
||||||
const user = req.reqUser
|
|
||||||
|
|
||||||
if (user.type === 'root' && !req.user.isRoot) {
|
|
||||||
Logger.error(`[UserController] Admin user attempted to purge media progress of root user`, req.user.username)
|
|
||||||
return res.sendStatus(403)
|
|
||||||
}
|
|
||||||
|
|
||||||
var progressPurged = 0
|
|
||||||
user.mediaProgress = user.mediaProgress.filter(mp => {
|
|
||||||
const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId)
|
|
||||||
if (!libraryItem) {
|
|
||||||
progressPurged++
|
|
||||||
return false
|
|
||||||
} else if (mp.episodeId) {
|
|
||||||
const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(mp.episodeId) : null
|
|
||||||
if (!episode) { // Episode not found
|
|
||||||
progressPurged++
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (progressPurged) {
|
|
||||||
Logger.info(`[UserController] Purged ${progressPurged} media progress for user ${user.username}`)
|
|
||||||
await this.db.updateEntity('user', user)
|
|
||||||
SocketAuthority.adminEmitter('user_updated', user.toJSONForBrowser())
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot))
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST: api/users/online (admin)
|
// POST: api/users/online (admin)
|
||||||
async getOnlineUsers(req, res) {
|
async getOnlineUsers(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
@@ -206,7 +186,7 @@ class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.params.id) {
|
if (req.params.id) {
|
||||||
req.reqUser = this.db.users.find(u => u.id === req.params.id)
|
req.reqUser = Database.users.find(u => u.id === req.params.id)
|
||||||
if (!req.reqUser) {
|
if (!req.reqUser) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const itemDb = require('../db/item.db')
|
||||||
|
|
||||||
|
const getLibraryItem = async (req, res) => {
|
||||||
|
let libraryItem = null
|
||||||
|
if (req.query.expanded == 1) {
|
||||||
|
libraryItem = await itemDb.getLibraryItemExpanded(req.params.id)
|
||||||
|
} else {
|
||||||
|
libraryItem = await itemDb.getLibraryItemMinified(req.params.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(libraryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getLibraryItem
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* TODO: Unused for testing
|
||||||
|
*/
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
|
const getLibraryItemMinified = (libraryItemId) => {
|
||||||
|
return Database.models.libraryItem.findByPk(libraryItemId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.models.book,
|
||||||
|
attributes: [
|
||||||
|
'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags'
|
||||||
|
],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.models.author,
|
||||||
|
attributes: ['id', 'name'],
|
||||||
|
through: {
|
||||||
|
attributes: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.models.series,
|
||||||
|
attributes: ['id', 'name'],
|
||||||
|
through: {
|
||||||
|
attributes: ['sequence']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.models.podcast,
|
||||||
|
attributes: [
|
||||||
|
'id', 'title', 'author', 'releaseDate', 'feedURL', 'imageURL', 'description', 'itunesPageURL', 'itunesId', 'itunesArtistId', 'language', 'podcastType', 'explicit', 'autoDownloadEpisodes', 'genres', 'tags',
|
||||||
|
[Sequelize.literal('(SELECT COUNT(*) FROM "podcastEpisodes" WHERE "podcastEpisodes"."podcastId" = podcast.id)'), 'numPodcastEpisodes']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLibraryItemExpanded = (libraryItemId) => {
|
||||||
|
return Database.models.libraryItem.findByPk(libraryItemId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.models.book,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.models.author,
|
||||||
|
through: {
|
||||||
|
attributes: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.models.series,
|
||||||
|
through: {
|
||||||
|
attributes: ['sequence']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.models.podcast,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.models.podcastEpisode
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'libraryFolder',
|
||||||
|
'library'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getLibraryItemMinified,
|
||||||
|
getLibraryItemExpanded
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
Copyright 2021 James BonTempo (jamesbontempo@gmail.com)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
@@ -1,489 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
const {
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
writeFileSync
|
|
||||||
} = require("graceful-fs");
|
|
||||||
|
|
||||||
const {
|
|
||||||
join,
|
|
||||||
resolve
|
|
||||||
} = require("path");
|
|
||||||
|
|
||||||
const {
|
|
||||||
aggregateStoreData,
|
|
||||||
aggregateStoreDataSync,
|
|
||||||
distributeStoreData,
|
|
||||||
distributeStoreDataSync,
|
|
||||||
deleteStoreData,
|
|
||||||
deleteStoreDataSync,
|
|
||||||
dropEverything,
|
|
||||||
dropEverythingSync,
|
|
||||||
getStoreNames,
|
|
||||||
getStoreNamesSync,
|
|
||||||
insertStoreData,
|
|
||||||
insertStoreDataSync,
|
|
||||||
insertFileData,
|
|
||||||
selectStoreData,
|
|
||||||
selectStoreDataSync,
|
|
||||||
statsStoreData,
|
|
||||||
statsStoreDataSync,
|
|
||||||
updateStoreData,
|
|
||||||
updateStoreDataSync
|
|
||||||
} = require("./njodb");
|
|
||||||
|
|
||||||
const {
|
|
||||||
Randomizer,
|
|
||||||
Reducer,
|
|
||||||
Result
|
|
||||||
} = require("./objects");
|
|
||||||
|
|
||||||
const {
|
|
||||||
validateArray,
|
|
||||||
validateFunction,
|
|
||||||
validateName,
|
|
||||||
validateObject,
|
|
||||||
validatePath,
|
|
||||||
validateSize
|
|
||||||
} = require("./validators");
|
|
||||||
|
|
||||||
const defaults = {
|
|
||||||
"datadir": "data",
|
|
||||||
"dataname": "data",
|
|
||||||
"datastores": 5,
|
|
||||||
"tempdir": "tmp",
|
|
||||||
"lockoptions": {
|
|
||||||
"stale": 5000,
|
|
||||||
"update": 1000,
|
|
||||||
"retries": {
|
|
||||||
"retries": 5000,
|
|
||||||
"minTimeout": 250,
|
|
||||||
"maxTimeout": 5000,
|
|
||||||
"factor": 0.15,
|
|
||||||
"randomize": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mergeProperties = (defaults, userProperties) => {
|
|
||||||
var target = Object.assign({}, defaults);
|
|
||||||
|
|
||||||
for (let key of Object.keys(userProperties)) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
|
||||||
if (typeof userProperties[key] !== 'object' && !Array.isArray(userProperties[key])) {
|
|
||||||
Object.assign(target, { [key]: userProperties[key] });
|
|
||||||
} else {
|
|
||||||
target[key] = mergeProperties(target[key], userProperties[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveProperties = (root, properties) => {
|
|
||||||
properties = {
|
|
||||||
"datadir": properties.datadir,
|
|
||||||
"dataname": properties.dataname,
|
|
||||||
"datastores": properties.datastores,
|
|
||||||
"tempdir": properties.tempdir,
|
|
||||||
"lockoptions": properties.lockoptions
|
|
||||||
};
|
|
||||||
const propertiesFile = join(root, "njodb.properties");
|
|
||||||
writeFileSync(propertiesFile, JSON.stringify(properties, null, 4));
|
|
||||||
return properties;
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on("uncaughtException", error => {
|
|
||||||
if (error.code === "ECOMPROMISED") {
|
|
||||||
console.error(Object.assign(new Error("Stale lock or attempt to update it after release"), { code: error.code }));
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
class Database {
|
|
||||||
|
|
||||||
constructor(root, properties = {}) {
|
|
||||||
validateObject(properties);
|
|
||||||
|
|
||||||
this.properties = {};
|
|
||||||
|
|
||||||
if (root !== undefined && root !== null) {
|
|
||||||
validateName(root);
|
|
||||||
this.properties.root = root;
|
|
||||||
} else {
|
|
||||||
this.properties.root = process.cwd();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(this.properties.root)) mkdirSync(this.properties.root);
|
|
||||||
|
|
||||||
const propertiesFile = join(this.properties.root, "njodb.properties");
|
|
||||||
|
|
||||||
if (existsSync(propertiesFile)) {
|
|
||||||
this.setProperties(JSON.parse(readFileSync(propertiesFile)));
|
|
||||||
} else {
|
|
||||||
this.setProperties(mergeProperties(defaults, properties));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(this.properties.datapath)) mkdirSync(this.properties.datapath);
|
|
||||||
if (!existsSync(this.properties.temppath)) mkdirSync(this.properties.temppath);
|
|
||||||
|
|
||||||
this.properties.storenames = getStoreNamesSync(this.properties.datapath, this.properties.dataname);
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Database management methods
|
|
||||||
|
|
||||||
getProperties() {
|
|
||||||
return this.properties;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProperties(properties) {
|
|
||||||
validateObject(properties);
|
|
||||||
|
|
||||||
this.properties.datadir = (validateName(properties.datadir)) ? properties.datadir : defaults.datadir;
|
|
||||||
this.properties.dataname = (validateName(properties.dataname)) ? properties.dataname : defaults.dataname;
|
|
||||||
this.properties.datastores = (validateSize(properties.datastores)) ? properties.datastores : defaults.datastores;
|
|
||||||
this.properties.tempdir = (validateName(properties.tempdir)) ? properties.tempdir : defaults.tempdir;
|
|
||||||
this.properties.lockoptions = (validateObject(properties.lockoptions)) ? properties.lockoptions : defaults.lockoptions;
|
|
||||||
this.properties.datapath = join(this.properties.root, this.properties.datadir);
|
|
||||||
this.properties.temppath = join(this.properties.root, this.properties.tempdir);
|
|
||||||
|
|
||||||
saveProperties(this.properties.root, this.properties);
|
|
||||||
|
|
||||||
return this.properties;
|
|
||||||
}
|
|
||||||
|
|
||||||
async stats() {
|
|
||||||
var stats = {
|
|
||||||
root: resolve(this.properties.root),
|
|
||||||
data: resolve(this.properties.datapath),
|
|
||||||
temp: resolve(this.properties.temppath)
|
|
||||||
};
|
|
||||||
|
|
||||||
var promises = [];
|
|
||||||
|
|
||||||
for (const storename of this.properties.storenames) {
|
|
||||||
const storepath = join(this.properties.datapath, storename);
|
|
||||||
promises.push(statsStoreData(storepath, this.properties.lockoptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
|
||||||
|
|
||||||
return Object.assign(stats, Reducer("stats", results));
|
|
||||||
}
|
|
||||||
|
|
||||||
statsSync() {
|
|
||||||
var stats = {
|
|
||||||
root: resolve(this.properties.root),
|
|
||||||
data: resolve(this.properties.datapath),
|
|
||||||
temp: resolve(this.properties.temppath)
|
|
||||||
};
|
|
||||||
|
|
||||||
var results = [];
|
|
||||||
|
|
||||||
for (const storename of this.properties.storenames) {
|
|
||||||
const storepath = join(this.properties.datapath, storename);
|
|
||||||
results.push(statsStoreDataSync(storepath));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.assign(stats, Reducer("stats", results));
|
|
||||||
}
|
|
||||||
|
|
||||||
async grow() {
|
|
||||||
this.properties.datastores++;
|
|
||||||
const results = await distributeStoreData(this.properties);
|
|
||||||
this.properties.storenames = await getStoreNames(this.properties.datapath, this.properties.dataname);
|
|
||||||
saveProperties(this.properties.root, this.properties);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
growSync() {
|
|
||||||
this.properties.datastores++;
|
|
||||||
const results = distributeStoreDataSync(this.properties);
|
|
||||||
this.properties.storenames = getStoreNamesSync(this.properties.datapath, this.properties.dataname);
|
|
||||||
saveProperties(this.properties.root, this.properties);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
async shrink() {
|
|
||||||
if (this.properties.datastores > 1) {
|
|
||||||
this.properties.datastores--;
|
|
||||||
const results = await distributeStoreData(this.properties);
|
|
||||||
this.properties.storenames = await getStoreNames(this.properties.datapath, this.properties.dataname);
|
|
||||||
saveProperties(this.properties.root, this.properties);
|
|
||||||
return results;
|
|
||||||
} else {
|
|
||||||
throw new Error("Database cannot shrink any further");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
shrinkSync() {
|
|
||||||
if (this.properties.datastores > 1) {
|
|
||||||
this.properties.datastores--;
|
|
||||||
const results = distributeStoreDataSync(this.properties);
|
|
||||||
this.properties.storenames = getStoreNamesSync(this.properties.datapath, this.properties.dataname);
|
|
||||||
saveProperties(this.properties.root, this.properties);
|
|
||||||
return results;
|
|
||||||
} else {
|
|
||||||
throw new Error("Database cannot shrink any further");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async resize(size) {
|
|
||||||
validateSize(size);
|
|
||||||
this.properties.datastores = size;
|
|
||||||
const results = await distributeStoreData(this.properties);
|
|
||||||
this.properties.storenames = await getStoreNames(this.properties.datapath, this.properties.dataname);
|
|
||||||
saveProperties(this.properties.root, this.properties);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
resizeSync(size) {
|
|
||||||
validateSize(size);
|
|
||||||
this.properties.datastores = size;
|
|
||||||
const results = distributeStoreDataSync(this.properties);
|
|
||||||
this.properties.storenames = getStoreNamesSync(this.properties.datapath, this.properties.dataname);
|
|
||||||
saveProperties(this.properties.root, this.properties);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
async drop() {
|
|
||||||
const results = await dropEverything(this.properties);
|
|
||||||
return Reducer("drop", results);
|
|
||||||
}
|
|
||||||
|
|
||||||
dropSync() {
|
|
||||||
const results = dropEverythingSync(this.properties);
|
|
||||||
return Reducer("drop", results);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data manipulation methods
|
|
||||||
|
|
||||||
async insert(data) {
|
|
||||||
validateArray(data);
|
|
||||||
|
|
||||||
var promises = [];
|
|
||||||
var records = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < this.properties.datastores; i++) {
|
|
||||||
records[i] = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
records[i % this.properties.datastores] += JSON.stringify(data[i]) + "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
const randomizer = Randomizer(Array.from(Array(this.properties.datastores).keys()), false);
|
|
||||||
|
|
||||||
for (var j = 0; j < records.length; j++) {
|
|
||||||
if (records[j] !== "") {
|
|
||||||
const storenumber = randomizer.next();
|
|
||||||
const storename = [this.properties.dataname, storenumber, "json"].join(".");
|
|
||||||
const storepath = join(this.properties.datapath, storename)
|
|
||||||
promises.push(insertStoreData(storepath, records[j], this.properties.lockoptions));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
|
||||||
|
|
||||||
this.properties.storenames = await getStoreNames(this.properties.datapath, this.properties.dataname);
|
|
||||||
|
|
||||||
return Reducer("insert", results);
|
|
||||||
}
|
|
||||||
|
|
||||||
insertSync(data) {
|
|
||||||
validateArray(data);
|
|
||||||
|
|
||||||
var results = [];
|
|
||||||
var records = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < this.properties.datastores; i++) {
|
|
||||||
records[i] = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
records[i % this.properties.datastores] += JSON.stringify(data[i]) + "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
const randomizer = Randomizer(Array.from(Array(this.properties.datastores).keys()), false);
|
|
||||||
|
|
||||||
for (var j = 0; j < records.length; j++) {
|
|
||||||
if (records[j] !== "") {
|
|
||||||
const storenumber = randomizer.next();
|
|
||||||
const storename = [this.properties.dataname, storenumber, "json"].join(".");
|
|
||||||
const storepath = join(this.properties.datapath, storename)
|
|
||||||
results.push(insertStoreDataSync(storepath, records[j], this.properties.lockoptions));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.properties.storenames = getStoreNamesSync(this.properties.datapath, this.properties.dataname);
|
|
||||||
|
|
||||||
return Reducer("insert", results);
|
|
||||||
}
|
|
||||||
|
|
||||||
async insertFile(file) {
|
|
||||||
validatePath(file);
|
|
||||||
|
|
||||||
const results = await insertFileData(file, this.properties.datapath, this.properties.storenames, this.properties.lockoptions);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
insertFileSync(file) {
|
|
||||||
validatePath(file);
|
|
||||||
|
|
||||||
const data = readFileSync(file, "utf8").split("\n");
|
|
||||||
var records = [];
|
|
||||||
|
|
||||||
var results = Result("insertFile");
|
|
||||||
|
|
||||||
for (var record of data) {
|
|
||||||
record = record.trim()
|
|
||||||
|
|
||||||
results.lines++;
|
|
||||||
|
|
||||||
if (record.length > 0) {
|
|
||||||
try {
|
|
||||||
records.push(JSON.parse(record));
|
|
||||||
} catch (error) {
|
|
||||||
results.errors.push({ error: error.message, line: results.lines, data: record });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
results.blanks++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.assign(results, this.insertSync(records));
|
|
||||||
}
|
|
||||||
|
|
||||||
async select(match, project) {
|
|
||||||
validateFunction(match);
|
|
||||||
if (project) validateFunction(project);
|
|
||||||
|
|
||||||
var promises = [];
|
|
||||||
|
|
||||||
for (const storename of this.properties.storenames) {
|
|
||||||
const storepath = join(this.properties.datapath, storename);
|
|
||||||
promises.push(selectStoreData(storepath, match, project, this.properties.lockoptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
|
||||||
return Reducer("select", results);
|
|
||||||
}
|
|
||||||
|
|
||||||
selectSync(match, project) {
|
|
||||||
validateFunction(match);
|
|
||||||
if (project) validateFunction(project);
|
|
||||||
|
|
||||||
var results = [];
|
|
||||||
|
|
||||||
for (const storename of this.properties.storenames) {
|
|
||||||
const storepath = join(this.properties.datapath, storename);
|
|
||||||
results.push(selectStoreDataSync(storepath, match, project));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Reducer("select", results);
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(match, update) {
|
|
||||||
validateFunction(match);
|
|
||||||
validateFunction(update);
|
|
||||||
|
|
||||||
var promises = [];
|
|
||||||
|
|
||||||
for (const storename of this.properties.storenames) {
|
|
||||||
const storepath = join(this.properties.datapath, storename);
|
|
||||||
const tempstorename = [storename, Date.now(), "tmp"].join(".");
|
|
||||||
const tempstorepath = join(this.properties.temppath, tempstorename);
|
|
||||||
promises.push(updateStoreData(storepath, match, update, tempstorepath, this.properties.lockoptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
|
||||||
return Reducer("update", results);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSync(match, update) {
|
|
||||||
validateFunction(match);
|
|
||||||
validateFunction(update);
|
|
||||||
|
|
||||||
var results = [];
|
|
||||||
|
|
||||||
for (const storename of this.properties.storenames) {
|
|
||||||
const storepath = join(this.properties.datapath, storename);
|
|
||||||
const tempstorename = [storename, Date.now(), "tmp"].join(".");
|
|
||||||
const tempstorepath = join(this.properties.temppath, tempstorename);
|
|
||||||
results.push(updateStoreDataSync(storepath, match, update, tempstorepath));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Reducer("update", results);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(match) {
|
|
||||||
validateFunction(match);
|
|
||||||
|
|
||||||
var promises = [];
|
|
||||||
|
|
||||||
for (const storename of this.properties.storenames) {
|
|
||||||
const storepath = join(this.properties.datapath, storename);
|
|
||||||
const tempstorename = [storename, Date.now(), "tmp"].join(".");
|
|
||||||
const tempstorepath = join(this.properties.temppath, tempstorename);
|
|
||||||
promises.push(deleteStoreData(storepath, match, tempstorepath, this.properties.lockoptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
|
||||||
return Reducer("delete", results);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteSync(match) {
|
|
||||||
validateFunction(match);
|
|
||||||
|
|
||||||
var results = [];
|
|
||||||
|
|
||||||
for (const storename of this.properties.storenames) {
|
|
||||||
const storepath = join(this.properties.datapath, storename);
|
|
||||||
const tempstorename = [storename, Date.now(), "tmp"].join(".");
|
|
||||||
const tempstorepath = join(this.properties.temppath, tempstorename);
|
|
||||||
results.push(deleteStoreDataSync(storepath, match, tempstorepath));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Reducer("delete", results);
|
|
||||||
}
|
|
||||||
|
|
||||||
async aggregate(match, index, project) {
|
|
||||||
validateFunction(match);
|
|
||||||
validateFunction(index);
|
|
||||||
if (project) validateFunction(project);
|
|
||||||
|
|
||||||
var promises = [];
|
|
||||||
|
|
||||||
for (const storename of this.properties.storenames) {
|
|
||||||
const storepath = join(this.properties.datapath, storename);
|
|
||||||
promises.push(aggregateStoreData(storepath, match, index, project, this.properties.lockoptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
|
||||||
return Reducer("aggregate", results);
|
|
||||||
}
|
|
||||||
|
|
||||||
aggregateSync(match, index, project) {
|
|
||||||
validateFunction(match);
|
|
||||||
validateFunction(index);
|
|
||||||
if (project) validateFunction(project);
|
|
||||||
|
|
||||||
var results = [];
|
|
||||||
|
|
||||||
for (const storename of this.properties.storenames) {
|
|
||||||
const storepath = join(this.properties.datapath, storename);
|
|
||||||
results.push(aggregateStoreDataSync(storepath, match, index, project));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Reducer("aggregate", results);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.Database = Database;
|
|
||||||
@@ -1,723 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
const {
|
|
||||||
appendFile,
|
|
||||||
appendFileSync,
|
|
||||||
createReadStream,
|
|
||||||
createWriteStream,
|
|
||||||
readFileSync,
|
|
||||||
readdir,
|
|
||||||
readdirSync,
|
|
||||||
stat,
|
|
||||||
statSync,
|
|
||||||
writeFile
|
|
||||||
} = require("graceful-fs");
|
|
||||||
|
|
||||||
const {
|
|
||||||
join,
|
|
||||||
resolve
|
|
||||||
} = require("path");
|
|
||||||
|
|
||||||
const { createInterface } = require("readline");
|
|
||||||
|
|
||||||
const { promisify } = require("util");
|
|
||||||
|
|
||||||
const {
|
|
||||||
check,
|
|
||||||
checkSync,
|
|
||||||
lock,
|
|
||||||
lockSync
|
|
||||||
} = require("../properLockfile");
|
|
||||||
|
|
||||||
const {
|
|
||||||
deleteFile,
|
|
||||||
deleteFileSync,
|
|
||||||
deleteDirectory,
|
|
||||||
deleteDirectorySync,
|
|
||||||
fileExists,
|
|
||||||
fileExistsSync,
|
|
||||||
moveFile,
|
|
||||||
moveFileSync,
|
|
||||||
releaseLock,
|
|
||||||
releaseLockSync,
|
|
||||||
replaceFile,
|
|
||||||
replaceFileSync
|
|
||||||
} = require("./utils");
|
|
||||||
|
|
||||||
const {
|
|
||||||
Handler,
|
|
||||||
Randomizer,
|
|
||||||
Result
|
|
||||||
} = require("./objects");
|
|
||||||
|
|
||||||
const filterStoreNames = (files, dataname) => {
|
|
||||||
var storenames = [];
|
|
||||||
const re = new RegExp("^" + [dataname, "\\d+", "json"].join(".") + "$");
|
|
||||||
for (const file of files) {
|
|
||||||
if (re.test(file)) storenames.push(file);
|
|
||||||
}
|
|
||||||
return storenames;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStoreNames = async (datapath, dataname) => {
|
|
||||||
const files = await promisify(readdir)(datapath);
|
|
||||||
return filterStoreNames(files, dataname);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStoreNamesSync = (datapath, dataname) => {
|
|
||||||
const files = readdirSync(datapath);
|
|
||||||
return filterStoreNames(files, dataname);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Database management
|
|
||||||
|
|
||||||
const statsStoreData = async (store, lockoptions) => {
|
|
||||||
var release, stats, results;
|
|
||||||
|
|
||||||
release = await lock(store, lockoptions);
|
|
||||||
|
|
||||||
const handlerResults = await new Promise((resolve, reject) => {
|
|
||||||
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
|
||||||
const handler = Handler("stats");
|
|
||||||
|
|
||||||
reader.on("line", record => handler.next(record));
|
|
||||||
reader.on("close", () => resolve(handler.return()));
|
|
||||||
reader.on("error", error => reject(error));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (await check(store, lockoptions)) await releaseLock(store, release);
|
|
||||||
|
|
||||||
results = Object.assign({ store: resolve(store) }, handlerResults)
|
|
||||||
|
|
||||||
stats = await promisify(stat)(store);
|
|
||||||
results.size = stats.size;
|
|
||||||
results.created = stats.birthtime;
|
|
||||||
results.modified = stats.mtime;
|
|
||||||
|
|
||||||
results.end = Date.now()
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
const statsStoreDataSync = (store) => {
|
|
||||||
var file, release, results;
|
|
||||||
|
|
||||||
release = lockSync(store);
|
|
||||||
file = readFileSync(store, "utf8");
|
|
||||||
|
|
||||||
if (checkSync(store)) releaseLockSync(store, release);
|
|
||||||
|
|
||||||
const data = file.split("\n");
|
|
||||||
const handler = Handler("stats");
|
|
||||||
|
|
||||||
for (var record of data) {
|
|
||||||
handler.next(record)
|
|
||||||
}
|
|
||||||
|
|
||||||
results = Object.assign({ store: resolve(store) }, handler.return());
|
|
||||||
|
|
||||||
const stats = statSync(store);
|
|
||||||
results.size = stats.size;
|
|
||||||
results.created = stats.birthtime;
|
|
||||||
results.modified = stats.mtime;
|
|
||||||
|
|
||||||
results.end = Date.now();
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
const distributeStoreData = async (properties) => {
|
|
||||||
var results = Result("distribute");
|
|
||||||
|
|
||||||
var storepaths = [];
|
|
||||||
var tempstorepaths = [];
|
|
||||||
|
|
||||||
var locks = [];
|
|
||||||
|
|
||||||
for (let storename of properties.storenames) {
|
|
||||||
const storepath = join(properties.datapath, storename);
|
|
||||||
storepaths.push(storepath);
|
|
||||||
locks.push(lock(storepath, properties.lockoptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
const releases = await Promise.all(locks);
|
|
||||||
|
|
||||||
var writes = [];
|
|
||||||
var writers = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < properties.datastores; i++) {
|
|
||||||
const tempstorepath = join(properties.temppath, [properties.dataname, i, results.start, "json"].join("."));
|
|
||||||
tempstorepaths.push(tempstorepath);
|
|
||||||
await promisify(writeFile)(tempstorepath, "");
|
|
||||||
writers.push(createWriteStream(tempstorepath, { flags: "r+" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let storename of properties.storenames) {
|
|
||||||
writes.push(new Promise((resolve, reject) => {
|
|
||||||
var line = 0;
|
|
||||||
const store = join(properties.datapath, storename);
|
|
||||||
const randomizer = Randomizer(Array.from(Array(properties.datastores).keys()), false);
|
|
||||||
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
|
||||||
|
|
||||||
reader.on("line", record => {
|
|
||||||
const storenumber = randomizer.next();
|
|
||||||
|
|
||||||
line++;
|
|
||||||
try {
|
|
||||||
record = JSON.stringify(JSON.parse(record));
|
|
||||||
results.records++;
|
|
||||||
} catch {
|
|
||||||
results.errors.push({ line: line, data: record });
|
|
||||||
} finally {
|
|
||||||
writers[storenumber].write(record + "\n");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
reader.on("close", () => {
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
reader.on("error", error => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(writes);
|
|
||||||
|
|
||||||
for (let writer of writers) {
|
|
||||||
writer.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
var deletes = [];
|
|
||||||
|
|
||||||
for (let storepath of storepaths) {
|
|
||||||
deletes.push(deleteFile(storepath));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(deletes);
|
|
||||||
|
|
||||||
for (const release of releases) {
|
|
||||||
release();
|
|
||||||
}
|
|
||||||
|
|
||||||
var moves = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < tempstorepaths.length; i++) {
|
|
||||||
moves.push(moveFile(tempstorepaths[i], join(properties.datapath, [properties.dataname, i, "json"].join("."))))
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(moves);
|
|
||||||
|
|
||||||
results.stores = tempstorepaths.length,
|
|
||||||
results.end = Date.now();
|
|
||||||
results.elapsed = results.end - results.start;
|
|
||||||
|
|
||||||
return results;
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const distributeStoreDataSync = (properties) => {
|
|
||||||
var results = Result("distribute");
|
|
||||||
|
|
||||||
var storepaths = [];
|
|
||||||
var tempstorepaths = [];
|
|
||||||
|
|
||||||
var releases = [];
|
|
||||||
var data = [];
|
|
||||||
|
|
||||||
for (let storename of properties.storenames) {
|
|
||||||
const storepath = join(properties.datapath, storename);
|
|
||||||
storepaths.push(storepath);
|
|
||||||
releases.push(lockSync(storepath));
|
|
||||||
const file = readFileSync(storepath, "utf8").trimEnd();
|
|
||||||
if (file.length > 0) data = data.concat(file.split("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
var records = [];
|
|
||||||
|
|
||||||
for (var i = 0; i < data.length; i++) {
|
|
||||||
try {
|
|
||||||
data[i] = JSON.stringify(JSON.parse(data[i]));
|
|
||||||
results.records++;
|
|
||||||
} catch (error) {
|
|
||||||
results.errors.push({ line: i, data: data[i] });
|
|
||||||
} finally {
|
|
||||||
if (i === i % properties.datastores) records[i] = [];
|
|
||||||
records[i % properties.datastores] += data[i] + "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const randomizer = Randomizer(Array.from(Array(properties.datastores).keys()), false);
|
|
||||||
|
|
||||||
for (var j = 0; j < records.length; j++) {
|
|
||||||
const storenumber = randomizer.next();
|
|
||||||
const tempstorepath = join(properties.temppath, [properties.dataname, storenumber, results.start, "json"].join("."));
|
|
||||||
tempstorepaths.push(tempstorepath);
|
|
||||||
appendFileSync(tempstorepath, records[j]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let storepath of storepaths) {
|
|
||||||
deleteFileSync(storepath);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const release of releases) {
|
|
||||||
release();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < tempstorepaths.length; i++) {
|
|
||||||
moveFileSync(tempstorepaths[i], join(properties.datapath, [properties.dataname, i, "json"].join(".")));
|
|
||||||
}
|
|
||||||
|
|
||||||
results.stores = tempstorepaths.length,
|
|
||||||
results.end = Date.now();
|
|
||||||
results.elapsed = results.end - results.start;
|
|
||||||
|
|
||||||
return results;
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropEverything = async (properties) => {
|
|
||||||
var locks = [];
|
|
||||||
|
|
||||||
for (let storename of properties.storenames) {
|
|
||||||
locks.push(lock(join(properties.datapath, storename), properties.lockoptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
const releases = await Promise.all(locks);
|
|
||||||
|
|
||||||
var deletes = [];
|
|
||||||
|
|
||||||
for (let storename of properties.storenames) {
|
|
||||||
deletes.push(deleteFile(join(properties.datapath, storename)));
|
|
||||||
}
|
|
||||||
|
|
||||||
var results = await Promise.all(deletes);
|
|
||||||
|
|
||||||
for (const release of releases) {
|
|
||||||
release();
|
|
||||||
}
|
|
||||||
|
|
||||||
deletes = [
|
|
||||||
deleteDirectory(properties.temppath),
|
|
||||||
deleteDirectory(properties.datapath),
|
|
||||||
deleteFile(join(properties.root, "njodb.properties"))
|
|
||||||
];
|
|
||||||
|
|
||||||
results = results.concat(await Promise.all(deletes));
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dropEverythingSync = (properties) => {
|
|
||||||
var results = [];
|
|
||||||
var releases = [];
|
|
||||||
|
|
||||||
for (let storename of properties.storenames) {
|
|
||||||
releases.push(lockSync(join(properties.datapath, storename)));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let storename of properties.storenames) {
|
|
||||||
results.push(deleteFileSync(join(properties.datapath, storename)));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const release of releases) {
|
|
||||||
release();
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push(deleteDirectorySync(properties.temppath));
|
|
||||||
results.push(deleteDirectorySync(properties.datapath));
|
|
||||||
results.push(deleteFileSync(join(properties.root, "njodb.properties")));
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data manipulation
|
|
||||||
|
|
||||||
const insertStoreData = async (store, data, lockoptions) => {
|
|
||||||
let release, results;
|
|
||||||
|
|
||||||
results = Object.assign({ store: resolve(store) }, Result("insert"));
|
|
||||||
|
|
||||||
if (await fileExists(store)) release = await lock(store, lockoptions);
|
|
||||||
|
|
||||||
await promisify(appendFile)(store, data, "utf8");
|
|
||||||
|
|
||||||
if (await check(store, lockoptions)) await releaseLock(store, release);
|
|
||||||
|
|
||||||
results.inserted = (data.length > 0) ? data.split("\n").length - 1 : 0;
|
|
||||||
results.end = Date.now();
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
const insertStoreDataSync = (store, data) => {
|
|
||||||
let release, results;
|
|
||||||
|
|
||||||
results = Object.assign({ store: resolve(store) }, Result("insert"));
|
|
||||||
|
|
||||||
if (fileExistsSync(store)) release = lockSync(store);
|
|
||||||
|
|
||||||
appendFileSync(store, data, "utf8");
|
|
||||||
|
|
||||||
if (checkSync(store)) releaseLockSync(store, release);
|
|
||||||
|
|
||||||
results.inserted = (data.length > 0) ? data.split("\n").length - 1 : 0;
|
|
||||||
results.end = Date.now();
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
const insertFileData = async (file, datapath, storenames, lockoptions) => {
|
|
||||||
let datastores, locks, releases, writers, results;
|
|
||||||
|
|
||||||
results = Result("insertFile");
|
|
||||||
|
|
||||||
datastores = storenames.length;
|
|
||||||
locks = [];
|
|
||||||
writers = [];
|
|
||||||
|
|
||||||
for (let storename of storenames) {
|
|
||||||
const storepath = join(datapath, storename);
|
|
||||||
locks.push(lock(storepath, lockoptions));
|
|
||||||
writers.push(createWriteStream(storepath, { flags: "r+" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
releases = await Promise.all(locks);
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const randomizer = Randomizer(Array.from(Array(datastores).keys()), false);
|
|
||||||
const reader = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
|
|
||||||
|
|
||||||
reader.on("line", record => {
|
|
||||||
record = record.trim();
|
|
||||||
|
|
||||||
const storenumber = randomizer.next();
|
|
||||||
results.lines++;
|
|
||||||
|
|
||||||
if (record.length > 0) {
|
|
||||||
try {
|
|
||||||
record = JSON.parse(record);
|
|
||||||
results.inserted++;
|
|
||||||
} catch (error) {
|
|
||||||
results.errors.push({ error: error.message, line: results.lines, data: record });
|
|
||||||
} finally {
|
|
||||||
writers[storenumber].write(JSON.stringify(record) + "\n");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
results.blanks++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
reader.on("close", () => {
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
reader.on("error", error => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const writer of writers) {
|
|
||||||
writer.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const release of releases) {
|
|
||||||
release();
|
|
||||||
}
|
|
||||||
|
|
||||||
results.end = Date.now();
|
|
||||||
results.elapsed = results.end - results.start;
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectStoreData = async (store, match, project, lockoptions) => {
|
|
||||||
let release, results;
|
|
||||||
|
|
||||||
release = await lock(store, lockoptions);
|
|
||||||
|
|
||||||
const handlerResults = await new Promise((resolve, reject) => {
|
|
||||||
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
|
||||||
const handler = Handler("select", match, project);
|
|
||||||
|
|
||||||
reader.on("line", record => handler.next(record));
|
|
||||||
reader.on("close", () => resolve(handler.return()));
|
|
||||||
reader.on("error", error => reject(error));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (await check(store, lockoptions)) await releaseLock(store, release);
|
|
||||||
|
|
||||||
results = Object.assign({ store: store }, handlerResults);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectStoreDataSync = (store, match, project) => {
|
|
||||||
let file, release, results;
|
|
||||||
|
|
||||||
release = lockSync(store);
|
|
||||||
|
|
||||||
file = readFileSync(store, "utf8");
|
|
||||||
|
|
||||||
if (checkSync(store)) releaseLockSync(store, release);
|
|
||||||
|
|
||||||
const records = file.split("\n");
|
|
||||||
const handler = Handler("select", match, project);
|
|
||||||
|
|
||||||
for (var record of records) {
|
|
||||||
handler.next(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
results = Object.assign({ store: store }, handler.return());
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStoreData = async (store, match, update, tempstore, lockoptions) => {
|
|
||||||
let release, results;
|
|
||||||
|
|
||||||
release = await lock(store, lockoptions);
|
|
||||||
|
|
||||||
const handlerResults = await new Promise((resolve, reject) => {
|
|
||||||
|
|
||||||
const writer = createWriteStream(tempstore);
|
|
||||||
const handler = Handler("update", match, update);
|
|
||||||
|
|
||||||
writer.on("open", () => {
|
|
||||||
// Reader was opening and closing before writer ever opened
|
|
||||||
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
|
||||||
|
|
||||||
reader.on("line", record => {
|
|
||||||
handler.next(record, writer)
|
|
||||||
});
|
|
||||||
|
|
||||||
reader.on("close", () => {
|
|
||||||
writer.end();
|
|
||||||
resolve(handler.return());
|
|
||||||
});
|
|
||||||
|
|
||||||
reader.on("error", error => reject(error));
|
|
||||||
});
|
|
||||||
|
|
||||||
writer.on("error", error => reject(error));
|
|
||||||
});
|
|
||||||
|
|
||||||
results = Object.assign({ store: store, tempstore: tempstore }, handlerResults);
|
|
||||||
|
|
||||||
if (results.updated > 0) {
|
|
||||||
if (!await replaceFile(store, tempstore)) {
|
|
||||||
results.errors = [...results.records];
|
|
||||||
results.updated = 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await deleteFile(tempstore);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await check(store, lockoptions)) await releaseLock(store, release);
|
|
||||||
|
|
||||||
results.end = Date.now();
|
|
||||||
delete results.data;
|
|
||||||
delete results.records;
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStoreDataSync = (store, match, update, tempstore) => {
|
|
||||||
let file, release, results;
|
|
||||||
|
|
||||||
release = lockSync(store);
|
|
||||||
file = readFileSync(store, "utf8").trimEnd();
|
|
||||||
|
|
||||||
if (checkSync(store)) releaseLockSync(store, release);
|
|
||||||
|
|
||||||
|
|
||||||
const records = file.split("\n");
|
|
||||||
const handler = Handler("update", match, update);
|
|
||||||
|
|
||||||
for (var record of records) {
|
|
||||||
handler.next(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
results = Object.assign({ store: store, tempstore: tempstore }, handler.return());
|
|
||||||
|
|
||||||
if (results.updated > 0) {
|
|
||||||
let append, replace;
|
|
||||||
|
|
||||||
try {
|
|
||||||
appendFileSync(tempstore, results.data.join("\n") + "\n", "utf8");
|
|
||||||
append = true;
|
|
||||||
} catch {
|
|
||||||
append = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (append) replace = replaceFileSync(store, tempstore);
|
|
||||||
|
|
||||||
if (!(append || replace)) {
|
|
||||||
results.errors = [...results.records];
|
|
||||||
results.updated = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results.end = Date.now();
|
|
||||||
delete results.data;
|
|
||||||
delete results.records;
|
|
||||||
|
|
||||||
return results;
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteStoreData = async (store, match, tempstore, lockoptions) => {
|
|
||||||
let release, results;
|
|
||||||
release = await lock(store, lockoptions);
|
|
||||||
|
|
||||||
const handlerResults = await new Promise((resolve, reject) => {
|
|
||||||
const writer = createWriteStream(tempstore);
|
|
||||||
const handler = Handler("delete", match);
|
|
||||||
|
|
||||||
writer.on("open", () => {
|
|
||||||
// Create reader after writer opens otherwise the reader can sometimes close before the writer opens
|
|
||||||
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
|
||||||
|
|
||||||
reader.on("line", record => handler.next(record, writer));
|
|
||||||
|
|
||||||
reader.on("close", () => {
|
|
||||||
writer.end();
|
|
||||||
resolve(handler.return());
|
|
||||||
});
|
|
||||||
|
|
||||||
reader.on("error", error => reject(error));
|
|
||||||
});
|
|
||||||
|
|
||||||
writer.on("error", error => reject(error));
|
|
||||||
});
|
|
||||||
|
|
||||||
results = Object.assign({ store: store, tempstore: tempstore }, handlerResults);
|
|
||||||
|
|
||||||
if (results.deleted > 0) {
|
|
||||||
if (!await replaceFile(store, tempstore)) {
|
|
||||||
results.errors = [...results.records];
|
|
||||||
results.deleted = 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await deleteFile(tempstore);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await check(store, lockoptions)) await releaseLock(store, release);
|
|
||||||
|
|
||||||
results.end = Date.now();
|
|
||||||
delete results.data;
|
|
||||||
delete results.records;
|
|
||||||
|
|
||||||
return results;
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteStoreDataSync = (store, match, tempstore) => {
|
|
||||||
let file, release, results;
|
|
||||||
|
|
||||||
release = lockSync(store);
|
|
||||||
file = readFileSync(store, "utf8");
|
|
||||||
|
|
||||||
if (checkSync(store)) releaseLockSync(store, release);
|
|
||||||
|
|
||||||
const records = file.split("\n");
|
|
||||||
const handler = Handler("delete", match);
|
|
||||||
|
|
||||||
for (var record of records) {
|
|
||||||
handler.next(record)
|
|
||||||
}
|
|
||||||
|
|
||||||
results = Object.assign({ store: store, tempstore: tempstore }, handler.return());
|
|
||||||
|
|
||||||
if (results.deleted > 0) {
|
|
||||||
let append, replace;
|
|
||||||
|
|
||||||
try {
|
|
||||||
appendFileSync(tempstore, results.data.join("\n") + "\n", "utf8");
|
|
||||||
append = true;
|
|
||||||
} catch {
|
|
||||||
append = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (append) replace = replaceFileSync(store, tempstore);
|
|
||||||
|
|
||||||
if (!(append || replace)) {
|
|
||||||
results.errors = [...results.records];
|
|
||||||
results.updated = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results.end = Date.now();
|
|
||||||
delete results.data;
|
|
||||||
delete results.records;
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
const aggregateStoreData = async (store, match, index, project, lockoptions) => {
|
|
||||||
let release, results;
|
|
||||||
|
|
||||||
release = await lock(store, lockoptions);
|
|
||||||
|
|
||||||
const handlerResults = await new Promise((resolve, reject) => {
|
|
||||||
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
|
||||||
const handler = Handler("aggregate", match, index, project);
|
|
||||||
|
|
||||||
reader.on("line", record => handler.next(record));
|
|
||||||
reader.on("close", () => resolve(handler.return()));
|
|
||||||
reader.on("error", error => reject(error));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (await check(store, lockoptions)) releaseLock(store, release);
|
|
||||||
|
|
||||||
results = Object.assign({ store: store }, handlerResults);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aggregateStoreDataSync = (store, match, index, project) => {
|
|
||||||
let file, release, results;
|
|
||||||
|
|
||||||
release = lockSync(store);
|
|
||||||
file = readFileSync(store, "utf8");
|
|
||||||
|
|
||||||
if (checkSync(store)) releaseLockSync(store, release);
|
|
||||||
|
|
||||||
const records = file.split("\n");
|
|
||||||
const handler = Handler("aggregate", match, index, project);
|
|
||||||
|
|
||||||
for (var record of records) {
|
|
||||||
handler.next(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
results = Object.assign({ store: store }, handler.return());
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.getStoreNames = getStoreNames;
|
|
||||||
exports.getStoreNamesSync = getStoreNamesSync;
|
|
||||||
|
|
||||||
// Database management
|
|
||||||
exports.statsStoreData = statsStoreData;
|
|
||||||
exports.statsStoreDataSync = statsStoreDataSync;
|
|
||||||
exports.distributeStoreData = distributeStoreData;
|
|
||||||
exports.distributeStoreDataSync = distributeStoreDataSync;
|
|
||||||
exports.dropEverything = dropEverything;
|
|
||||||
exports.dropEverythingSync = dropEverythingSync;
|
|
||||||
|
|
||||||
// Data manipulation
|
|
||||||
exports.insertStoreData = insertStoreData;
|
|
||||||
exports.insertStoreDataSync = insertStoreDataSync;
|
|
||||||
exports.insertFileData = insertFileData;
|
|
||||||
exports.selectStoreData = selectStoreData;
|
|
||||||
exports.selectStoreDataSync = selectStoreDataSync;
|
|
||||||
exports.updateStoreData = updateStoreData;
|
|
||||||
exports.updateStoreDataSync = updateStoreDataSync;
|
|
||||||
exports.deleteStoreData = deleteStoreData;
|
|
||||||
exports.deleteStoreDataSync = deleteStoreDataSync;
|
|
||||||
exports.aggregateStoreData = aggregateStoreData;
|
|
||||||
exports.aggregateStoreDataSync = aggregateStoreDataSync;
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user