Add:Audiobooks only library settings, supplementary ebooks #1664

This commit is contained in:
advplyr 2023-06-10 12:46:57 -05:00
parent 4b4fb33d8f
commit 014fc45c15
39 changed files with 624 additions and 122 deletions

View File

@ -921,7 +921,7 @@ export default {
return null return null
}) })
if (!libraryItem) return if (!libraryItem) return
this.store.commit('showEReader', libraryItem) this.store.commit('showEReader', { libraryItem, keepProgress: true })
}, },
selectBtnClick(evt) { selectBtnClick(evt) {
if (this.processingBatch) return if (this.processingBatch) return

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-full px-1 md:px-4 py-1 mb-4"> <div class="w-full h-full px-1 md:px-4 py-1 mb-4">
<div class="flex items-center py-2"> <div class="flex items-center py-3">
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" /> <ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp"> <ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
<p class="pl-4 text-base"> <p class="pl-4 text-base">
@ -17,13 +17,22 @@
</div> </div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p> <p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
</div> </div>
<div v-if="mediaType == 'book'" class="py-3"> <div v-if="isBookLibrary" class="flex items-center py-3">
<ui-toggle-switch v-model="audiobooksOnly" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
<p class="pl-4 text-base">
{{ $strings.LabelSettingsAudiobooksOnly }}
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div v-if="isBookLibrary" class="py-3">
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" /> <ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p> <p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
</div> </div>
</div> </div>
<div v-if="mediaType == 'book'" class="py-3"> <div v-if="isBookLibrary" class="py-3">
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" /> <ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p> <p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
@ -47,7 +56,8 @@ export default {
useSquareBookCovers: false, useSquareBookCovers: false,
disableWatcher: false, disableWatcher: false,
skipMatchingMediaWithAsin: false, skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false skipMatchingMediaWithIsbn: false,
audiobooksOnly: false
} }
}, },
computed: { computed: {
@ -60,6 +70,9 @@ export default {
mediaType() { mediaType() {
return this.library.mediaType return this.library.mediaType
}, },
isBookLibrary() {
return this.mediaType === 'book'
},
providers() { providers() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers return this.$store.state.scanners.providers
@ -72,7 +85,8 @@ export default {
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD, coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
disableWatcher: !!this.disableWatcher, disableWatcher: !!this.disableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin, skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
audiobooksOnly: !!this.audiobooksOnly
} }
} }
}, },
@ -84,6 +98,7 @@ export default {
this.disableWatcher = !!this.librarySettings.disableWatcher this.disableWatcher = !!this.librarySettings.disableWatcher
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
} }
}, },
mounted() { mounted() {

View File

@ -64,7 +64,9 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
playerOpen: Boolean playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
}, },
data() { data() {
return { return {
@ -98,6 +100,9 @@ export default {
return this.libraryItem?.id return this.libraryItem?.id
}, },
ebookUrl() { ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook` return `/api/items/${this.libraryItemId}/ebook`
}, },
comicMetadataKeys() { comicMetadataKeys() {
@ -114,6 +119,8 @@ export default {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
savedPage() { savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number // Validate ebookLocation is a number
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0 if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
return Number(this.userMediaProgress.ebookLocation) return Number(this.userMediaProgress.ebookLocation)
@ -141,6 +148,8 @@ export default {
this.showInfoMenu = !this.showInfoMenu this.showInfoMenu = !this.showInfoMenu
}, },
updateProgress() { updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) { if (!this.numPages) {
console.error('Num pages not loaded') console.error('Num pages not loaded')
return return

View File

@ -28,7 +28,9 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
playerOpen: Boolean playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
}, },
data() { data() {
return { return {
@ -68,6 +70,7 @@ export default {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
savedEbookLocation() { savedEbookLocation() {
if (!this.keepProgress) return null
if (!this.userMediaProgress?.ebookLocation) return null if (!this.userMediaProgress?.ebookLocation) return null
// Validate ebookLocation is an epubcfi // Validate ebookLocation is an epubcfi
if (!String(this.userMediaProgress.ebookLocation).startsWith('epubcfi')) return null if (!String(this.userMediaProgress.ebookLocation).startsWith('epubcfi')) return null
@ -84,7 +87,10 @@ export default {
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight
return this.windowHeight - 164 return this.windowHeight - 164
}, },
epubUrl() { ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook` return `/api/items/${this.libraryItemId}/ebook`
} }
}, },
@ -112,6 +118,7 @@ export default {
* @param {string} payload.ebookProgress - eBook Progress Percentage * @param {string} payload.ebookProgress - eBook Progress Percentage
*/ */
updateProgress(payload) { updateProgress(payload) {
if (!this.keepProgress) return
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => { this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error) console.error('EpubReader.updateProgress failed:', error)
}) })
@ -223,7 +230,7 @@ export default {
const reader = this const reader = this
/** @type {ePub.Book} */ /** @type {ePub.Book} */
reader.book = new ePub(reader.epubUrl, { reader.book = new ePub(reader.ebookUrl, {
width: this.readerWidth, width: this.readerWidth,
height: this.readerHeight - 50, height: this.readerHeight - 50,
openAs: 'epub', openAs: 'epub',
@ -242,7 +249,7 @@ export default {
reader.rendition.display(this.savedEbookLocation || reader.book.locations.start) reader.rendition.display(this.savedEbookLocation || reader.book.locations.start)
// load style // load style
reader.rendition.themes.default({ '*': { color: '#fff!important', 'background-color': 'rgb(35 35 35)!important' } }) reader.rendition.themes.default({ '*': { color: '#fff!important', 'background-color': 'rgb(35 35 35)!important' }, a: { color: '#fff!important' } })
reader.book.ready.then(() => { reader.book.ready.then(() => {
// set up event listeners // set up event listeners

View File

@ -19,7 +19,8 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
playerOpen: Boolean playerOpen: Boolean,
fileId: String
}, },
data() { data() {
return {} return {}
@ -32,6 +33,9 @@ export default {
return this.libraryItem?.id return this.libraryItem?.id
}, },
ebookUrl() { ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook` return `/api/items/${this.libraryItemId}/ebook`
} }
}, },

View File

@ -45,7 +45,9 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
playerOpen: Boolean playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
}, },
data() { data() {
return { return {
@ -95,13 +97,21 @@ export default {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
savedPage() { savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number // Validate ebookLocation is a number
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0 if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
return Number(this.userMediaProgress.ebookLocation) return Number(this.userMediaProgress.ebookLocation)
}, },
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
},
pdfDocInitParams() { pdfDocInitParams() {
return { return {
url: `/api/items/${this.libraryItemId}/ebook`, url: this.ebookUrl,
httpHeaders: { httpHeaders: {
Authorization: `Bearer ${this.userToken}` Authorization: `Bearer ${this.userToken}`
} }
@ -116,6 +126,7 @@ export default {
this.scale -= 0.1 this.scale -= 0.1
}, },
updateProgress() { updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) { if (!this.numPages) {
console.error('Num pages not loaded') console.error('Num pages not loaded')
return return

View File

@ -17,7 +17,7 @@
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span> <span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
</div> </div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" /> <component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" />
<!-- 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>
@ -103,10 +103,18 @@ export default {
return this.selectedLibraryItem.folderId return this.selectedLibraryItem.folderId
}, },
ebookFile() { ebookFile() {
// ebook file id is passed when reading a supplementary ebook
if (this.ebookFileId) {
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
}
return this.media.ebookFile return this.media.ebookFile
}, },
ebookFormat() { ebookFormat() {
if (!this.ebookFile) return null if (!this.ebookFile) return null
// Use file extension for supplementary ebook
if (!this.ebookFile.ebookFormat) {
return this.ebookFile.metadata.ext.toLowerCase().slice(1)
}
return this.ebookFile.ebookFormat return this.ebookFile.ebookFormat
}, },
ebookType() { ebookType() {
@ -130,6 +138,12 @@ export default {
}, },
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
},
keepProgress() {
return this.$store.state.ereaderKeepProgress
},
ebookFileId() {
return this.$store.state.ereaderFileId
} }
}, },
methods: { methods: {

View File

@ -88,7 +88,7 @@ export default {
}, },
deleteLibraryFile() { deleteLibraryFile() {
const payload = { const payload = {
message: 'This will delete the file from your file system. Are you sure?', message: this.$strings.MessageConfirmDeleteFile,
callback: (confirmed) => { callback: (confirmed) => {
if (confirmed) { if (confirmed) {
this.$axios this.$axios

View File

@ -0,0 +1,87 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-2 md:pr-4">{{ $strings.HeaderEbookFiles }}</p>
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ ebookFiles.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
</div>
<transition name="slide">
<div class="w-full" v-show="showFiles">
<table class="text-sm tracksTable">
<tr>
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
<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>
</th>
<th v-if="userCanDelete || userCanDownload || userIsAdmin" class="text-center w-16"></th>
</tr>
<template v-for="file in ebookFiles">
<tables-ebook-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" @read="readEbook" />
</template>
</table>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
showFiles: false,
showFullPath: false
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
ebookFiles() {
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: {
readEbook(fileIno) {
this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
},
clickBar() {
this.showFiles = !this.showFiles
}
},
mounted() {}
}
</script>

View File

@ -0,0 +1,139 @@
<template>
<tr>
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }} <ui-tooltip :text="$strings.LabelPrimaryEbook" class="inline-block"><span v-if="isPrimary" class="material-icons-outlined text-success align-text-bottom">check_circle</span></ui-tooltip>
</td>
<td>
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<ui-icon-btn icon="auto_stories" outlined borderless icon-font-size="1.125rem" :size="8" @click="readEbook" />
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="130" :processing="processing" @action="contextMenuAction" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
file: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
downloadUrl() {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`
},
isPrimary() {
return !this.file.isSupplementary
},
libraryIsAudiobooksOnly() {
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
},
contextMenuItems() {
const items = []
if (this.userCanUpdate && !this.libraryIsAudiobooksOnly) {
items.push({
text: this.isPrimary ? this.$strings.LabelSetEbookAsSupplementary : this.$strings.LabelSetEbookAsPrimary,
action: 'updateStatus'
})
}
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
return items
}
},
methods: {
readEbook() {
this.$emit('read', this.file.ino)
},
contextMenuAction({ action }) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
this.downloadLibraryFile()
} else if (action === 'updateStatus') {
this.updateEbookStatus()
}
},
updateEbookStatus() {
this.processing = true
this.$axios
.$patch(`/api/items/${this.libraryItemId}/ebook/${this.file.ino}/status`)
.then(() => {
this.$toast.success('Ebook updated')
})
.catch((error) => {
console.error('Failed to update ebook', error)
this.$toast.error('Failed to update ebook')
})
.finally(() => {
this.processing = false
})
},
deleteLibraryFile() {
const payload = {
message: this.$strings.MessageConfirmDeleteFile,
callback: (confirmed) => {
if (confirmed) {
this.processing = true
this.$axios
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
.then(() => {
this.$toast.success('File deleted')
})
.catch((error) => {
console.error('Failed to delete file', error)
this.$toast.error('Failed to delete file')
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
}
},
mounted() {}
}
</script>

View File

@ -38,7 +38,6 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
isMissing: Boolean,
expanded: Boolean, // start expanded expanded: Boolean, // start expanded
inModal: Boolean inModal: Boolean
}, },

View File

@ -83,7 +83,7 @@ export default {
}, },
deleteLibraryFile() { deleteLibraryFile() {
const payload = { const payload = {
message: 'This will delete the file from your file system. Are you sure?', message: this.$strings.MessageConfirmDeleteFile,
callback: (confirmed) => { callback: (confirmed) => {
if (confirmed) { if (confirmed) {
this.$axios this.$axios

View File

@ -1,9 +1,12 @@
<template> <template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj"> <div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu"> <slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu"> <button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons" :class="iconClass">more_vert</span> <span class="material-icons" :class="iconClass">more_vert</span>
</button> </button>
<div v-else class="h-full w-full flex items-center justify-center">
<widgets-loading-spinner />
</div>
</slot> </slot>
<transition name="menu"> <transition name="menu">
@ -28,7 +31,7 @@
</div> </div>
</template> </template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)"> <div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p>{{ item.text }}</p> <p class="text-left">{{ item.text }}</p>
</div> </div>
</template> </template>
</div> </div>
@ -51,7 +54,8 @@ export default {
menuWidth: { menuWidth: {
type: Number, type: Number,
default: 192 default: 192
} },
processing: Boolean
}, },
data() { data() {
return { return {

View File

@ -147,7 +147,9 @@
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" /> <tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item="libraryItem" class="mt-6" /> <tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" class="mt-6" />
<tables-library-files-table v-if="libraryFiles.length" :library-item="libraryItem" class="mt-6" />
</div> </div>
</div> </div>
</div> </div>
@ -320,6 +322,9 @@ export default {
libraryFiles() { libraryFiles() {
return this.libraryItem.libraryFiles || [] return this.libraryItem.libraryFiles || []
}, },
ebookFiles() {
return this.libraryFiles.filter((lf) => lf.fileType === 'ebook')
},
ebookFile() { ebookFile() {
return this.media.ebookFile return this.media.ebookFile
}, },
@ -519,7 +524,7 @@ export default {
this.$store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'cover' }) this.$store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'cover' })
}, },
openEbook() { openEbook() {
this.$store.commit('showEReader', this.libraryItem) this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: true })
}, },
toggleFinished(confirmed = false) { toggleFinished(confirmed = false) {
if (!this.userIsFinished && this.progressPercent > 0 && !confirmed) { if (!this.userIsFinished && this.progressPercent > 0 && !confirmed) {

View File

@ -17,6 +17,8 @@ export const state = () => ({
editPodcastModalTab: 'details', editPodcastModalTab: 'details',
showEditModal: false, showEditModal: false,
showEReader: false, showEReader: false,
ereaderKeepProgress: false,
ereaderFileId: null,
selectedLibraryItem: null, selectedLibraryItem: null,
developerMode: false, developerMode: false,
processingBatch: false, processingBatch: false,
@ -210,8 +212,10 @@ export const mutations = {
setEditPodcastModalTab(state, tab) { setEditPodcastModalTab(state, tab) {
state.editPodcastModalTab = tab state.editPodcastModalTab = tab
}, },
showEReader(state, libraryItem) { showEReader(state, { libraryItem, keepProgress, fileId }) {
state.selectedLibraryItem = libraryItem state.selectedLibraryItem = libraryItem
state.ereaderKeepProgress = keepProgress
state.ereaderFileId = fileId
state.showEReader = true state.showEReader = true
}, },

View File

@ -57,6 +57,9 @@ export const getters = {
if (!getters.getCurrentLibrarySettings || isNaN(getters.getCurrentLibrarySettings.coverAspectRatio)) return 1 if (!getters.getCurrentLibrarySettings || isNaN(getters.getCurrentLibrarySettings.coverAspectRatio)) return 1
return getters.getCurrentLibrarySettings.coverAspectRatio === Constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1 return getters.getCurrentLibrarySettings.coverAspectRatio === Constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1
}, },
getLibraryIsAudiobooksOnly: (state, getters) => {
return !!getters.getCurrentLibrarySettings?.audiobooksOnly
},
getCollection: state => id => { getCollection: state => id => {
return state.collections.find(c => c.id === id) return state.collections.find(c => c.id === id)
}, },

View File

@ -98,6 +98,7 @@
"HeaderCurrentDownloads": "Aktuelle Downloads", "HeaderCurrentDownloads": "Aktuelle Downloads",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Warteschlange", "HeaderDownloadQueue": "Download Warteschlange",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email", "HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episoden", "HeaderEpisodes": "Episoden",
@ -339,12 +340,15 @@
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)", "LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
"LabelPreventIndexing": "Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird", "LabelPreventIndexing": "Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Fortschritt", "LabelProgress": "Fortschritt",
"LabelProvider": "Anbieter", "LabelProvider": "Anbieter",
"LabelPubDate": "Veröffentlichungsdatum", "LabelPubDate": "Veröffentlichungsdatum",
"LabelPublisher": "Herausgeber", "LabelPublisher": "Herausgeber",
"LabelPublishYear": "Jahr", "LabelPublishYear": "Jahr",
"LabelRead": "Read",
"LabelReadAgain": "Read Again", "LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Kürzlich hinzugefügt", "LabelRecentlyAdded": "Kürzlich hinzugefügt",
"LabelRecentSeries": "Aktuelle Serien", "LabelRecentSeries": "Aktuelle Serien",
"LabelRecommended": "Empfohlen", "LabelRecommended": "Empfohlen",
@ -366,6 +370,10 @@
"LabelSeries": "Serien", "LabelSeries": "Serien",
"LabelSeriesName": "Serienname", "LabelSeriesName": "Serienname",
"LabelSeriesProgress": "Serienfortschritt", "LabelSeriesProgress": "Serienfortschritt",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden", "LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
"LabelSettingsChromecastSupport": "Chromecastunterstützung", "LabelSettingsChromecastSupport": "Chromecastunterstützung",
"LabelSettingsDateFormat": "Datumsformat", "LabelSettingsDateFormat": "Datumsformat",
@ -489,6 +497,7 @@
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)", "MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
"MessageCheckingCron": "Überprüfe Cron...", "MessageCheckingCron": "Überprüfe Cron...",
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?", "MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?", "MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?", "MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?", "MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",

View File

@ -98,6 +98,7 @@
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email", "HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodes", "HeaderEpisodes": "Episodes",
@ -339,12 +340,15 @@
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)", "LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories", "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Progress", "LabelProgress": "Progress",
"LabelProvider": "Provider", "LabelProvider": "Provider",
"LabelPubDate": "Pub Date", "LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher", "LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year", "LabelPublishYear": "Publish Year",
"LabelRead": "Read",
"LabelReadAgain": "Read Again", "LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Recently Added", "LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series", "LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended", "LabelRecommended": "Recommended",
@ -366,6 +370,10 @@
"LabelSeries": "Series", "LabelSeries": "Series",
"LabelSeriesName": "Series Name", "LabelSeriesName": "Series Name",
"LabelSeriesProgress": "Series Progress", "LabelSeriesProgress": "Series Progress",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves", "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
"LabelSettingsChromecastSupport": "Chromecast support", "LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Date Format", "LabelSettingsDateFormat": "Date Format",
@ -489,6 +497,7 @@
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook", "MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...", "MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?",

View File

@ -98,6 +98,7 @@
"HeaderCurrentDownloads": "Descargando Actualmente", "HeaderCurrentDownloads": "Descargando Actualmente",
"HeaderDetails": "Detalles", "HeaderDetails": "Detalles",
"HeaderDownloadQueue": "Lista de Descarga", "HeaderDownloadQueue": "Lista de Descarga",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email", "HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodios", "HeaderEpisodes": "Episodios",
@ -339,12 +340,15 @@
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefijos para Ignorar (no distingue entre mayúsculas y minúsculas.)", "LabelPrefixesToIgnore": "Prefijos para Ignorar (no distingue entre mayúsculas y minúsculas.)",
"LabelPreventIndexing": "Evite que su fuente sea indexado por iTunes y Google podcast directories", "LabelPreventIndexing": "Evite que su fuente sea indexado por iTunes y Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Progreso", "LabelProgress": "Progreso",
"LabelProvider": "Proveedor", "LabelProvider": "Proveedor",
"LabelPubDate": "Fecha de Publicación", "LabelPubDate": "Fecha de Publicación",
"LabelPublisher": "Editor", "LabelPublisher": "Editor",
"LabelPublishYear": "Año de Publicación", "LabelPublishYear": "Año de Publicación",
"LabelRead": "Read",
"LabelReadAgain": "Read Again", "LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Agregado Reciente", "LabelRecentlyAdded": "Agregado Reciente",
"LabelRecentSeries": "Series Recientes", "LabelRecentSeries": "Series Recientes",
"LabelRecommended": "Recomendados", "LabelRecommended": "Recomendados",
@ -366,6 +370,10 @@
"LabelSeries": "Series", "LabelSeries": "Series",
"LabelSeriesName": "Nombre de la Serie", "LabelSeriesName": "Nombre de la Serie",
"LabelSeriesProgress": "Progreso de la Serie", "LabelSeriesProgress": "Progreso de la Serie",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "Diseño Skeumorphic con Estantes de Madera", "LabelSettingsBookshelfViewHelp": "Diseño Skeumorphic con Estantes de Madera",
"LabelSettingsChromecastSupport": "Soporte para Chromecast", "LabelSettingsChromecastSupport": "Soporte para Chromecast",
"LabelSettingsDateFormat": "Formato de Fecha", "LabelSettingsDateFormat": "Formato de Fecha",
@ -489,6 +497,7 @@
"MessageChapterStartIsAfter": "El comienzo del capítulo es después del final de su audiolibro", "MessageChapterStartIsAfter": "El comienzo del capítulo es después del final de su audiolibro",
"MessageCheckingCron": "Checking cron...", "MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Esta seguro que desea eliminar el respaldo {0}?", "MessageConfirmDeleteBackup": "Esta seguro que desea eliminar el respaldo {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?", "MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?",
"MessageConfirmDeleteSession": "Esta seguro que desea eliminar esta session?", "MessageConfirmDeleteSession": "Esta seguro que desea eliminar esta session?",
"MessageConfirmForceReScan": "Esta seguro que desea forzar re-escanear?", "MessageConfirmForceReScan": "Esta seguro que desea forzar re-escanear?",

View File

@ -98,6 +98,7 @@
"HeaderCurrentDownloads": "Téléchargements en cours", "HeaderCurrentDownloads": "Téléchargements en cours",
"HeaderDetails": "Détails", "HeaderDetails": "Détails",
"HeaderDownloadQueue": "File d'attente de téléchargements", "HeaderDownloadQueue": "File d'attente de téléchargements",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "E-mails", "HeaderEmail": "E-mails",
"HeaderEmailSettings": "Configuration des e-mails", "HeaderEmailSettings": "Configuration des e-mails",
"HeaderEpisodes": "Épisodes", "HeaderEpisodes": "Épisodes",
@ -339,12 +340,15 @@
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)", "LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
"LabelPreventIndexing": "Empêcher lindexation de votre flux par les bases de données iTunes et Google podcast", "LabelPreventIndexing": "Empêcher lindexation de votre flux par les bases de données iTunes et Google podcast",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Progression", "LabelProgress": "Progression",
"LabelProvider": "Fournisseur", "LabelProvider": "Fournisseur",
"LabelPubDate": "Date de publication", "LabelPubDate": "Date de publication",
"LabelPublisher": "Éditeur", "LabelPublisher": "Éditeur",
"LabelPublishYear": "Année dédition", "LabelPublishYear": "Année dédition",
"LabelRead": "Read",
"LabelReadAgain": "Read Again", "LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Derniers ajouts", "LabelRecentlyAdded": "Derniers ajouts",
"LabelRecentSeries": "Séries récentes", "LabelRecentSeries": "Séries récentes",
"LabelRecommended": "Recommandé", "LabelRecommended": "Recommandé",
@ -366,6 +370,10 @@
"LabelSeries": "Séries", "LabelSeries": "Séries",
"LabelSeriesName": "Nom de la série", "LabelSeriesName": "Nom de la série",
"LabelSeriesProgress": "Progression de séries", "LabelSeriesProgress": "Progression de séries",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois", "LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois",
"LabelSettingsChromecastSupport": "Support du Chromecast", "LabelSettingsChromecastSupport": "Support du Chromecast",
"LabelSettingsDateFormat": "Format de date", "LabelSettingsDateFormat": "Format de date",
@ -489,6 +497,7 @@
"MessageChapterStartIsAfter": "Le Chapitre Début est situé au début de votre Livre Audio", "MessageChapterStartIsAfter": "Le Chapitre Début est situé au début de votre Livre Audio",
"MessageCheckingCron": "Vérification du cron…", "MessageCheckingCron": "Vérification du cron…",
"MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la Sauvegarde de {0} ?", "MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la Sauvegarde de {0} ?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?", "MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?",
"MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?", "MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?",
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?", "MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?",

View File

@ -98,6 +98,7 @@
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email", "HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodes", "HeaderEpisodes": "Episodes",
@ -339,12 +340,15 @@
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)", "LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories", "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Progress", "LabelProgress": "Progress",
"LabelProvider": "Provider", "LabelProvider": "Provider",
"LabelPubDate": "Pub Date", "LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher", "LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year", "LabelPublishYear": "Publish Year",
"LabelRead": "Read",
"LabelReadAgain": "Read Again", "LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Recently Added", "LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series", "LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended", "LabelRecommended": "Recommended",
@ -366,6 +370,10 @@
"LabelSeries": "Series", "LabelSeries": "Series",
"LabelSeriesName": "Series Name", "LabelSeriesName": "Series Name",
"LabelSeriesProgress": "Series Progress", "LabelSeriesProgress": "Series Progress",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves", "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
"LabelSettingsChromecastSupport": "Chromecast support", "LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Date Format", "LabelSettingsDateFormat": "Date Format",
@ -489,6 +497,7 @@
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook", "MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...", "MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?",

View File

@ -98,6 +98,7 @@
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email", "HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodes", "HeaderEpisodes": "Episodes",
@ -339,12 +340,15 @@
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)", "LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories", "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Progress", "LabelProgress": "Progress",
"LabelProvider": "Provider", "LabelProvider": "Provider",
"LabelPubDate": "Pub Date", "LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher", "LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year", "LabelPublishYear": "Publish Year",
"LabelRead": "Read",
"LabelReadAgain": "Read Again", "LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Recently Added", "LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series", "LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended", "LabelRecommended": "Recommended",
@ -366,6 +370,10 @@
"LabelSeries": "Series", "LabelSeries": "Series",
"LabelSeriesName": "Series Name", "LabelSeriesName": "Series Name",
"LabelSeriesProgress": "Series Progress", "LabelSeriesProgress": "Series Progress",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves", "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
"LabelSettingsChromecastSupport": "Chromecast support", "LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Date Format", "LabelSettingsDateFormat": "Date Format",
@ -489,6 +497,7 @@
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook", "MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...", "MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?",

View File

@ -98,6 +98,7 @@
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Detalji", "HeaderDetails": "Detalji",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email", "HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Epizode", "HeaderEpisodes": "Epizode",
@ -339,12 +340,15 @@
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefiksi za ignorirati (mala i velika slova nisu bitna)", "LabelPrefixesToIgnore": "Prefiksi za ignorirati (mala i velika slova nisu bitna)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories", "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Napredak", "LabelProgress": "Napredak",
"LabelProvider": "Dobavljač", "LabelProvider": "Dobavljač",
"LabelPubDate": "Datam izdavanja", "LabelPubDate": "Datam izdavanja",
"LabelPublisher": "Izdavač", "LabelPublisher": "Izdavač",
"LabelPublishYear": "Godina izdavanja", "LabelPublishYear": "Godina izdavanja",
"LabelRead": "Read",
"LabelReadAgain": "Read Again", "LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Nedavno dodano", "LabelRecentlyAdded": "Nedavno dodano",
"LabelRecentSeries": "Nedavne serije", "LabelRecentSeries": "Nedavne serije",
"LabelRecommended": "Recommended", "LabelRecommended": "Recommended",
@ -366,6 +370,10 @@
"LabelSeries": "Serije", "LabelSeries": "Serije",
"LabelSeriesName": "Ime serije", "LabelSeriesName": "Ime serije",
"LabelSeriesProgress": "Series Progress", "LabelSeriesProgress": "Series Progress",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "Skeumorfski (što god to bilo) dizajn sa drvenim policama", "LabelSettingsBookshelfViewHelp": "Skeumorfski (što god to bilo) dizajn sa drvenim policama",
"LabelSettingsChromecastSupport": "Chromecast podrška", "LabelSettingsChromecastSupport": "Chromecast podrška",
"LabelSettingsDateFormat": "Format datuma", "LabelSettingsDateFormat": "Format datuma",
@ -489,6 +497,7 @@
"MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.", "MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.",
"MessageCheckingCron": "Provjeravam cron...", "MessageCheckingCron": "Provjeravam cron...",
"MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?", "MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?", "MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?",
"MessageConfirmDeleteSession": "Jeste li sigurni da želite obrisati ovu sesiju?", "MessageConfirmDeleteSession": "Jeste li sigurni da želite obrisati ovu sesiju?",
"MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?", "MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?",

View File

@ -98,6 +98,7 @@
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Dettagli", "HeaderDetails": "Dettagli",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email", "HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodi", "HeaderEpisodes": "Episodi",
@ -339,12 +340,15 @@
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)", "LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)",
"LabelPreventIndexing": "Impedisci che il tuo feed venga indicizzato da iTunes e dalle directory dei podcast di Google", "LabelPreventIndexing": "Impedisci che il tuo feed venga indicizzato da iTunes e dalle directory dei podcast di Google",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Cominciati", "LabelProgress": "Cominciati",
"LabelProvider": "Provider", "LabelProvider": "Provider",
"LabelPubDate": "Data Pubblicazione", "LabelPubDate": "Data Pubblicazione",
"LabelPublisher": "Editore", "LabelPublisher": "Editore",
"LabelPublishYear": "Anno Pubblicazione", "LabelPublishYear": "Anno Pubblicazione",
"LabelRead": "Read",
"LabelReadAgain": "Read Again", "LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Aggiunti Recentemente", "LabelRecentlyAdded": "Aggiunti Recentemente",
"LabelRecentSeries": "Serie Recenti", "LabelRecentSeries": "Serie Recenti",
"LabelRecommended": "Raccomandati", "LabelRecommended": "Raccomandati",
@ -366,6 +370,10 @@
"LabelSeries": "Serie", "LabelSeries": "Serie",
"LabelSeriesName": "Nome Serie", "LabelSeriesName": "Nome Serie",
"LabelSeriesProgress": "Cominciato", "LabelSeriesProgress": "Cominciato",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "Design con scaffali in legno", "LabelSettingsBookshelfViewHelp": "Design con scaffali in legno",
"LabelSettingsChromecastSupport": "Supporto a Chromecast", "LabelSettingsChromecastSupport": "Supporto a Chromecast",
"LabelSettingsDateFormat": "Formato Data", "LabelSettingsDateFormat": "Formato Data",
@ -489,6 +497,7 @@
"MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro", "MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro",
"MessageCheckingCron": "Controllo cron...", "MessageCheckingCron": "Controllo cron...",
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?", "MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?", "MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?", "MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?", "MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",

View File

@ -98,6 +98,7 @@
"HeaderCurrentDownloads": "Huidige downloads", "HeaderCurrentDownloads": "Huidige downloads",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download-wachtrij", "HeaderDownloadQueue": "Download-wachtrij",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email", "HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Afleveringen", "HeaderEpisodes": "Afleveringen",
@ -339,12 +340,15 @@
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "Te negeren voorzetsels (ongeacht hoofdlettergebruik)", "LabelPrefixesToIgnore": "Te negeren voorzetsels (ongeacht hoofdlettergebruik)",
"LabelPreventIndexing": "Voorkom indexering van je feed door iTunes- en Google podcastmappen", "LabelPreventIndexing": "Voorkom indexering van je feed door iTunes- en Google podcastmappen",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Voortgang", "LabelProgress": "Voortgang",
"LabelProvider": "Bron", "LabelProvider": "Bron",
"LabelPubDate": "Publicatiedatum", "LabelPubDate": "Publicatiedatum",
"LabelPublisher": "Uitgever", "LabelPublisher": "Uitgever",
"LabelPublishYear": "Jaar van uitgave", "LabelPublishYear": "Jaar van uitgave",
"LabelRead": "Read",
"LabelReadAgain": "Read Again", "LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Recent toegevoegd", "LabelRecentlyAdded": "Recent toegevoegd",
"LabelRecentSeries": "Recente series", "LabelRecentSeries": "Recente series",
"LabelRecommended": "Aangeraden", "LabelRecommended": "Aangeraden",
@ -366,6 +370,10 @@
"LabelSeries": "Serie", "LabelSeries": "Serie",
"LabelSeriesName": "Naam serie", "LabelSeriesName": "Naam serie",
"LabelSeriesProgress": "Voortgang serie", "LabelSeriesProgress": "Voortgang serie",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken", "LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
"LabelSettingsChromecastSupport": "Chromecast support", "LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Datum format", "LabelSettingsDateFormat": "Datum format",
@ -489,6 +497,7 @@
"MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek", "MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek",
"MessageCheckingCron": "Cron aan het checken...", "MessageCheckingCron": "Cron aan het checken...",
"MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?", "MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?", "MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?",
"MessageConfirmDeleteSession": "Weet je zeker dat je deze sessie wil verwijderen?", "MessageConfirmDeleteSession": "Weet je zeker dat je deze sessie wil verwijderen?",
"MessageConfirmForceReScan": "Weet je zeker dat je geforceerd opnieuw wil scannen?", "MessageConfirmForceReScan": "Weet je zeker dat je geforceerd opnieuw wil scannen?",

View File

@ -98,6 +98,7 @@
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Szczegóły", "HeaderDetails": "Szczegóły",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email", "HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Rozdziały", "HeaderEpisodes": "Rozdziały",
@ -339,12 +340,15 @@
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)", "LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories", "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Postęp", "LabelProgress": "Postęp",
"LabelProvider": "Dostawca", "LabelProvider": "Dostawca",
"LabelPubDate": "Data publikacji", "LabelPubDate": "Data publikacji",
"LabelPublisher": "Wydawca", "LabelPublisher": "Wydawca",
"LabelPublishYear": "Rok publikacji", "LabelPublishYear": "Rok publikacji",
"LabelRead": "Read",
"LabelReadAgain": "Read Again", "LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Niedawno dodany", "LabelRecentlyAdded": "Niedawno dodany",
"LabelRecentSeries": "Ostatnie serie", "LabelRecentSeries": "Ostatnie serie",
"LabelRecommended": "Recommended", "LabelRecommended": "Recommended",
@ -366,6 +370,10 @@
"LabelSeries": "Serie", "LabelSeries": "Serie",
"LabelSeriesName": "Nazwy serii", "LabelSeriesName": "Nazwy serii",
"LabelSeriesProgress": "Postęp w serii", "LabelSeriesProgress": "Postęp w serii",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "Widok półki z ksiązkami", "LabelSettingsBookshelfViewHelp": "Widok półki z ksiązkami",
"LabelSettingsChromecastSupport": "Wsparcie Chromecast", "LabelSettingsChromecastSupport": "Wsparcie Chromecast",
"LabelSettingsDateFormat": "Format daty", "LabelSettingsDateFormat": "Format daty",
@ -489,6 +497,7 @@
"MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka", "MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka",
"MessageCheckingCron": "Sprawdzanie cron...", "MessageCheckingCron": "Sprawdzanie cron...",
"MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?", "MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?", "MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",
"MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?", "MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?",
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?", "MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",

View File

@ -98,6 +98,7 @@
"HeaderCurrentDownloads": "Текущие закачки", "HeaderCurrentDownloads": "Текущие закачки",
"HeaderDetails": "Подробности", "HeaderDetails": "Подробности",
"HeaderDownloadQueue": "Очередь скачивания", "HeaderDownloadQueue": "Очередь скачивания",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email", "HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Эпизоды", "HeaderEpisodes": "Эпизоды",
@ -339,12 +340,15 @@
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "Игнорируемые префиксы (без учета регистра)", "LabelPrefixesToIgnore": "Игнорируемые префиксы (без учета регистра)",
"LabelPreventIndexing": "Запретить индексацию фида каталогами подкастов iTunes и Google", "LabelPreventIndexing": "Запретить индексацию фида каталогами подкастов iTunes и Google",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Прогресс", "LabelProgress": "Прогресс",
"LabelProvider": "Провайдер", "LabelProvider": "Провайдер",
"LabelPubDate": "Дата публикации", "LabelPubDate": "Дата публикации",
"LabelPublisher": "Издатель", "LabelPublisher": "Издатель",
"LabelPublishYear": "Год публикации", "LabelPublishYear": "Год публикации",
"LabelRead": "Read",
"LabelReadAgain": "Read Again", "LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Недавно добавленные", "LabelRecentlyAdded": "Недавно добавленные",
"LabelRecentSeries": "Последние серии", "LabelRecentSeries": "Последние серии",
"LabelRecommended": "Рекомендованное", "LabelRecommended": "Рекомендованное",
@ -366,6 +370,10 @@
"LabelSeries": "Серия", "LabelSeries": "Серия",
"LabelSeriesName": "Имя серии", "LabelSeriesName": "Имя серии",
"LabelSeriesProgress": "Прогресс серии", "LabelSeriesProgress": "Прогресс серии",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками", "LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками",
"LabelSettingsChromecastSupport": "Поддержка Chromecast", "LabelSettingsChromecastSupport": "Поддержка Chromecast",
"LabelSettingsDateFormat": "Формат даты", "LabelSettingsDateFormat": "Формат даты",
@ -489,6 +497,7 @@
"MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги", "MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги",
"MessageCheckingCron": "Проверка cron...", "MessageCheckingCron": "Проверка cron...",
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?", "MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?", "MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?", "MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?", "MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",

View File

@ -98,6 +98,7 @@
"HeaderCurrentDownloads": "当前下载", "HeaderCurrentDownloads": "当前下载",
"HeaderDetails": "详情", "HeaderDetails": "详情",
"HeaderDownloadQueue": "下载队列", "HeaderDownloadQueue": "下载队列",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email", "HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "剧集", "HeaderEpisodes": "剧集",
@ -339,12 +340,15 @@
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "忽略的前缀 (不区分大小写)", "LabelPrefixesToIgnore": "忽略的前缀 (不区分大小写)",
"LabelPreventIndexing": "防止 iTunes 和 Google 播客目录对你的源进行索引", "LabelPreventIndexing": "防止 iTunes 和 Google 播客目录对你的源进行索引",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "进度", "LabelProgress": "进度",
"LabelProvider": "供应商", "LabelProvider": "供应商",
"LabelPubDate": "出版日期", "LabelPubDate": "出版日期",
"LabelPublisher": "出版商", "LabelPublisher": "出版商",
"LabelPublishYear": "发布年份", "LabelPublishYear": "发布年份",
"LabelRead": "Read",
"LabelReadAgain": "Read Again", "LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "最近添加", "LabelRecentlyAdded": "最近添加",
"LabelRecentSeries": "最近添加系列", "LabelRecentSeries": "最近添加系列",
"LabelRecommended": "推荐内容", "LabelRecommended": "推荐内容",
@ -366,6 +370,10 @@
"LabelSeries": "系列", "LabelSeries": "系列",
"LabelSeriesName": "系列名称", "LabelSeriesName": "系列名称",
"LabelSeriesProgress": "系列进度", "LabelSeriesProgress": "系列进度",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计", "LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计",
"LabelSettingsChromecastSupport": "Chromecast 支持", "LabelSettingsChromecastSupport": "Chromecast 支持",
"LabelSettingsDateFormat": "日期格式", "LabelSettingsDateFormat": "日期格式",
@ -489,6 +497,7 @@
"MessageChapterStartIsAfter": "章节开始是在有声读物结束之后", "MessageChapterStartIsAfter": "章节开始是在有声读物结束之后",
"MessageCheckingCron": "检查计划任务...", "MessageCheckingCron": "检查计划任务...",
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
"MessageConfirmDeleteSession": "你确定要删除此会话吗?", "MessageConfirmDeleteSession": "你确定要删除此会话吗?",
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?",

View File

@ -611,13 +611,26 @@ class LibraryItemController {
} }
/** /**
* GET api/items/:id/ebook * GET api/items/:id/ebook/:fileid?
* fileid is the inode value stored in LibraryFile.ino or EBookFile.ino
* fileid is only required when reading a supplementary ebook
* when no fileid is passed in the primary ebook will be returned
* *
* @param {express.Request} req * @param {express.Request} req
* @param {express.Response} res * @param {express.Response} res
*/ */
async getEBookFile(req, res) { async getEBookFile(req, res) {
const ebookFile = req.libraryItem.media.ebookFile let ebookFile = null
if (req.params.fileid) {
ebookFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
if (!ebookFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id')
}
} else {
ebookFile = req.libraryItem.media.ebookFile
}
if (!ebookFile) { if (!ebookFile) {
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.metadata.title}"`) Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.metadata.title}"`)
return res.sendStatus(404) return res.sendStatus(404)
@ -632,6 +645,37 @@ class LibraryItemController {
res.sendFile(ebookFilePath) res.sendFile(ebookFilePath)
} }
/**
* PATCH api/items/:id/ebook/:fileid/status
* toggle the status of an ebook file.
* if an ebook file is the primary ebook, then it will be changed to supplementary
* if an ebook file is supplementary, then it will be changed to primary
*
* @param {express.Request} req
* @param {express.Response} res
*/
async updateEbookFileStatus(req, res) {
const ebookLibraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
if (!ebookLibraryFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id')
}
if (ebookLibraryFile.isSupplementary) {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`)
req.libraryItem.setPrimaryEbook(ebookLibraryFile)
} else {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`)
ebookLibraryFile.isSupplementary = true
req.libraryItem.setPrimaryEbook(null)
}
req.libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
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 = this.db.libraryItems.find(li => li.id === req.params.id)
if (!req.libraryItem?.media) return res.sendStatus(404) if (!req.libraryItem?.media) return res.sendStatus(404)

View File

@ -33,6 +33,9 @@ class Library {
get isMusic() { get isMusic() {
return this.mediaType === 'music' return this.mediaType === 'music'
} }
get isBook() {
return this.mediaType === 'book'
}
construct(library) { construct(library) {
this.id = library.id this.id = library.id

View File

@ -80,6 +80,16 @@ class LibraryItem {
this.media.libraryItemId = this.id this.media.libraryItemId = this.id
this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f)) this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f))
// Migration for v2.2.23 to set ebook library files as supplementary
if (this.isBook && this.media.ebookFile) {
for (const libraryFile of this.libraryFiles) {
if (libraryFile.isEBookFile && libraryFile.isSupplementary === null) {
libraryFile.isSupplementary = this.media.ebookFile.ino !== libraryFile.ino
}
}
}
} }
toJSON() { toJSON() {
@ -432,21 +442,30 @@ class LibraryItem {
} }
// Set metadata from files // Set metadata from files
async syncFiles(preferOpfMetadata) { async syncFiles(preferOpfMetadata, librarySettings) {
let hasUpdated = false let hasUpdated = false
if (this.mediaType === 'book') { if (this.isBook) {
// Add/update ebook file (ebooks that were removed are removed in checkScanData) // Add/update ebook files (ebooks that were removed are removed in checkScanData)
if (this.media.ebookFile) { if (librarySettings.audiobooksOnly) {
hasUpdated = this.media.ebookFile
if (hasUpdated) {
// If library was set to audiobooks only then set primary ebook as supplementary
Logger.info(`[LibraryItem] Library is audiobooks only so setting ebook "${this.media.ebookFile.metadata.filename}" as supplementary`)
}
this.setPrimaryEbook(null)
} else if (this.media.ebookFile) {
const matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === this.media.ebookFile.ino) const matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === this.media.ebookFile.ino)
if (matchingLibraryFile && this.media.ebookFile.updateFromLibraryFile(matchingLibraryFile)) { if (matchingLibraryFile && this.media.ebookFile.updateFromLibraryFile(matchingLibraryFile)) {
hasUpdated = true hasUpdated = true
} }
} else { } else {
const ebookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile && !lf.isSupplementary)
// Prefer epub ebook then fallback to first other ebook file // Prefer epub ebook then fallback to first other ebook file
const ebookLibraryFile = this.libraryFiles.find(lf => lf.isEBookFile && lf.metadata.format === 'epub') || this.libraryFiles.find(lf => lf.isEBookFile) const ebookLibraryFile = ebookLibraryFiles.find(lf => lf.metadata.format === 'epub') || ebookLibraryFiles[0]
if (ebookLibraryFile) { if (ebookLibraryFile) {
this.media.setEbookFile(ebookLibraryFile) this.setPrimaryEbook(ebookLibraryFile)
hasUpdated = true hasUpdated = true
} }
} }
@ -565,5 +584,20 @@ class LibraryItem {
} }
return false return false
} }
/**
* Set the EBookFile from a LibraryFile
* If null then ebookFile will be removed from the book
* all ebook library files that are not primary are marked as supplementary
*
* @param {LibraryFile} [libraryFile]
*/
setPrimaryEbook(ebookLibraryFile = null) {
const ebookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile)
for (const libraryFile of ebookLibraryFiles) {
libraryFile.isSupplementary = ebookLibraryFile?.ino !== libraryFile.ino
}
this.media.setEbookFile(ebookLibraryFile)
}
} }
module.exports = LibraryItem module.exports = LibraryItem

View File

@ -7,6 +7,7 @@ class LibraryFile {
constructor(file) { constructor(file) {
this.ino = null this.ino = null
this.metadata = null this.metadata = null
this.isSupplementary = null
this.addedAt = null this.addedAt = null
this.updatedAt = null this.updatedAt = null
@ -18,6 +19,7 @@ class LibraryFile {
construct(file) { construct(file) {
this.ino = file.ino this.ino = file.ino
this.metadata = new FileMetadata(file.metadata) this.metadata = new FileMetadata(file.metadata)
this.isSupplementary = file.isSupplementary === undefined ? null : file.isSupplementary
this.addedAt = file.addedAt this.addedAt = file.addedAt
this.updatedAt = file.updatedAt this.updatedAt = file.updatedAt
} }
@ -26,6 +28,7 @@ class LibraryFile {
return { return {
ino: this.ino, ino: this.ino,
metadata: this.metadata.toJSON(), metadata: this.metadata.toJSON(),
isSupplementary: this.isSupplementary,
addedAt: this.addedAt, addedAt: this.addedAt,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
fileType: this.fileType fileType: this.fileType

View File

@ -370,11 +370,21 @@ class Book {
return payload return payload
} }
setEbookFile(libraryFile) { /**
var ebookFile = new EBookFile() * Set the EBookFile from a LibraryFile
* If null then ebookFile will be removed from the book
*
* @param {LibraryFile} [libraryFile]
*/
setEbookFile(libraryFile = null) {
if (!libraryFile) {
this.ebookFile = null
} else {
const ebookFile = new EBookFile()
ebookFile.setData(libraryFile) ebookFile.setData(libraryFile)
this.ebookFile = ebookFile this.ebookFile = ebookFile
} }
}
addAudioFile(audioFile) { addAudioFile(audioFile) {
this.audioFiles.push(audioFile) this.audioFiles.push(audioFile)

View File

@ -7,6 +7,7 @@ class LibrarySettings {
this.skipMatchingMediaWithAsin = false this.skipMatchingMediaWithAsin = false
this.skipMatchingMediaWithIsbn = false this.skipMatchingMediaWithIsbn = false
this.autoScanCronExpression = null this.autoScanCronExpression = null
this.audiobooksOnly = false
if (settings) { if (settings) {
this.construct(settings) this.construct(settings)
@ -19,6 +20,7 @@ class LibrarySettings {
this.skipMatchingMediaWithAsin = !!settings.skipMatchingMediaWithAsin this.skipMatchingMediaWithAsin = !!settings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!settings.skipMatchingMediaWithIsbn this.skipMatchingMediaWithIsbn = !!settings.skipMatchingMediaWithIsbn
this.autoScanCronExpression = settings.autoScanCronExpression || null this.autoScanCronExpression = settings.autoScanCronExpression || null
this.audiobooksOnly = !!settings.audiobooksOnly
} }
toJSON() { toJSON() {
@ -27,12 +29,13 @@ class LibrarySettings {
disableWatcher: this.disableWatcher, disableWatcher: this.disableWatcher,
skipMatchingMediaWithAsin: this.skipMatchingMediaWithAsin, skipMatchingMediaWithAsin: this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: this.skipMatchingMediaWithIsbn, skipMatchingMediaWithIsbn: this.skipMatchingMediaWithIsbn,
autoScanCronExpression: this.autoScanCronExpression autoScanCronExpression: this.autoScanCronExpression,
audiobooksOnly: this.audiobooksOnly
} }
} }
update(payload) { update(payload) {
var hasUpdates = false let hasUpdates = false
for (const key in payload) { for (const key in payload) {
if (this[key] !== payload[key]) { if (this[key] !== payload[key]) {
this[key] = payload[key] this[key] = payload[key]

View File

@ -125,7 +125,8 @@ class ApiRouter {
this.router.get('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getLibraryFile.bind(this)) this.router.get('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getLibraryFile.bind(this))
this.router.delete('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.deleteLibraryFile.bind(this)) this.router.delete('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.deleteLibraryFile.bind(this))
this.router.get('/items/:id/file/:fileid/download', LibraryItemController.middleware.bind(this), LibraryItemController.downloadLibraryFile.bind(this)) this.router.get('/items/:id/file/:fileid/download', LibraryItemController.middleware.bind(this), LibraryItemController.downloadLibraryFile.bind(this))
this.router.get('/items/:id/ebook', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this)) this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this))
this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this))
// //
// User Routes // User Routes

View File

@ -3,7 +3,7 @@ const fs = require('../libs/fsExtra')
const date = require('../libs/dateAndTime') const date = require('../libs/dateAndTime')
const Logger = require('../Logger') const Logger = require('../Logger')
const Folder = require('../objects/Folder') const Library = require('../objects/Library')
const { LogLevel } = require('../utils/constants') const { LogLevel } = require('../utils/constants')
const filePerms = require('../utils/filePerms') const filePerms = require('../utils/filePerms')
const { getId, secondsToTimestamp } = require('../utils/index') const { getId, secondsToTimestamp } = require('../utils/index')
@ -12,10 +12,7 @@ class LibraryScan {
constructor() { constructor() {
this.id = null this.id = null
this.type = null this.type = null
this.libraryId = null this.library = null
this.libraryName = null
this.libraryMediaType = null
this.folders = null
this.verbose = false this.verbose = false
this.scanOptions = null this.scanOptions = null
@ -31,6 +28,11 @@ class LibraryScan {
this.logs = [] this.logs = []
} }
get libraryId() { return this.library.id }
get libraryName() { return this.library.name }
get libraryMediaType() { return this.library.mediaType }
get folders() { return this.library.folders }
get _scanOptions() { return this.scanOptions || {} } get _scanOptions() { return this.scanOptions || {} }
get forceRescan() { return !!this._scanOptions.forceRescan } get forceRescan() { return !!this._scanOptions.forceRescan }
get preferAudioMetadata() { return !!this._scanOptions.preferAudioMetadata } get preferAudioMetadata() { return !!this._scanOptions.preferAudioMetadata }
@ -70,10 +72,7 @@ class LibraryScan {
return { return {
id: this.id, id: this.id,
type: this.type, type: this.type,
libraryId: this.libraryId, library: this.library.toJSON(),
libraryName: this.libraryName,
libraryMediaType: this.libraryMediaType,
folders: this.folders.map(f => f.toJSON()),
scanOptions: this.scanOptions ? this.scanOptions.toJSON() : null, scanOptions: this.scanOptions ? this.scanOptions.toJSON() : null,
startedAt: this.startedAt, startedAt: this.startedAt,
finishedAt: this.finishedAt, finishedAt: this.finishedAt,
@ -87,10 +86,7 @@ class LibraryScan {
setData(library, scanOptions, type = 'scan') { setData(library, scanOptions, type = 'scan') {
this.id = getId('lscan') this.id = getId('lscan')
this.type = type this.type = type
this.libraryId = library.id this.library = new Library(library.toJSON()) // clone library
this.libraryName = library.name
this.libraryMediaType = library.mediaType
this.folders = library.folders.map(folder => new Folder(folder.toJSON()))
this.scanOptions = scanOptions this.scanOptions = scanOptions

View File

@ -1,5 +1,5 @@
class ScanOptions { class ScanOptions {
constructor(options) { constructor() {
this.forceRescan = false this.forceRescan = false
// Server settings // Server settings
@ -10,26 +10,11 @@ class ScanOptions {
this.preferOpfMetadata = false this.preferOpfMetadata = false
this.preferMatchedMetadata = false this.preferMatchedMetadata = false
this.preferOverdriveMediaMarker = false this.preferOverdriveMediaMarker = false
if (options) {
this.construct(options)
}
}
construct(options) {
for (const key in options) {
if (key === 'metadataPrecedence' && options[key].length) {
this.metadataPrecedence = [...options[key]]
} else if (this[key] !== undefined) {
this[key] = options[key]
}
}
} }
toJSON() { toJSON() {
return { return {
forceRescan: this.forceRescan, forceRescan: this.forceRescan,
metadataPrecedence: this.metadataPrecedence,
parseSubtitles: this.parseSubtitles, parseSubtitles: this.parseSubtitles,
findCovers: this.findCovers, findCovers: this.findCovers,
storeCoverWithItem: this.storeCoverWithItem, storeCoverWithItem: this.storeCoverWithItem,

View File

@ -4,7 +4,7 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
// Utils // Utils
const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder } = require('../utils/scandir') const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder, checkFilepathIsAudioFile } = require('../utils/scandir')
const { comparePaths } = require('../utils/index') const { comparePaths } = require('../utils/index')
const { getIno, filePathToPOSIX } = require('../utils/fileUtils') const { getIno, filePathToPOSIX } = require('../utils/fileUtils')
const { ScanResult, LogLevel } = require('../utils/constants') const { ScanResult, LogLevel } = require('../utils/constants')
@ -86,7 +86,7 @@ class Scanner {
}) })
this.taskManager.addTask(task) this.taskManager.addTask(task)
const result = await this.scanLibraryItem(library.mediaType, folder, libraryItem) const result = await this.scanLibraryItem(library, folder, libraryItem)
task.setFinished(this.getScanResultDescription(result)) task.setFinished(this.getScanResultDescription(result))
this.taskManager.taskFinished(task) this.taskManager.taskFinished(task)
@ -94,7 +94,9 @@ class Scanner {
return result return result
} }
async scanLibraryItem(libraryMediaType, folder, libraryItem) { async scanLibraryItem(library, folder, libraryItem) {
const libraryMediaType = library.mediaType
// TODO: Support for single media item // TODO: Support for single media item
const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false) const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false)
if (!libraryItemData) { if (!libraryItemData) {
@ -106,7 +108,7 @@ class Scanner {
if (checkRes.updated) hasUpdated = true if (checkRes.updated) hasUpdated = true
// Sync other files first so that local images are used as cover art // Sync other files first so that local images are used as cover art
if (await libraryItem.syncFiles(this.db.serverSettings.scannerPreferOpfMetadata)) { if (await libraryItem.syncFiles(this.db.serverSettings.scannerPreferOpfMetadata, library.settings)) {
hasUpdated = true hasUpdated = true
} }
@ -157,10 +159,10 @@ class Scanner {
return return
} }
var scanOptions = new ScanOptions() const scanOptions = new ScanOptions()
scanOptions.setData(options, this.db.serverSettings) scanOptions.setData(options, this.db.serverSettings)
var libraryScan = new LibraryScan() const libraryScan = new LibraryScan()
libraryScan.setData(library, scanOptions) libraryScan.setData(library, scanOptions)
libraryScan.verbose = false libraryScan.verbose = false
this.librariesScanning.push(libraryScan.getScanEmitData) this.librariesScanning.push(libraryScan.getScanEmitData)
@ -169,7 +171,7 @@ class Scanner {
Logger.info(`[Scanner] Starting library scan ${libraryScan.id} for ${libraryScan.libraryName}`) Logger.info(`[Scanner] Starting library scan ${libraryScan.id} for ${libraryScan.libraryName}`)
var canceled = await this.scanLibrary(libraryScan) const canceled = await this.scanLibrary(libraryScan)
if (canceled) { if (canceled) {
Logger.info(`[Scanner] Library scan canceled for "${libraryScan.libraryName}"`) Logger.info(`[Scanner] Library scan canceled for "${libraryScan.libraryName}"`)
@ -182,7 +184,7 @@ class Scanner {
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id) this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
if (canceled && !libraryScan.totalResults) { if (canceled && !libraryScan.totalResults) {
var emitData = libraryScan.getScanEmitData const emitData = libraryScan.getScanEmitData
emitData.results = null emitData.results = null
SocketAuthority.emitter('scan_complete', emitData) SocketAuthority.emitter('scan_complete', emitData)
return return
@ -201,7 +203,7 @@ class Scanner {
// Scan each library // Scan each library
for (let i = 0; i < libraryScan.folders.length; i++) { for (let i = 0; i < libraryScan.folders.length; i++) {
const folder = libraryScan.folders[i] const folder = libraryScan.folders[i]
const itemDataFoundInFolder = await scanFolder(libraryScan.libraryMediaType, folder) const itemDataFoundInFolder = await scanFolder(libraryScan.library, folder)
libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`) libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`)
libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder) libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder)
} }
@ -356,7 +358,7 @@ class Scanner {
async scanNewLibraryItemDataChunk(newLibraryItemsData, libraryScan) { async scanNewLibraryItemDataChunk(newLibraryItemsData, libraryScan) {
let newLibraryItems = await Promise.all(newLibraryItemsData.map((lid) => { let newLibraryItems = await Promise.all(newLibraryItemsData.map((lid) => {
return this.scanNewLibraryItem(lid, libraryScan.libraryMediaType, libraryScan) return this.scanNewLibraryItem(lid, libraryScan.library, libraryScan)
})) }))
newLibraryItems = newLibraryItems.filter(li => li) // Filter out nulls newLibraryItems = newLibraryItems.filter(li => li) // Filter out nulls
@ -376,7 +378,7 @@ class Scanner {
let hasUpdated = updated let hasUpdated = updated
// Sync other files first to use local images as cover before extracting audio file cover // Sync other files first to use local images as cover before extracting audio file cover
if (await libraryItem.syncFiles(libraryScan.preferOpfMetadata)) { if (await libraryItem.syncFiles(libraryScan.preferOpfMetadata, libraryScan.library.settings)) {
hasUpdated = true hasUpdated = true
} }
@ -425,7 +427,7 @@ class Scanner {
return hasUpdated ? libraryItem : null return hasUpdated ? libraryItem : null
} }
async scanNewLibraryItem(libraryItemData, libraryMediaType, libraryScan = null) { async scanNewLibraryItem(libraryItemData, library, libraryScan = null) {
if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Scanning new library item "${libraryItemData.path}"`) if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Scanning new library item "${libraryItemData.path}"`)
else Logger.debug(`[Scanner] Scanning new item "${libraryItemData.path}"`) else Logger.debug(`[Scanner] Scanning new item "${libraryItemData.path}"`)
@ -433,14 +435,14 @@ class Scanner {
const findCovers = libraryScan ? !!libraryScan.findCovers : !!global.ServerSettings.scannerFindCovers const findCovers = libraryScan ? !!libraryScan.findCovers : !!global.ServerSettings.scannerFindCovers
const libraryItem = new LibraryItem() const libraryItem = new LibraryItem()
libraryItem.setData(libraryMediaType, libraryItemData) libraryItem.setData(library.mediaType, libraryItemData)
const mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video') const mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video')
if (mediaFiles.length) { if (mediaFiles.length) {
await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItem, libraryScan) await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItem, libraryScan)
} }
await libraryItem.syncFiles(preferOpfMetadata) await libraryItem.syncFiles(preferOpfMetadata, library.settings)
if (!libraryItem.hasMediaEntities) { if (!libraryItem.hasMediaEntities) {
Logger.warn(`[Scanner] Library item has no media files "${libraryItemData.path}"`) Logger.warn(`[Scanner] Library item has no media files "${libraryItemData.path}"`)
@ -457,7 +459,7 @@ class Scanner {
} }
// Scan for cover if enabled and has no cover // Scan for cover if enabled and has no cover
if (libraryMediaType === 'book') { if (library.isBook) {
if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) { if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) {
const updatedCover = await this.searchForCover(libraryItem, libraryScan) const updatedCover = await this.searchForCover(libraryItem, libraryScan)
libraryItem.media.updateLastCoverSearch(updatedCover) libraryItem.media.updateLastCoverSearch(updatedCover)
@ -534,7 +536,7 @@ class Scanner {
} }
async scanFilesChanged(fileUpdates) { async scanFilesChanged(fileUpdates) {
if (!fileUpdates || !fileUpdates.length) return if (!fileUpdates?.length) return
// If already scanning files from watcher then add these updates to queue // If already scanning files from watcher then add these updates to queue
if (this.scanningFilesChanged) { if (this.scanningFilesChanged) {
@ -545,28 +547,28 @@ class Scanner {
this.scanningFilesChanged = true this.scanningFilesChanged = true
// files grouped by folder // files grouped by folder
var folderGroups = this.getFileUpdatesGrouped(fileUpdates) const folderGroups = this.getFileUpdatesGrouped(fileUpdates)
for (const folderId in folderGroups) { for (const folderId in folderGroups) {
var libraryId = folderGroups[folderId].libraryId const libraryId = folderGroups[folderId].libraryId
var library = this.db.libraries.find(lib => lib.id === libraryId) const library = this.db.libraries.find(lib => lib.id === libraryId)
if (!library) { if (!library) {
Logger.error(`[Scanner] Library not found in files changed ${libraryId}`) Logger.error(`[Scanner] Library not found in files changed ${libraryId}`)
continue; continue;
} }
var folder = library.getFolderById(folderId) const folder = library.getFolderById(folderId)
if (!folder) { if (!folder) {
Logger.error(`[Scanner] Folder is not in library in files changed "${folderId}", Library "${library.name}"`) Logger.error(`[Scanner] Folder is not in library in files changed "${folderId}", Library "${library.name}"`)
continue; continue;
} }
var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath) const relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
var fileUpdateGroup = groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths) const fileUpdateGroup = groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths, false)
if (!Object.keys(fileUpdateGroup).length) { if (!Object.keys(fileUpdateGroup).length) {
Logger.info(`[Scanner] No important changes to scan for in folder "${folderId}"`) Logger.info(`[Scanner] No important changes to scan for in folder "${folderId}"`)
continue; continue;
} }
var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup) const folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)
Logger.debug(`[Scanner] Folder scan results`, folderScanResults) Logger.debug(`[Scanner] Folder scan results`, folderScanResults)
} }
@ -584,25 +586,25 @@ class Scanner {
// First pass - Remove files in parent dirs of items and remap the fileupdate group // First pass - Remove files in parent dirs of items and remap the fileupdate group
// Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item // Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item
var updateGroup = { ...fileUpdateGroup } const updateGroup = { ...fileUpdateGroup }
for (const itemDir in updateGroup) { for (const itemDir in updateGroup) {
if (itemDir == fileUpdateGroup[itemDir]) continue; // Media in root path if (itemDir == fileUpdateGroup[itemDir]) continue; // Media in root path
var itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/')) const itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/'))
if (!itemDirNestedFiles.length) continue; if (!itemDirNestedFiles.length) continue;
var firstNest = itemDirNestedFiles[0].split('/').shift() const firstNest = itemDirNestedFiles[0].split('/').shift()
var altDir = `${itemDir}/${firstNest}` const altDir = `${itemDir}/${firstNest}`
var fullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), itemDir) const fullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), itemDir)
var childLibraryItem = this.db.libraryItems.find(li => li.path !== fullPath && li.path.startsWith(fullPath)) const childLibraryItem = this.db.libraryItems.find(li => li.path !== fullPath && li.path.startsWith(fullPath))
if (!childLibraryItem) { if (!childLibraryItem) {
continue; continue
} }
var altFullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), altDir) const altFullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), altDir)
var altChildLibraryItem = this.db.libraryItems.find(li => li.path !== altFullPath && li.path.startsWith(altFullPath)) const altChildLibraryItem = this.db.libraryItems.find(li => li.path !== altFullPath && li.path.startsWith(altFullPath))
if (altChildLibraryItem) { if (altChildLibraryItem) {
continue; continue
} }
delete fileUpdateGroup[itemDir] delete fileUpdateGroup[itemDir]
@ -638,14 +640,17 @@ class Scanner {
SocketAuthority.emitter('item_updated', existingLibraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', existingLibraryItem.toJSONExpanded())
itemGroupingResults[itemDir] = ScanResult.REMOVED itemGroupingResults[itemDir] = ScanResult.REMOVED
continue; continue
} }
} }
// Scan library item for updates // Scan library item for updates
Logger.debug(`[Scanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" - scan for updates`) Logger.debug(`[Scanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" - scan for updates`)
itemGroupingResults[itemDir] = await this.scanLibraryItem(library.mediaType, folder, existingLibraryItem) itemGroupingResults[itemDir] = await this.scanLibraryItem(library, folder, existingLibraryItem)
continue; continue
} else if (library.settings.audiobooksOnly && !fileUpdateGroup[itemDir].some(checkFilepathIsAudioFile)) {
Logger.debug(`[Scanner] Folder update for relative path "${itemDir}" has no audio files`)
continue
} }
// Check if a library item is a subdirectory of this dir // Check if a library item is a subdirectory of this dir
@ -653,12 +658,12 @@ class Scanner {
if (childItem) { if (childItem) {
Logger.warn(`[Scanner] Files were modified in a parent directory of a library item "${childItem.media.metadata.title}" - ignoring`) Logger.warn(`[Scanner] Files were modified in a parent directory of a library item "${childItem.media.metadata.title}" - ignoring`)
itemGroupingResults[itemDir] = ScanResult.NOTHING itemGroupingResults[itemDir] = ScanResult.NOTHING
continue; continue
} }
Logger.debug(`[Scanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`) Logger.debug(`[Scanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`)
var isSingleMediaItem = itemDir === fileUpdateGroup[itemDir] var isSingleMediaItem = itemDir === fileUpdateGroup[itemDir]
var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath, isSingleMediaItem) var newLibraryItem = await this.scanPotentialNewLibraryItem(library, folder, fullPath, isSingleMediaItem)
if (newLibraryItem) { if (newLibraryItem) {
await this.createNewAuthorsAndSeries(newLibraryItem) await this.createNewAuthorsAndSeries(newLibraryItem)
await this.db.insertLibraryItem(newLibraryItem) await this.db.insertLibraryItem(newLibraryItem)
@ -670,10 +675,10 @@ class Scanner {
return itemGroupingResults return itemGroupingResults
} }
async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath, isSingleMediaItem = false) { async scanPotentialNewLibraryItem(library, folder, fullPath, isSingleMediaItem = false) {
const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem) const libraryItemData = await getLibraryItemFileData(library.mediaType, folder, fullPath, isSingleMediaItem)
if (!libraryItemData) return null if (!libraryItemData) return null
return this.scanNewLibraryItem(libraryItemData, libraryMediaType) return this.scanNewLibraryItem(libraryItemData, library)
} }
async searchForCover(libraryItem, libraryScan = null) { async searchForCover(libraryItem, libraryScan = null) {

View File

@ -5,14 +5,23 @@ const { recurseFiles, getFileTimestampsWithIno, filePathToPOSIX } = require('./f
const globals = require('./globals') const globals = require('./globals')
const LibraryFile = require('../objects/files/LibraryFile') const LibraryFile = require('../objects/files/LibraryFile')
function isMediaFile(mediaType, ext) { function isMediaFile(mediaType, ext, audiobooksOnly = false) {
if (!ext) return false if (!ext) return false
var extclean = ext.slice(1).toLowerCase() const extclean = ext.slice(1).toLowerCase()
if (mediaType === 'podcast' || mediaType === 'music') return globals.SupportedAudioTypes.includes(extclean) if (mediaType === 'podcast' || mediaType === 'music') return globals.SupportedAudioTypes.includes(extclean)
else if (mediaType === 'video') return globals.SupportedVideoTypes.includes(extclean) else if (mediaType === 'video') return globals.SupportedVideoTypes.includes(extclean)
else if (audiobooksOnly) return globals.SupportedAudioTypes.includes(extclean)
return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean) return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean)
} }
function checkFilepathIsAudioFile(filepath) {
const ext = Path.extname(filepath)
if (!ext) return false
const extclean = ext.slice(1).toLowerCase()
return globals.SupportedAudioTypes.includes(extclean)
}
module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile
// TODO: Function needs to be re-done // TODO: Function needs to be re-done
// Input: array of relative file paths // Input: array of relative file paths
// Output: map of files grouped into potential item dirs // Output: map of files grouped into potential item dirs
@ -25,12 +34,12 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) {
let parsedPath = Path.parse(path) let parsedPath = Path.parse(path)
// Is not in root dir OR is a book media file // Is not in root dir OR is a book media file
if (parsedPath.dir) { if (parsedPath.dir) {
if (!isMediaFile(mediaType, parsedPath.ext)) { // Seperate out non-media files if (!isMediaFile(mediaType, parsedPath.ext, false)) { // Seperate out non-media files
nonMediaFilePaths.push(path) nonMediaFilePaths.push(path)
return false return false
} }
return true return true
} else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext)) { // (book media type supports single file audiobooks/ebooks in root dir) } else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext, false)) { // (book media type supports single file audiobooks/ebooks in root dir)
return true return true
} }
return false return false
@ -90,11 +99,11 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
// Input: array of relative file items (see recurseFiles) // Input: array of relative file items (see recurseFiles)
// Output: map of files grouped into potential libarary item dirs // Output: map of files grouped into potential libarary item dirs
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) { function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly = false) {
// Handle music where every audio file is a library item // Handle music where every audio file is a library item
if (mediaType === 'music') { if (mediaType === 'music') {
const audioFileGroup = {} const audioFileGroup = {}
fileItems.filter(i => isMediaFile(mediaType, i.extension)).forEach((item) => { fileItems.filter(i => isMediaFile(mediaType, i.extension, audiobooksOnly)).forEach((item) => {
audioFileGroup[item.path] = item.path audioFileGroup[item.path] = item.path
}) })
return audioFileGroup return audioFileGroup
@ -102,7 +111,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
// Step 1: Filter out non-book-media files in root dir (with depth of 0) // Step 1: Filter out non-book-media files in root dir (with depth of 0)
const itemsFiltered = fileItems.filter(i => { const itemsFiltered = fileItems.filter(i => {
return i.deep > 0 || ((mediaType === 'book' || mediaType === 'video' || mediaType === 'music') && isMediaFile(mediaType, i.extension)) return i.deep > 0 || ((mediaType === 'book' || mediaType === 'video' || mediaType === 'music') && isMediaFile(mediaType, i.extension, audiobooksOnly))
}) })
// Step 2: Seperate media files and other files // Step 2: Seperate media files and other files
@ -110,7 +119,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
const mediaFileItems = [] const mediaFileItems = []
const otherFileItems = [] const otherFileItems = []
itemsFiltered.forEach(item => { itemsFiltered.forEach(item => {
if (isMediaFile(mediaType, item.extension)) mediaFileItems.push(item) if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item)
else otherFileItems.push(item) else otherFileItems.push(item)
}) })
@ -175,7 +184,7 @@ function cleanFileObjects(libraryItemPath, files) {
} }
// Scan folder // Scan folder
async function scanFolder(libraryMediaType, folder) { async function scanFolder(library, folder) {
const folderPath = filePathToPOSIX(folder.fullPath) const folderPath = filePathToPOSIX(folder.fullPath)
const pathExists = await fs.pathExists(folderPath) const pathExists = await fs.pathExists(folderPath)
@ -185,7 +194,7 @@ async function scanFolder(libraryMediaType, folder) {
} }
const fileItems = await recurseFiles(folderPath) const fileItems = await recurseFiles(folderPath)
const libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems) const libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(library.mediaType, fileItems, library.settings.audiobooksOnly)
if (!Object.keys(libraryItemGrouping).length) { if (!Object.keys(libraryItemGrouping).length) {
Logger.error(`Root path has no media folders: ${folderPath}`) Logger.error(`Root path has no media folders: ${folderPath}`)
@ -197,7 +206,7 @@ async function scanFolder(libraryMediaType, folder) {
let isFile = false // item is not in a folder let isFile = false // item is not in a folder
let libraryItemData = null let libraryItemData = null
let fileObjs = [] let fileObjs = []
if (libraryMediaType === 'music') { if (library.mediaType === 'music') {
libraryItemData = { libraryItemData = {
path: Path.posix.join(folderPath, libraryItemPath), path: Path.posix.join(folderPath, libraryItemPath),
relPath: libraryItemPath relPath: libraryItemPath
@ -216,7 +225,7 @@ async function scanFolder(libraryMediaType, folder) {
fileObjs = await cleanFileObjects(folderPath, [libraryItemPath]) fileObjs = await cleanFileObjects(folderPath, [libraryItemPath])
isFile = true isFile = true
} else { } else {
libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath) libraryItemData = getDataFromMediaDir(library.mediaType, folderPath, libraryItemPath)
fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath]) fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath])
} }