Compare commits

..

25 Commits

Author SHA1 Message Date
advplyr aa82c8a253 Version bump 2.2.23 2023-06-10 16:00:48 -05:00
advplyr aae92649b1 Add:Ebook and supplementary ebook library filters 2023-06-10 15:59:44 -05:00
advplyr a9f5c64204 Update:Cleanup UI/UX for filter and sort dropdowns 2023-06-10 15:46:12 -05:00
advplyr 1392baf1eb Fix:Remove experimental features 2023-06-10 15:28:21 -05:00
advplyr 0ec50bb570 Remove experimental features and experimental ereader setting 2023-06-10 14:11:51 -05:00
advplyr b60473d7ae Update:Setting new other ebook files as supplementary #1809 2023-06-10 13:20:38 -05:00
advplyr 014fc45c15 Add:Audiobooks only library settings, supplementary ebooks #1664 2023-06-10 12:46:57 -05:00
advplyr 4b4fb33d8f Fix:Pressing edit on a podcast episode from a playlist #1833 2023-06-09 17:12:38 -05:00
advplyr 35e3458fb4 Add:Download button in comic reader to download current page image #1822 2023-06-07 17:03:23 -05:00
advplyr 8f42153bee Add:Save progress for comics #1829 2023-06-07 16:14:48 -05:00
advplyr 2f04d34bce Fix:Submenu overflowing page #1828 2023-06-07 15:48:23 -05:00
advplyr 09566c02ea Fix:Series page In Progress filter showing completed series #1827 2023-06-07 14:01:03 -05:00
advplyr d714ef37d9 Fix:Using arrow keys when editing podcast description #1826 2023-06-07 11:01:11 -05:00
advplyr fde07d26e5 Update:Prefer epub ebook file when setting ebook #1825, validate ebookLocation 2023-06-06 16:53:11 -05:00
advplyr 9547824aaa Merge pull request #1819 from mayli/usetemp
Fix: useTempFiles=true, upload use tmp instead of ram
2023-06-06 15:41:28 -05:00
advplyr 5a01be1ee3 Add tempFileDir for uploads 2023-06-06 15:40:52 -05:00
advplyr 5dc4606657 Add:Support for CAF audio files 2023-06-05 16:23:40 -05:00
Coda 2fd3238576 Fix: useTempFiles=true, upload use tmp instead of ram 2023-06-04 15:56:41 -07:00
advplyr c1bcfe8304 Merge pull request #1816 from mayli/utf8-filename
Fix: decode filename as utf8 on upload
2023-06-04 08:39:38 -05:00
Coda a3642b204d Fix: decode filename as utf8 on upload 2023-06-03 21:44:13 -07:00
advplyr 8243da69f6 Merge pull request #1814 from depwl9992/patch-1
Update readme.md - Expand required Apache modules for reverse proxy and detail how to handle acme challenges
2023-06-03 17:30:16 -05:00
Daniel Powell 6d5987b2e0 Update readme.md
After setting up the reverse proxy for Apache per the current instructions, I received the error: "AH01144: No protocol handler was valid for the URL / (scheme 'http')". Installing proxy_http and proxy_balancer modules in addition to those listed fixed this issue.

a2enmod command does not include the redundant '_module' suffix.

Let's Encrypt was not able to validate certificates either automatically or manually as ABS does not respond to ACME challenges directly. Editing the virtual environment configuration to ignore proxy requests to .well-known and serving that directly from Apache allowed LE to validate the challenge instead.
2023-06-03 11:46:42 -06:00
advplyr a2fdc3e876 Update:Increase max height of libraries dropdown 2023-06-01 17:09:04 -05:00
advplyr f92b66a469 Merge pull request #1810 from glacasa/master
Update french translations
2023-06-01 16:29:48 -05:00
Guillaume Lacasa c3d256c42b Update french translations 2023-06-01 09:38:00 +02:00
68 changed files with 1015 additions and 374 deletions
+1 -4
View File
@@ -7,7 +7,7 @@
</nuxt-link> </nuxt-link>
<nuxt-link to="/"> <nuxt-link to="/">
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1> <h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
</nuxt-link> </nuxt-link>
<ui-libraries-dropdown class="mr-2" /> <ui-libraries-dropdown class="mr-2" />
@@ -149,9 +149,6 @@ export default {
processingBatch() { processingBatch() {
return this.$store.state.processingBatch return this.$store.state.processingBatch
}, },
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
isChromecastEnabled() { isChromecastEnabled() {
return this.$store.getters['getServerSetting']('chromecastEnabled') return this.$store.getters['getServerSetting']('chromecastEnabled')
}, },
@@ -65,9 +65,6 @@ export default {
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
+1 -1
View File
@@ -82,7 +82,7 @@
<!-- issues page remove all button --> <!-- issues page remove all button -->
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn> <ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" menu-width="110px" class="ml-2" @action="contextMenuAction" /> <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
</template> </template>
<!-- search page --> <!-- search page -->
<template v-else-if="page === 'search'"> <template v-else-if="page === 'search'">
-3
View File
@@ -78,9 +78,6 @@ export default {
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryMediaType() { libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] return this.$store.getters['libraries/getCurrentLibraryMediaType']
}, },
+4 -10
View File
@@ -174,12 +174,6 @@ export default {
dateFormat() { dateFormat() {
return this.store.state.serverSettings.dateFormat return this.store.state.serverSettings.dateFormat
}, },
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
enableEReader() {
return this.store.getters['getServerSetting']('enableEReader')
},
_libraryItem() { _libraryItem() {
return this.libraryItem || {} return this.libraryItem || {}
}, },
@@ -367,13 +361,13 @@ export default {
return this.store.getters['getIsStreamingFromDifferentLibrary'] return this.store.getters['getIsStreamingFromDifferentLibrary']
}, },
showReadButton() { showReadButton() {
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat && (this.showExperimentalFeatures || this.enableEReader) return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
}, },
showPlayButton() { showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic) return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
}, },
showSmallEBookIcon() { showSmallEBookIcon() {
return !this.isSelectionMode && this.ebookFormat && (this.showExperimentalFeatures || this.enableEReader) return !this.isSelectionMode && this.ebookFormat
}, },
isMissing() { isMissing() {
return this._libraryItem.isMissing return this._libraryItem.isMissing
@@ -888,7 +882,7 @@ export default {
var wrapperBox = this.$refs.moreIcon.getBoundingClientRect() var wrapperBox = this.$refs.moreIcon.getBoundingClientRect()
var el = instance.$el var el = instance.$el
var elHeight = this.moreMenuItems.length * 28 + 2 var elHeight = this.moreMenuItems.length * 28 + 10
var elWidth = 130 var elWidth = 130
var bottomOfIcon = wrapperBox.top + wrapperBox.height var bottomOfIcon = wrapperBox.top + wrapperBox.height
@@ -921,7 +915,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
+9 -4
View File
@@ -1,6 +1,6 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between"> <span class="flex items-center justify-between">
<span class="block truncate text-xs">{{ selectedText }}</span> <span class="block truncate text-xs">{{ selectedText }}</span>
</span> </span>
@@ -14,12 +14,17 @@
</div> </div>
</button> </button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"> <div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-sm ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span> <span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div> </div>
</li> </li>
</template> </template>
@@ -1,6 +1,6 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between"> <span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span> </span>
@@ -9,7 +9,7 @@
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg> </svg>
</span> </span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected"> <div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-icons" style="font-size: 1.1rem">close</span> <span class="material-icons" style="font-size: 1.1rem">close</span>
</div> </div>
</button> </button>
@@ -17,23 +17,27 @@
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu"> <div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems"> <template v-for="item in selectItems">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span> <span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
</div> </div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center"> <div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_right</span> <span class="material-icons text-2xl">arrow_right</span>
</div> </div>
<!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li> </li>
</template> </template>
</ul> </ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null"> <li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center"> <div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_left</span> <span class="material-icons text-2xl">arrow_left</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate">Back</span> <span class="font-normal block truncate">Back</span>
</div> </div>
</li> </li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option"> <li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
@@ -41,16 +45,15 @@
<span class="font-normal block truncate py-2">No {{ sublist }}</span> <span class="font-normal block truncate py-2">No {{ sublist }}</span>
</div> </div>
</li> </li>
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('no-series'))">
<div class="flex items-center">
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">{{ $strings.MessageNoSeries }}</span>
</div>
</li>
<template v-for="item in sublistItems"> <template v-for="item in sublistItems">
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)"> <li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span> <span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div> </div>
<!-- selected checkmark icon -->
<div v-if="`${sublist}.${item.value}` === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li> </li>
</template> </template>
</ul> </ul>
@@ -72,9 +75,8 @@ export default {
}, },
watch: { watch: {
showMenu(newVal) { showMenu(newVal) {
if (!newVal) { if (newVal) {
if (this.sublist && !this.selectedItemSublist) this.sublist = null this.sublist = this.selectedItemSublist
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
} }
} }
}, },
@@ -186,9 +188,9 @@ export default {
sublist: true sublist: true
}, },
{ {
text: this.$strings.LabelEbook, text: this.$strings.LabelEbooks,
value: 'ebook', value: 'ebooks',
sublist: false sublist: true
}, },
{ {
text: this.$strings.LabelAbridged, text: this.$strings.LabelAbridged,
@@ -260,20 +262,20 @@ export default {
return this.bookItems return this.bookItems
}, },
selectedItemSublist() { selectedItemSublist() {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false return this.selected?.includes('.') ? this.selected.split('.')[0] : null
}, },
selectedText() { selectedText() {
if (!this.selected) return '' if (!this.selected) return ''
var parts = this.selected.split('.') const parts = this.selected.split('.')
var filterName = this.selectItems.find((i) => i.value === parts[0]) const filterName = this.selectItems.find((i) => i.value === parts[0])
var filterValue = null let filterValue = null
if (parts.length > 1) { if (parts.length > 1) {
var decoded = this.$decode(parts[1]) const decoded = this.$decode(parts[1])
if (decoded.startsWith('aut_')) { if (decoded.startsWith('aut_')) {
var author = this.authors.find((au) => au.id == decoded) const author = this.authors.find((au) => au.id == decoded)
if (author) filterValue = author.name if (author) filterValue = author.name
} else if (decoded.startsWith('ser_')) { } else if (decoded.startsWith('ser_')) {
var series = this.series.find((se) => se.id == decoded) const series = this.series.find((se) => se.id == decoded)
if (series) filterValue = series.name if (series) filterValue = series.name
} else { } else {
filterValue = decoded filterValue = decoded
@@ -339,6 +341,18 @@ export default {
} }
] ]
}, },
ebooks() {
return [
{
id: 'ebook',
name: this.$strings.LabelHasEbook
},
{
id: 'supplementary',
name: this.$strings.LabelHasSupplementaryEbook
}
]
},
missing() { missing() {
return [ return [
{ {
@@ -396,7 +410,7 @@ export default {
] ]
}, },
sublistItems() { sublistItems() {
return (this[this.sublist] || []).map((item) => { const sublistItems = (this[this.sublist] || []).map((item) => {
if (typeof item === 'string') { if (typeof item === 'string') {
return { return {
text: item, text: item,
@@ -409,6 +423,13 @@ export default {
} }
} }
}) })
if (this.sublist === 'series') {
sublistItems.unshift({
text: this.$strings.MessageNoSeries,
value: this.$encode('no-series')
})
}
return sublistItems
}, },
filterData() { filterData() {
return this.$store.state.libraries.filterData || {} return this.$store.state.libraries.filterData || {}
@@ -433,7 +454,7 @@ export default {
return return
} }
var val = option.value const val = option.value
if (this.selected === val) { if (this.selected === val) {
this.showMenu = false this.showMenu = false
return return
@@ -7,11 +7,11 @@
</span> </span>
</button> </button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems"> <template v-for="item in selectItems">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span> <span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div> </div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
+4 -4
View File
@@ -1,17 +1,17 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between"> <span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</button> </button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span> <span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div> </div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
@@ -127,9 +127,6 @@ export default {
} }
] ]
}, },
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
}, },
@@ -154,7 +151,6 @@ export default {
availableTabs() { availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return [] if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => { return this.tabs.filter((tab) => {
if (tab.experimental && !this.showExperimentalFeatures) return false
if (tab.mediaType && this.mediaType !== tab.mediaType) return false if (tab.mediaType && this.mediaType !== tab.mediaType) return false
if (tab.admin && !this.userIsAdminOrUp) return false if (tab.admin && !this.userIsAdminOrUp) return false
+2 -5
View File
@@ -20,7 +20,7 @@
</div> </div>
<!-- Split to mp3 --> <!-- Split to mp3 -->
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8"> <!-- <div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center"> <div class="flex items-center">
<div> <div>
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p> <p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
@@ -31,7 +31,7 @@
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn> <ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
</div> </div>
</div> </div>
</div> </div> -->
<!-- Embed Metadata --> <!-- Embed Metadata -->
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8"> <div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
@@ -79,9 +79,6 @@ export default {
return {} return {}
}, },
computed: { computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryItemId() { libraryItemId() {
return this.libraryItem?.id || null return this.libraryItem?.id || null
}, },
@@ -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() {
+95 -14
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52"> <div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400" :style="{ width: pageMenuWidth + 'px' }">
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)"> <div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index + 1)">
<p class="text-sm truncate">{{ file }}</p> <p class="text-sm truncate">{{ file }}</p>
</div> </div>
</div> </div>
@@ -14,23 +14,26 @@
</div> </div>
</div> </div>
<div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu"> <a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-32' : 'left-20'">
<span class="material-icons text-xl">download</span>
</a>
<div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
<span class="material-icons text-xl">more</span> <span class="material-icons text-xl">more</span>
</div> </div>
<div class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu"> <div v-if="numPages" class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu">
<span class="material-icons text-xl">menu</span> <span class="material-icons text-xl">menu</span>
</div> </div>
<div class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20"> <div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p> <p class="font-mono">{{ page }} / {{ numPages }}</p>
</div> </div>
<div class="overflow-hidden w-full h-full relative"> <div class="overflow-hidden w-full h-full relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent> <div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2"> <div class="flex items-center justify-center h-full w-1/2">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span> <span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div> </div>
</div> </div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent> <div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto"> <div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span> <span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div> </div>
@@ -61,7 +64,9 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
playerOpen: Boolean playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
}, },
data() { data() {
return { return {
@@ -71,6 +76,7 @@ export default {
mainImg: null, mainImg: null,
page: 0, page: 0,
numPages: 0, numPages: 0,
pageMenuWidth: 256,
showPageMenu: false, showPageMenu: false,
showInfoMenu: false, showInfoMenu: false,
loadTimeout: null, loadTimeout: null,
@@ -94,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() {
@@ -104,9 +113,59 @@ export default {
}, },
canGoPrev() { canGoPrev() {
return this.page > 0 return this.page > 0
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
return Number(this.userMediaProgress.ebookLocation)
},
cleanedPageNames() {
return (
this.pages?.map((p) => {
if (p.length > 50) {
let firstHalf = p.slice(0, 22)
let lastHalf = p.slice(p.length - 23)
return `${firstHalf} ... ${lastHalf}`
}
return p
}) || []
)
} }
}, },
methods: { methods: {
clickShowPageMenu() {
this.showInfoMenu = false
this.showPageMenu = !this.showPageMenu
},
clickShowInfoMenu() {
this.showPageMenu = false
this.showInfoMenu = !this.showInfoMenu
},
updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
if (this.savedPage === this.page) {
return
}
const payload = {
ebookLocation: this.page,
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('ComicReader.updateProgress failed:', error)
})
},
clickOutside() { clickOutside() {
if (this.showPageMenu) this.showPageMenu = false if (this.showPageMenu) this.showPageMenu = false
if (this.showInfoMenu) this.showInfoMenu = false if (this.showInfoMenu) this.showInfoMenu = false
@@ -119,12 +178,15 @@ export default {
if (!this.canGoPrev) return if (!this.canGoPrev) return
this.setPage(this.page - 1) this.setPage(this.page - 1)
}, },
setPage(index) { setPage(page) {
if (index < 0 || index > this.numPages - 1) { if (page <= 0 || page > this.numPages) {
return return
} }
var filename = this.pages[index] this.showPageMenu = false
this.page = index this.showInfoMenu = false
const filename = this.pages[page - 1]
this.page = page
this.updateProgress()
return this.extractFile(filename) return this.extractFile(filename)
}, },
setLoadTimeout() { setLoadTimeout() {
@@ -174,9 +236,28 @@ export default {
this.numPages = this.pages.length this.numPages = this.pages.length
// Calculate page menu size
const largestFilename = this.cleanedPageNames
.map((p) => p)
.sort((a, b) => a.length - b.length)
.pop()
const pEl = document.createElement('p')
pEl.innerText = largestFilename
pEl.style.fontSize = '0.875rem'
pEl.style.opacity = 0
pEl.style.position = 'absolute'
document.body.appendChild(pEl)
const textWidth = pEl.getBoundingClientRect()?.width
if (textWidth) {
this.pageMenuWidth = textWidth + (16 + 5 + 2 + 5)
}
pEl.remove()
if (this.pages.length) { if (this.pages.length) {
this.loading = false this.loading = false
await this.setPage(0)
const startPage = this.savedPage > 0 && this.savedPage <= this.numPages ? this.savedPage : 1
await this.setPage(startPage)
this.loadedFirstPage = true this.loadedFirstPage = true
} else { } else {
this.$toast.error('Unable to extract pages') this.$toast.error('Unable to extract pages')
+19 -6
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 {
@@ -67,6 +69,13 @@ export default {
if (!this.libraryItemId) return if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
savedEbookLocation() {
if (!this.keepProgress) return null
if (!this.userMediaProgress?.ebookLocation) return null
// Validate ebookLocation is an epubcfi
if (!String(this.userMediaProgress.ebookLocation).startsWith('epubcfi')) return null
return this.userMediaProgress.ebookLocation
},
localStorageLocationsKey() { localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}` return `ebookLocations-${this.libraryItemId}`
}, },
@@ -78,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`
} }
}, },
@@ -106,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)
}) })
@@ -197,7 +210,7 @@ export default {
}, },
/** @param {string} location - CFI of the new location */ /** @param {string} location - CFI of the new location */
relocated(location) { relocated(location) {
if (this.userMediaProgress?.ebookLocation === location.start.cfi) { if (this.savedEbookLocation === location.start.cfi) {
return return
} }
@@ -217,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',
@@ -233,10 +246,10 @@ export default {
}) })
// load saved progress // load saved progress
reader.rendition.display(this.userMediaProgress?.ebookLocation || 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
+5 -1
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`
} }
}, },
+17 -4
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,11 +97,21 @@ export default {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
savedPage() { savedPage() {
return Number(this.userMediaProgress?.ebookLocation || 0) if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
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}`
} }
@@ -114,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
@@ -128,7 +141,7 @@ export default {
}) })
}, },
loadedEvt() { loadedEvt() {
if (this.savedPage && this.savedPage > 0 && this.savedPage <= this.numPages) { if (this.savedPage > 0 && this.savedPage <= this.numPages) {
this.page = this.savedPage this.page = this.savedPage
} }
}, },
+15 -1
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: {
@@ -17,7 +17,7 @@
{{ $secondsToTimestamp(track.duration) }} {{ $secondsToTimestamp(track.duration) }}
</td> </td>
<td v-if="contextMenuItems.length" class="text-center"> <td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" /> <ui-context-menu-dropdown :items="contextMenuItems" :menu-width="110" @action="contextMenuAction" />
</td> </td>
</tr> </tr>
</template> </template>
@@ -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
@@ -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>
@@ -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>
@@ -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
}, },
@@ -12,7 +12,7 @@
</div> </div>
</td> </td>
<td v-if="contextMenuItems.length" class="text-center"> <td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" /> <ui-context-menu-dropdown :items="contextMenuItems" :menu-width="110" @action="contextMenuAction" />
</td> </td>
</tr> </tr>
</template> </template>
@@ -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
@@ -70,7 +70,10 @@ export default {
methods: { methods: {
editItem(playlistItem) { editItem(playlistItem) {
if (playlistItem.episode) { if (playlistItem.episode) {
this.$store.commit('globals/setSelectedEpisode', playlist.episode) const episodeIds = this.items.map((pi) => pi.episodeId)
this.$store.commit('setEpisodeTableEpisodeIds', episodeIds)
this.$store.commit('setSelectedLibraryItem', playlistItem.libraryItem)
this.$store.commit('globals/setSelectedEpisode', playlistItem.episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
} else { } else {
const itemIds = this.items.map((i) => i.libraryItemId) const itemIds = this.items.map((i) => i.libraryItemId)
+37 -13
View File
@@ -1,26 +1,37 @@
<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">
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth }"> <div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
<template v-for="(item, index) in items"> <template v-for="(item, index) in items">
<template v-if="item.subitems"> <template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-default" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop> <div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p> <p>{{ item.text }}</p>
</div> </div>
<div v-if="mouseoverItemIndex === index" :key="`subitems-${index}`" @mouseover="mouseoverSubItemMenu(index)" @mouseleave="mouseleaveSubItemMenu(index)" class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50 -ml-px" :style="{ left: menuWidth, top: index * 29 + 'px' }"> <div
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" 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(subitem.action, subitem.data)"> v-if="mouseoverItemIndex === index"
:key="`subitems-${index}`"
@mouseover="mouseoverSubItemMenu(index)"
@mouseleave="mouseleaveSubItemMenu(index)"
class="absolute bg-bg border rounded-b-md border-black-200 shadow-lg z-50 -ml-px py-1"
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
>
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)">
<p>{{ subitem.text }}</p> <p>{{ subitem.text }}</p>
</div> </div>
</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/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>
@@ -41,9 +52,10 @@ export default {
default: '' default: ''
}, },
menuWidth: { menuWidth: {
type: String, type: Number,
default: '192px' default: 192
} },
processing: Boolean
}, },
data() { data() {
return { return {
@@ -52,12 +64,18 @@ export default {
events: ['mousedown'], events: ['mousedown'],
isActive: true isActive: true
}, },
submenuWidth: 144,
showMenu: false, showMenu: false,
mouseoverItemIndex: null, mouseoverItemIndex: null,
isOverSubItemMenu: false isOverSubItemMenu: false,
openSubMenuLeft: false
}
},
computed: {
submenuLeftPos() {
return this.openSubMenuLeft ? -(this.submenuWidth - 1) : this.menuWidth - 0.5
} }
}, },
computed: {},
methods: { methods: {
mouseoverSubItemMenu(index) { mouseoverSubItemMenu(index) {
this.isOverSubItemMenu = true this.isOverSubItemMenu = true
@@ -80,6 +98,12 @@ export default {
clickShowMenu() { clickShowMenu() {
if (this.disabled) return if (this.disabled) return
this.showMenu = !this.showMenu this.showMenu = !this.showMenu
this.$nextTick(() => {
const boundingRect = this.$refs.menuWrapper?.getBoundingClientRect()
if (boundingRect) {
this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5
}
})
}, },
clickedOutside() { clickedOutside() {
this.showMenu = false this.showMenu = false
+17 -3
View File
@@ -1,6 +1,14 @@
<template> <template>
<div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj"> <div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name" @click.stop.prevent="clickShowMenu"> <button
type="button"
:disabled="disabled"
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
aria-haspopup="listbox"
:aria-expanded="showMenu"
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
@click.stop.prevent="clickShowMenu"
>
<div class="flex items-center justify-center sm:justify-start"> <div class="flex items-center justify-center sm:justify-start">
<ui-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" /> <ui-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span> <span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
@@ -8,7 +16,7 @@
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered"> <template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)"> <li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
<div class="flex items-center px-2"> <div class="flex items-center px-2">
@@ -93,4 +101,10 @@ export default {
}, },
mounted() {} mounted() {}
} }
</script> </script>
<style scoped>
.librariesDropdownMenu {
max-height: calc(100vh - 75px);
}
</style>
+3 -1
View File
@@ -213,7 +213,9 @@ export default {
// Reload HTML content // Reload HTML content
this.$refs.trix.editor.loadHTML(newContent) this.$refs.trix.editor.loadHTML(newContent)
// Move cursor to end of new content updated // Move cursor to end of new content updated
this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition()) if (this.autofocus) {
this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
}
}, },
getContentEndPosition() { getContentEndPosition() {
return this.$refs.trix.editor.getDocument().toString().length - 1 return this.$refs.trix.editor.getDocument().toString().length - 1
+22 -8
View File
@@ -1,17 +1,17 @@
<template> <template>
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0"> <div ref="wrapper" class="absolute bg-bg rounded-md py-1 border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" :style="{ width: menuWidth + 'px' }" style="top: 0; left: 0">
<template v-for="(item, index) in items"> <template v-for="(item, index) in items">
<template v-if="item.subitems"> <template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-default" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop> <div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p> <p>{{ item.text }}</p>
</div> </div>
<div v-if="mouseoverItemIndex === index" :key="`subitems-${index}`" @mouseover="mouseoverSubItemMenu(index)" @mouseleave="mouseleaveSubItemMenu(index)" class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" :style="{ left: 143 + 'px', top: index * 28 + 'px' }"> <div v-if="mouseoverItemIndex === index" :key="`subitems-${index}`" @mouseover="mouseoverSubItemMenu(index)" @mouseleave="mouseleaveSubItemMenu(index)" class="absolute bg-bg rounded-b-md border border-black-200 py-1 shadow-lg z-50" :class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'" :style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }">
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" 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(subitem.func, subitem.data)"> <div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.func, subitem.data)">
<p>{{ subitem.text }}</p> <p>{{ subitem.text }}</p>
</div> </div>
</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" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop="clickAction(item.func)"> <div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop="clickAction(item.func)">
<p>{{ item.text }}</p> <p>{{ item.text }}</p>
</div> </div>
</template> </template>
@@ -33,11 +33,18 @@ export default {
events: ['mousedown'], events: ['mousedown'],
isActive: true isActive: true
}, },
submenuWidth: 144,
menuWidth: 144,
mouseoverItemIndex: null, mouseoverItemIndex: null,
isOverSubItemMenu: false isOverSubItemMenu: false,
openSubMenuLeft: false
}
},
computed: {
submenuLeftPos() {
return this.openSubMenuLeft ? -this.submenuWidth : this.menuWidth - 1.5
} }
}, },
computed: {},
methods: { methods: {
mouseoverSubItemMenu(index) { mouseoverSubItemMenu(index) {
this.isOverSubItemMenu = true this.isOverSubItemMenu = true
@@ -77,7 +84,14 @@ export default {
this.$el.parentNode.removeChild(this.$el) this.$el.parentNode.removeChild(this.$el)
} }
}, },
mounted() {}, mounted() {
this.$nextTick(() => {
const boundingRect = this.$refs.wrapper?.getBoundingClientRect()
if (boundingRect) {
this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5
}
})
},
beforeDestroy() {} beforeDestroy() {}
} }
</script> </script>
+3 -9
View File
@@ -491,9 +491,9 @@ export default {
} }
}, },
checkActiveElementIsInput() { checkActiveElementIsInput() {
var activeElement = document.activeElement const activeElement = document.activeElement
var inputs = ['input', 'select', 'button', 'textarea'] const inputs = ['input', 'select', 'button', 'textarea', 'trix-editor']
return activeElement && inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1 return activeElement && inputs.some((i) => i === activeElement.tagName.toLowerCase())
}, },
getHotkeyName(e) { getHotkeyName(e) {
var keyCode = e.keyCode || e.which var keyCode = e.keyCode || e.which
@@ -560,12 +560,6 @@ export default {
.catch((err) => console.error(err)) .catch((err) => console.error(err))
}, },
initLocalStorage() { initLocalStorage() {
// If experimental features set in local storage
var experimentalFeaturesSaved = localStorage.getItem('experimental')
if (experimentalFeaturesSaved === '1') {
this.$store.commit('setExperimentalFeatures', true)
}
// Queue auto play // Queue auto play
var playerQueueAutoPlay = localStorage.getItem('playerQueueAutoPlay') var playerQueueAutoPlay = localStorage.getItem('playerQueueAutoPlay')
this.$store.commit('setPlayerQueueAutoPlay', playerQueueAutoPlay !== '0') this.$store.commit('setPlayerQueueAutoPlay', playerQueueAutoPlay !== '0')
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.22", "version": "2.2.23",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.22", "version": "2.2.23",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.22", "version": "2.2.23",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+2 -29
View File
@@ -166,7 +166,8 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="pt-4"> <!-- old experimental features -->
<!-- <div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsExperimental }}</h2> <h2 class="font-semibold">{{ $strings.HeaderSettingsExperimental }}</h2>
</div> </div>
@@ -180,26 +181,6 @@
</a> </a>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-enable-e-reader" v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
<ui-tooltip :text="$strings.LabelSettingsEnableEReaderHelp">
<p class="pl-4">
<span id="settings-enable-e-reader">{{ $strings.LabelSettingsEnableEReader }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<!-- <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerUseTone" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseTone', val)" />
<ui-tooltip text="Tone library for metadata">
<p class="pl-4">
Use Tone library for metadata
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div> --> </div> -->
</div> </div>
</div> </div>
@@ -303,14 +284,6 @@ export default {
providers() { providers() {
return this.$store.state.scanners.providers return this.$store.state.scanners.providers
}, },
showExperimentalFeatures: {
get() {
return this.$store.state.showExperimentalFeatures
},
set(val) {
this.$store.commit('setExperimentalFeatures', val)
}
},
dateFormats() { dateFormats() {
return this.$store.state.globals.dateFormats return this.$store.state.globals.dateFormats
}, },
+9 -20
View File
@@ -52,13 +52,6 @@
<div class="hidden md:block flex-grow" /> <div class="hidden md:block flex-grow" />
</div> </div>
<!-- Alerts -->
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
<span class="material-icons text-2xl">warning_amber</span>
<p v-if="userIsAdminOrUp" class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader can be enabled in config.</p>
<p v-else class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader must be enabled by a server admin.</p>
</div>
<!-- Podcast episode downloads queue --> <!-- Podcast episode downloads queue -->
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0"> <div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div class="flex items-center"> <div class="flex items-center">
@@ -122,7 +115,7 @@
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" /> <ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip> </ui-tooltip>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" menu-width="148px" @action="contextMenuAction"> <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
<template #default="{ showMenu, clickShowMenu, disabled }"> <template #default="{ showMenu, clickShowMenu, disabled }">
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons">more_horiz</span> <span class="material-icons">more_horiz</span>
@@ -147,7 +140,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>
@@ -200,12 +195,6 @@ export default {
dateFormat() { dateFormat() {
return this.$store.state.serverSettings.dateFormat return this.$store.state.serverSettings.dateFormat
}, },
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
enableEReader() {
return this.$store.getters['getServerSetting']('enableEReader')
},
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
@@ -257,7 +246,7 @@ export default {
return this.tracks.length return this.tracks.length
}, },
showReadButton() { showReadButton() {
return this.ebookFile && (this.showExperimentalFeatures || this.enableEReader) return this.ebookFile
}, },
libraryId() { libraryId() {
return this.libraryItem.libraryId return this.libraryItem.libraryId
@@ -320,6 +309,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
}, },
@@ -330,9 +322,6 @@ export default {
// Music track // Music track
return this.media.audioFile return this.media.audioFile
}, },
showExperimentalReadAlert() {
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
},
description() { description() {
return this.mediaMetadata.description || '' return this.mediaMetadata.description || ''
}, },
@@ -519,7 +508,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) {
+1 -1
View File
@@ -1,6 +1,6 @@
const SupportedFileTypes = { const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'], image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb'], audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'], info: ['nfo'],
text: ['txt'], text: ['txt'],
+5 -6
View File
@@ -17,11 +17,12 @@ 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,
previousPath: '/', previousPath: '/',
showExperimentalFeatures: false,
bookshelfBookIds: [], bookshelfBookIds: [],
episodeTableEpisodeIds: [], episodeTableEpisodeIds: [],
openModal: null, openModal: null,
@@ -210,8 +211,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
}, },
@@ -227,10 +230,6 @@ export const mutations = {
setProcessingBatch(state, val) { setProcessingBatch(state, val) {
state.processingBatch = val state.processingBatch = val
}, },
setExperimentalFeatures(state, val) {
state.showExperimentalFeatures = val
localStorage.setItem('experimental', val ? 1 : 0)
},
setOpenModal(state, val) { setOpenModal(state, val) {
state.openModal = val state.openModal = val
}, },
+3
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)
}, },
+1 -1
View File
@@ -80,7 +80,7 @@ export const actions = {
if (state.settings.orderBy == 'media.metadata.publishedYear') { if (state.settings.orderBy == 'media.metadata.publishedYear') {
settingsUpdate.orderBy = 'media.metadata.title' settingsUpdate.orderBy = 'media.metadata.title'
} }
const invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues'] const invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues', 'ebooks', 'abridged']
const filterByFirstPart = (state.settings.filterBy || '').split('.').shift() const filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
if (invalidFilters.includes(filterByFirstPart)) { if (invalidFilters.includes(filterByFirstPart)) {
settingsUpdate.filterBy = 'all' settingsUpdate.filterBy = 'all'
+12 -2
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",
@@ -223,6 +224,7 @@
"LabelDuration": "Laufzeit", "LabelDuration": "Laufzeit",
"LabelDurationFound": "Gefundene Laufzeit:", "LabelDurationFound": "Gefundene Laufzeit:",
"LabelEbook": "Ebook", "LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Bearbeiten", "LabelEdit": "Bearbeiten",
"LabelEmail": "Email", "LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address", "LabelEmailSettingsFromAddress": "From Address",
@@ -250,6 +252,8 @@
"LabelGenre": "Kategorie", "LabelGenre": "Kategorie",
"LabelGenres": "Kategorien", "LabelGenres": "Kategorien",
"LabelHardDeleteFile": "Datei dauerhaft löschen", "LabelHardDeleteFile": "Datei dauerhaft löschen",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Stunde", "LabelHour": "Stunde",
"LabelIcon": "Symbol", "LabelIcon": "Symbol",
@@ -339,12 +343,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,14 +373,16 @@
"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",
"LabelSettingsDisableWatcher": "Überwachung deaktivieren", "LabelSettingsDisableWatcher": "Überwachung deaktivieren",
"LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren", "LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren",
"LabelSettingsDisableWatcherHelp": "Deaktiviert das automatische Hinzufügen/Aktualisieren von Elementen, wenn Dateiänderungen erkannt werden. *Erfordert einen Server-Neustart", "LabelSettingsDisableWatcherHelp": "Deaktiviert das automatische Hinzufügen/Aktualisieren von Elementen, wenn Dateiänderungen erkannt werden. *Erfordert einen Server-Neustart",
"LabelSettingsEnableEReader": "E-Reader für alle Benutzer aktivieren",
"LabelSettingsEnableEReaderHelp": "Der E-Reader befindet sich noch in der Entwicklung, aber mit dieser Einstellung können Sie ihn für alle Benutzer aktivieren (oder aktivieren Sie die Option \"Experimentelle Funktionen\", dann Sie ihn nur selbst verwenden)",
"LabelSettingsExperimentalFeatures": "Experimentelle Funktionen", "LabelSettingsExperimentalFeatures": "Experimentelle Funktionen",
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.", "LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.",
"LabelSettingsFindCovers": "Suche Titelbilder", "LabelSettingsFindCovers": "Suche Titelbilder",
@@ -489,6 +498,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?",
+12 -2
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",
@@ -223,6 +224,7 @@
"LabelDuration": "Duration", "LabelDuration": "Duration",
"LabelDurationFound": "Duration found:", "LabelDurationFound": "Duration found:",
"LabelEbook": "Ebook", "LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Edit", "LabelEdit": "Edit",
"LabelEmail": "Email", "LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address", "LabelEmailSettingsFromAddress": "From Address",
@@ -250,6 +252,8 @@
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file", "LabelHardDeleteFile": "Hard delete file",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hour", "LabelHour": "Hour",
"LabelIcon": "Icon", "LabelIcon": "Icon",
@@ -339,12 +343,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,14 +373,16 @@
"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",
"LabelSettingsDisableWatcher": "Disable Watcher", "LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library", "LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart", "LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableEReader": "Enable e-reader for all users",
"LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
"LabelSettingsExperimentalFeatures": "Experimental features", "LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.", "LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers", "LabelSettingsFindCovers": "Find covers",
@@ -489,6 +498,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?",
+12 -2
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",
@@ -223,6 +224,7 @@
"LabelDuration": "Duración", "LabelDuration": "Duración",
"LabelDurationFound": "Duración Comprobada:", "LabelDurationFound": "Duración Comprobada:",
"LabelEbook": "Ebook", "LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Editar", "LabelEdit": "Editar",
"LabelEmail": "Email", "LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address", "LabelEmailSettingsFromAddress": "From Address",
@@ -250,6 +252,8 @@
"LabelGenre": "Genero", "LabelGenre": "Genero",
"LabelGenres": "Géneros", "LabelGenres": "Géneros",
"LabelHardDeleteFile": "Eliminar Definitivamente", "LabelHardDeleteFile": "Eliminar Definitivamente",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hora", "LabelHour": "Hora",
"LabelIcon": "Icono", "LabelIcon": "Icono",
@@ -339,12 +343,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,14 +373,16 @@
"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",
"LabelSettingsDisableWatcher": "Deshabilitar Watcher", "LabelSettingsDisableWatcher": "Deshabilitar Watcher",
"LabelSettingsDisableWatcherForLibrary": "Deshabilitar Watcher de Carpetas para esta biblioteca", "LabelSettingsDisableWatcherForLibrary": "Deshabilitar Watcher de Carpetas para esta biblioteca",
"LabelSettingsDisableWatcherHelp": "Deshabilitar la función automática de agregar/actualizar los elementos, cuando se detecta cambio en los archivos. *Require Reiniciar el Servidor", "LabelSettingsDisableWatcherHelp": "Deshabilitar la función automática de agregar/actualizar los elementos, cuando se detecta cambio en los archivos. *Require Reiniciar el Servidor",
"LabelSettingsEnableEReader": "Habilitar e-reader para todos los usuarios",
"LabelSettingsEnableEReaderHelp": "E-reader sigue en proceso, pero use esta configuración para hacerlo disponible a todos los usuarios. (o use las \"Funciones Experimentales\" para habilitarla para solo este usuario)",
"LabelSettingsExperimentalFeatures": "Funciones Experimentales", "LabelSettingsExperimentalFeatures": "Funciones Experimentales",
"LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo sobre las que esperamos sus comentarios y experiencia. Haga click aquí para abrir una conversación en Github.", "LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo sobre las que esperamos sus comentarios y experiencia. Haga click aquí para abrir una conversación en Github.",
"LabelSettingsFindCovers": "Buscar Portadas", "LabelSettingsFindCovers": "Buscar Portadas",
@@ -489,6 +498,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?",
+45 -35
View File
@@ -55,7 +55,7 @@
"ButtonRemoveAll": "Supprimer tout", "ButtonRemoveAll": "Supprimer tout",
"ButtonRemoveAllLibraryItems": "Supprimer tous les articles de la bibliothèque", "ButtonRemoveAllLibraryItems": "Supprimer tous les articles de la bibliothèque",
"ButtonRemoveFromContinueListening": "Ne plus continuer à écouter", "ButtonRemoveFromContinueListening": "Ne plus continuer à écouter",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading", "ButtonRemoveFromContinueReading": "Ne plus continuer à lire",
"ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série", "ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série",
"ButtonReScan": "Nouvelle analyse", "ButtonReScan": "Nouvelle analyse",
"ButtonReset": "Réinitialiser", "ButtonReset": "Réinitialiser",
@@ -87,7 +87,7 @@
"HeaderAdvanced": "Avancé", "HeaderAdvanced": "Avancé",
"HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise", "HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise",
"HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook", "HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook",
"HeaderAudioTracks": "Pistes zudio", "HeaderAudioTracks": "Pistes audio",
"HeaderBackups": "Sauvegardes", "HeaderBackups": "Sauvegardes",
"HeaderChangePassword": "Modifier le mot de passe", "HeaderChangePassword": "Modifier le mot de passe",
"HeaderChapters": "Chapitres", "HeaderChapters": "Chapitres",
@@ -95,13 +95,14 @@
"HeaderCollection": "Collection", "HeaderCollection": "Collection",
"HeaderCollectionItems": "Entrées de la Collection", "HeaderCollectionItems": "Entrées de la Collection",
"HeaderCover": "Couverture", "HeaderCover": "Couverture",
"HeaderCurrentDownloads": "File dattente de téléchargement", "HeaderCurrentDownloads": "Téléchargements en cours",
"HeaderDetails": "Détails", "HeaderDetails": "Détails",
"HeaderDownloadQueue": "Queue de téléchargement", "HeaderDownloadQueue": "File d'attente de téléchargements",
"HeaderEmail": "Email", "HeaderEbookFiles": "Ebook Files",
"HeaderEmailSettings": "Email Settings", "HeaderEmail": "E-mails",
"HeaderEmailSettings": "Configuration des e-mails",
"HeaderEpisodes": "Épisodes", "HeaderEpisodes": "Épisodes",
"HeaderEReaderDevices": "E-Reader Devices", "HeaderEReaderDevices": "Lecteurs d'e-books",
"HeaderFiles": "Fichiers", "HeaderFiles": "Fichiers",
"HeaderFindChapters": "Trouver les chapitres", "HeaderFindChapters": "Trouver les chapitres",
"HeaderIgnoredFiles": "Fichiers Ignorés", "HeaderIgnoredFiles": "Fichiers Ignorés",
@@ -222,12 +223,13 @@
"LabelDownload": "Téléchargement", "LabelDownload": "Téléchargement",
"LabelDuration": "Durée", "LabelDuration": "Durée",
"LabelDurationFound": "Durée trouvée :", "LabelDurationFound": "Durée trouvée :",
"LabelEbook": "Ebook", "LabelEbook": "E-book",
"LabelEbooks": "Ebooks",
"LabelEdit": "Modifier", "LabelEdit": "Modifier",
"LabelEmail": "Email", "LabelEmail": "E-mail",
"LabelEmailSettingsFromAddress": "From Address", "LabelEmailSettingsFromAddress": "Expéditeur",
"LabelEmailSettingsSecure": "Secure", "LabelEmailSettingsSecure": "Sécurisé",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)", "LabelEmailSettingsSecureHelp": "Si coché, la connexion utilisera TLS lors de la connexion au serveur. Sinon TLS est utilisé si le serveur prend en charge l'extension STARTTLS. Dans la plupart des cas, cochez si vous vous connectez au port 465. Décochez pour le port 587 ou 25. (source: nodemailer.com/smtp/#authentication)",
"LabelEmbeddedCover": "Couverture du livre intégrée", "LabelEmbeddedCover": "Couverture du livre intégrée",
"LabelEnable": "Activer", "LabelEnable": "Activer",
"LabelEnd": "Fin", "LabelEnd": "Fin",
@@ -236,9 +238,9 @@
"LabelEpisodeType": "Type de l’épisode", "LabelEpisodeType": "Type de l’épisode",
"LabelExample": "Exemple", "LabelExample": "Exemple",
"LabelExplicit": "Restriction", "LabelExplicit": "Restriction",
"LabelFeedURL": "URL deu flux", "LabelFeedURL": "URL du flux",
"LabelFile": "Fichier", "LabelFile": "Fichier",
"LabelFileBirthtime": "Creation du fichier", "LabelFileBirthtime": "Création du fichier",
"LabelFileModified": "Modification du fichier", "LabelFileModified": "Modification du fichier",
"LabelFilename": "Nom de fichier", "LabelFilename": "Nom de fichier",
"LabelFilterByUser": "Filtrer par lutilisateur", "LabelFilterByUser": "Filtrer par lutilisateur",
@@ -250,13 +252,15 @@
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Suppression du fichier", "LabelHardDeleteFile": "Suppression du fichier",
"LabelHost": "Host", "LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Hôte",
"LabelHour": "Heure", "LabelHour": "Heure",
"LabelIcon": "Icone", "LabelIcon": "Icone",
"LabelIncludeInTracklist": "Inclure dans la liste des pistes", "LabelIncludeInTracklist": "Inclure dans la liste des pistes",
"LabelIncomplete": "Incomplet", "LabelIncomplete": "Incomplet",
"LabelInProgress": "En cours", "LabelInProgress": "En cours",
"LabelInterval": "Interval", "LabelInterval": "Intervalle",
"LabelIntervalCustomDailyWeekly": "Journalier / Hebdomadaire personnalisé", "LabelIntervalCustomDailyWeekly": "Journalier / Hebdomadaire personnalisé",
"LabelIntervalEvery12Hours": "Toutes les 12 heures", "LabelIntervalEvery12Hours": "Toutes les 12 heures",
"LabelIntervalEvery15Minutes": "Toutes les 15 minutes", "LabelIntervalEvery15Minutes": "Toutes les 15 minutes",
@@ -266,7 +270,7 @@
"LabelIntervalEveryDay": "Tous les jours", "LabelIntervalEveryDay": "Tous les jours",
"LabelIntervalEveryHour": "Toutes les heures", "LabelIntervalEveryHour": "Toutes les heures",
"LabelInvalidParts": "Parties invalides", "LabelInvalidParts": "Parties invalides",
"LabelInvert": "Invert", "LabelInvert": "Inverser",
"LabelItem": "Article", "LabelItem": "Article",
"LabelLanguage": "Langue", "LabelLanguage": "Langue",
"LabelLanguageDefaultServer": "Langue par défaut", "LabelLanguageDefaultServer": "Langue par défaut",
@@ -307,14 +311,14 @@
"LabelNextScheduledRun": "Prochain lancement prévu", "LabelNextScheduledRun": "Prochain lancement prévu",
"LabelNotes": "Notes", "LabelNotes": "Notes",
"LabelNotFinished": "Non terminé(e)", "LabelNotFinished": "Non terminé(e)",
"LabelNotificationAppriseURL": "URL(s) dapprise", "LabelNotificationAppriseURL": "URL(s) dApprise",
"LabelNotificationAvailableVariables": "Variables disponibles", "LabelNotificationAvailableVariables": "Variables disponibles",
"LabelNotificationBodyTemplate": "Modèle de Message", "LabelNotificationBodyTemplate": "Modèle de Message",
"LabelNotificationEvent": "Evènement de Notification", "LabelNotificationEvent": "Evènement de Notification",
"LabelNotificationsMaxFailedAttempts": "Nombres de tentatives denvoi", "LabelNotificationsMaxFailedAttempts": "Nombres de tentatives denvoi",
"LabelNotificationsMaxFailedAttemptsHelp": "La notification est abandonnée une fois ce seuil atteint", "LabelNotificationsMaxFailedAttemptsHelp": "La notification est abandonnée une fois ce seuil atteint",
"LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente", "LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente",
"LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Le notification seront ignorées si la file dattente est à son maximum. Cela empêche un flot trop important.", "LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Les notifications seront ignorées si la file dattente est à son maximum. Cela empêche un flot trop important.",
"LabelNotificationTitleTemplate": "Modèle de Titre", "LabelNotificationTitleTemplate": "Modèle de Titre",
"LabelNotStarted": "Non Démarré(e)", "LabelNotStarted": "Non Démarré(e)",
"LabelNumberOfBooks": "Nombre de Livres", "LabelNumberOfBooks": "Nombre de Livres",
@@ -338,20 +342,23 @@
"LabelPodcastType": "Type de Podcast", "LabelPodcastType": "Type de Podcast",
"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 donénes 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é",
"LabelRegion": "Région", "LabelRegion": "Région",
"LabelReleaseDate": "Date de parution", "LabelReleaseDate": "Date de parution",
"LabelRemoveCover": "Supprimer la couverture", "LabelRemoveCover": "Supprimer la couverture",
"LabelRSSFeedCustomOwnerEmail": "Email propriétaire personnalisé", "LabelRSSFeedCustomOwnerEmail": "E-mail propriétaire personnalisé",
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé", "LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
"LabelRSSFeedOpen": "Flux RSS ouvert", "LabelRSSFeedOpen": "Flux RSS ouvert",
"LabelRSSFeedPreventIndexing": "Empêcher lindexation", "LabelRSSFeedPreventIndexing": "Empêcher lindexation",
@@ -361,23 +368,25 @@
"LabelSearchTitle": "Titre de recherche", "LabelSearchTitle": "Titre de recherche",
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN", "LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
"LabelSeason": "Saison", "LabelSeason": "Saison",
"LabelSendEbookToDevice": "Send Ebook to...", "LabelSendEbookToDevice": "Envoyer l'e-book à...",
"LabelSequence": "Séquence", "LabelSequence": "Séquence",
"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",
"LabelSettingsBookshelfViewHelp": "Interface Skeuomorphic avec une étagère en bois", "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",
"LabelSettingsChromecastSupport": "Support du Chromecast", "LabelSettingsChromecastSupport": "Support du Chromecast",
"LabelSettingsDateFormat": "Format de date", "LabelSettingsDateFormat": "Format de date",
"LabelSettingsDisableWatcher": "Désactiver la surveillance", "LabelSettingsDisableWatcher": "Désactiver la surveillance",
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque", "LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque",
"LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque les fichiers changent. *Nécessite un redémarrage*", "LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque les fichiers changent. *Nécessite un redémarrage*",
"LabelSettingsEnableEReader": "Active E-reader pour tous les utilisateurs",
"LabelSettingsEnableEReaderHelp": "E-reader est toujours en cours de développement, mais ce paramètre lactive pour tous les utilisateurs (ou utiliser linterrupteur « Fonctionnalités expérimentales » pour lactiver seulement pour vous)",
"LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales", "LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales",
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquels nous attendons votre retour et expérience. Cliquer pour ouvrir la discussion Github.", "LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion Github.",
"LabelSettingsFindCovers": "Chercher des couvertures de livre", "LabelSettingsFindCovers": "Chercher des couvertures de livre",
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, lanalyser tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps danalyse.", "LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, lanalyseur tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps danalyse.",
"LabelSettingsHomePageBookshelfView": "La page daccueil utilise la vue étagère", "LabelSettingsHomePageBookshelfView": "La page daccueil utilise la vue étagère",
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère", "LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
"LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres", "LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres",
@@ -481,7 +490,7 @@
"MessageBookshelfNoCollections": "Vous navez pas encore de collections", "MessageBookshelfNoCollections": "Vous navez pas encore de collections",
"MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0}: {1} »", "MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0}: {1} »",
"MessageBookshelfNoRSSFeeds": "Aucun flux RSS nest ouvert", "MessageBookshelfNoRSSFeeds": "Aucun flux RSS nest ouvert",
"MessageBookshelfNoSeries": "Vous navez aucune séries", "MessageBookshelfNoSeries": "Vous navez aucune série",
"MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio", "MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio",
"MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0", "MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0",
"MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre", "MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre",
@@ -489,6 +498,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 ?",
@@ -498,15 +508,15 @@
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?", "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?", "MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?",
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?", "MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", "MessageConfirmRemoveNarrator": "Êtes-vous sûr de vouloir supprimer le narrateur \"{0}\"?",
"MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?", "MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?",
"MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » vers « {1} » pour tous les articles ?", "MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » en « {1} » pour tous les articles ?",
"MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.", "MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.",
"MessageConfirmRenameGenreWarning": "Attention ! Un genre similaire avec une casse différente existe déjà « {0} ».", "MessageConfirmRenameGenreWarning": "Attention ! Un genre similaire avec une casse différente existe déjà « {0} ».",
"MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » vers « {1} » pour tous les articles ?", "MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les articles ?",
"MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.", "MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.",
"MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».", "MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer l'ebook {0} \"{1}\" à l'appareil \"{2}\"?",
"MessageDownloadingEpisode": "Téléchargement de l’épisode", "MessageDownloadingEpisode": "Téléchargement de l’épisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans lordre correct", "MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans lordre correct",
"MessageEmbedFinished": "Intégration Terminée !", "MessageEmbedFinished": "Intégration Terminée !",
@@ -594,7 +604,7 @@
"NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux en HTTPS.", "NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux en HTTPS.",
"NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.", "NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
"NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.", "NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.",
"NoteUploaderOnlyAudioFiles": "Si vous téléverser uniquement des fichiers audio, chaque fichier audio sera traité comme un livre audio distinct.", "NoteUploaderOnlyAudioFiles": "Si vous téléversez uniquement des fichiers audio, chaque fichier audio sera traité comme un livre audio distinct.",
"NoteUploaderUnsupportedFiles": "Les fichiers non pris en charge sont ignorés. Lorsque vous choisissez ou déposez un dossier, les autres fichiers qui ne sont pas dans un dossier d’élément sont ignorés.", "NoteUploaderUnsupportedFiles": "Les fichiers non pris en charge sont ignorés. Lorsque vous choisissez ou déposez un dossier, les autres fichiers qui ne sont pas dans un dossier d’élément sont ignorés.",
"PlaceholderNewCollection": "Nom de la nouvelle collection", "PlaceholderNewCollection": "Nom de la nouvelle collection",
"PlaceholderNewFolderPath": "Nouveau chemin de dossier", "PlaceholderNewFolderPath": "Nouveau chemin de dossier",
@@ -661,8 +671,8 @@
"ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection", "ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection",
"ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS", "ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS",
"ToastRSSFeedCloseSuccess": "Flux RSS fermé", "ToastRSSFeedCloseSuccess": "Flux RSS fermé",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device", "ToastSendEbookToDeviceFailed": "Échec de l'envoi de l'e-book à l'appareil",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"", "ToastSendEbookToDeviceSuccess": "E-book envoyé à l'appareil \"{0}\"",
"ToastSeriesUpdateFailed": "Échec de la mise à jour de la série", "ToastSeriesUpdateFailed": "Échec de la mise à jour de la série",
"ToastSeriesUpdateSuccess": "Mise à jour de la série réussie", "ToastSeriesUpdateSuccess": "Mise à jour de la série réussie",
"ToastSessionDeleteFailed": "Échec de la suppression de session", "ToastSessionDeleteFailed": "Échec de la suppression de session",
+12 -2
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",
@@ -223,6 +224,7 @@
"LabelDuration": "Duration", "LabelDuration": "Duration",
"LabelDurationFound": "Duration found:", "LabelDurationFound": "Duration found:",
"LabelEbook": "Ebook", "LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Edit", "LabelEdit": "Edit",
"LabelEmail": "Email", "LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address", "LabelEmailSettingsFromAddress": "From Address",
@@ -250,6 +252,8 @@
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file", "LabelHardDeleteFile": "Hard delete file",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hour", "LabelHour": "Hour",
"LabelIcon": "Icon", "LabelIcon": "Icon",
@@ -339,12 +343,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,14 +373,16 @@
"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",
"LabelSettingsDisableWatcher": "Disable Watcher", "LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library", "LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart", "LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableEReader": "Enable e-reader for all users",
"LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
"LabelSettingsExperimentalFeatures": "Experimental features", "LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.", "LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers", "LabelSettingsFindCovers": "Find covers",
@@ -489,6 +498,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?",
+12 -2
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",
@@ -223,6 +224,7 @@
"LabelDuration": "Duration", "LabelDuration": "Duration",
"LabelDurationFound": "Duration found:", "LabelDurationFound": "Duration found:",
"LabelEbook": "Ebook", "LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Edit", "LabelEdit": "Edit",
"LabelEmail": "Email", "LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address", "LabelEmailSettingsFromAddress": "From Address",
@@ -250,6 +252,8 @@
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file", "LabelHardDeleteFile": "Hard delete file",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hour", "LabelHour": "Hour",
"LabelIcon": "Icon", "LabelIcon": "Icon",
@@ -339,12 +343,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,14 +373,16 @@
"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",
"LabelSettingsDisableWatcher": "Disable Watcher", "LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library", "LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart", "LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableEReader": "Enable e-reader for all users",
"LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
"LabelSettingsExperimentalFeatures": "Experimental features", "LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.", "LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers", "LabelSettingsFindCovers": "Find covers",
@@ -489,6 +498,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?",
+12 -2
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",
@@ -223,6 +224,7 @@
"LabelDuration": "Trajanje", "LabelDuration": "Trajanje",
"LabelDurationFound": "Pronađeno trajanje:", "LabelDurationFound": "Pronađeno trajanje:",
"LabelEbook": "Ebook", "LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Uredi", "LabelEdit": "Uredi",
"LabelEmail": "Email", "LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address", "LabelEmailSettingsFromAddress": "From Address",
@@ -250,6 +252,8 @@
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Žanrovi", "LabelGenres": "Žanrovi",
"LabelHardDeleteFile": "Obriši datoteku zauvijek", "LabelHardDeleteFile": "Obriši datoteku zauvijek",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Sat", "LabelHour": "Sat",
"LabelIcon": "Ikona", "LabelIcon": "Ikona",
@@ -339,12 +343,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,14 +373,16 @@
"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",
"LabelSettingsDisableWatcher": "Isključi Watchera", "LabelSettingsDisableWatcher": "Isključi Watchera",
"LabelSettingsDisableWatcherForLibrary": "Isključi folder watchera za biblioteku", "LabelSettingsDisableWatcherForLibrary": "Isključi folder watchera za biblioteku",
"LabelSettingsDisableWatcherHelp": "Isključi automatsko dodavanje/aktualiziranje stavci ako su promjene prepoznate. *Potreban restart servera", "LabelSettingsDisableWatcherHelp": "Isključi automatsko dodavanje/aktualiziranje stavci ako su promjene prepoznate. *Potreban restart servera",
"LabelSettingsEnableEReader": "Uključi e-readere za sve korisnike",
"LabelSettingsEnableEReaderHelp": "E-reader je i dalje rad u tijeku, ali s ovom postavkom ga možete uključiti za sve korisnike (ili koristi \"Eksperimentalni features\" toggle da bi uključio postavku samo za sebe)",
"LabelSettingsExperimentalFeatures": "Eksperimentalni features", "LabelSettingsExperimentalFeatures": "Eksperimentalni features",
"LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.", "LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.",
"LabelSettingsFindCovers": "Pronađi covers", "LabelSettingsFindCovers": "Pronađi covers",
@@ -489,6 +498,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?",
+12 -2
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",
@@ -223,6 +224,7 @@
"LabelDuration": "Durata", "LabelDuration": "Durata",
"LabelDurationFound": "Durata Trovata:", "LabelDurationFound": "Durata Trovata:",
"LabelEbook": "Ebook", "LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Modifica", "LabelEdit": "Modifica",
"LabelEmail": "Email", "LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address", "LabelEmailSettingsFromAddress": "From Address",
@@ -250,6 +252,8 @@
"LabelGenre": "Genere", "LabelGenre": "Genere",
"LabelGenres": "Generi", "LabelGenres": "Generi",
"LabelHardDeleteFile": "Elimina Definitivamente", "LabelHardDeleteFile": "Elimina Definitivamente",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Ora", "LabelHour": "Ora",
"LabelIcon": "Icona", "LabelIcon": "Icona",
@@ -339,12 +343,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,14 +373,16 @@
"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",
"LabelSettingsDisableWatcher": "Disattiva Watcher", "LabelSettingsDisableWatcher": "Disattiva Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie", "LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie",
"LabelSettingsDisableWatcherHelp": "Disattiva il controllo automatico libri nelle cartelle delle librerie. *Richiede il Riavvio del Server", "LabelSettingsDisableWatcherHelp": "Disattiva il controllo automatico libri nelle cartelle delle librerie. *Richiede il Riavvio del Server",
"LabelSettingsEnableEReader": "Abilita e-reader for tutti gli Utenti",
"LabelSettingsEnableEReaderHelp": "L'e-reader è ancora un work in progress, ma usa questa impostazione per abilitarlo a tutti i tuoi utenti (o usa lo switch \"Funzionalità sperimentali\" solo per te)",
"LabelSettingsExperimentalFeatures": "Opzioni Sperimentali", "LabelSettingsExperimentalFeatures": "Opzioni Sperimentali",
"LabelSettingsExperimentalFeaturesHelp": "Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.", "LabelSettingsExperimentalFeaturesHelp": "Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.",
"LabelSettingsFindCovers": "Trova covers", "LabelSettingsFindCovers": "Trova covers",
@@ -489,6 +498,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?",
+12 -2
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",
@@ -223,6 +224,7 @@
"LabelDuration": "Duur", "LabelDuration": "Duur",
"LabelDurationFound": "Gevonden duur:", "LabelDurationFound": "Gevonden duur:",
"LabelEbook": "Ebook", "LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Wijzig", "LabelEdit": "Wijzig",
"LabelEmail": "Email", "LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address", "LabelEmailSettingsFromAddress": "From Address",
@@ -250,6 +252,8 @@
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard-delete bestand", "LabelHardDeleteFile": "Hard-delete bestand",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Uur", "LabelHour": "Uur",
"LabelIcon": "Icoon", "LabelIcon": "Icoon",
@@ -339,12 +343,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,14 +373,16 @@
"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",
"LabelSettingsDisableWatcher": "Watcher uitschakelen", "LabelSettingsDisableWatcher": "Watcher uitschakelen",
"LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen", "LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen",
"LabelSettingsDisableWatcherHelp": "Schakelt het automatisch toevoegen/bijwerken van onderdelen wanneer bestandswijzigingen gedetecteerd zijn uit. *Vereist herstart server", "LabelSettingsDisableWatcherHelp": "Schakelt het automatisch toevoegen/bijwerken van onderdelen wanneer bestandswijzigingen gedetecteerd zijn uit. *Vereist herstart server",
"LabelSettingsEnableEReader": "E-reader inschakelen voor alle gebruikers",
"LabelSettingsEnableEReaderHelp": "E-reader is nog in ontwikkeling, maar gebruik deze instelling om het beschikbaar te maken voor al je gebruikers (of gebruik de \"Experimentele functies\"-schakelaar voor eigen gebruik)",
"LabelSettingsExperimentalFeatures": "Experimentele functies", "LabelSettingsExperimentalFeatures": "Experimentele functies",
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.", "LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
"LabelSettingsFindCovers": "Zoek covers", "LabelSettingsFindCovers": "Zoek covers",
@@ -489,6 +498,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?",
+12 -2
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",
@@ -223,6 +224,7 @@
"LabelDuration": "Czas trwania", "LabelDuration": "Czas trwania",
"LabelDurationFound": "Znaleziona długość:", "LabelDurationFound": "Znaleziona długość:",
"LabelEbook": "Ebook", "LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Edytuj", "LabelEdit": "Edytuj",
"LabelEmail": "Email", "LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address", "LabelEmailSettingsFromAddress": "From Address",
@@ -250,6 +252,8 @@
"LabelGenre": "Gatunek", "LabelGenre": "Gatunek",
"LabelGenres": "Gatunki", "LabelGenres": "Gatunki",
"LabelHardDeleteFile": "Usuń trwale plik", "LabelHardDeleteFile": "Usuń trwale plik",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Godzina", "LabelHour": "Godzina",
"LabelIcon": "Ikona", "LabelIcon": "Ikona",
@@ -339,12 +343,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,14 +373,16 @@
"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",
"LabelSettingsDisableWatcher": "Wyłącz monitorowanie", "LabelSettingsDisableWatcher": "Wyłącz monitorowanie",
"LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki", "LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki",
"LabelSettingsDisableWatcherHelp": "Wyłącz automatyczne dodawanie/aktualizowanie elementów po wykryciu zmian w plikach. *Wymaga restartu serwera", "LabelSettingsDisableWatcherHelp": "Wyłącz automatyczne dodawanie/aktualizowanie elementów po wykryciu zmian w plikach. *Wymaga restartu serwera",
"LabelSettingsEnableEReader": "Włącz e-czytnika dla wszystkich użytkowników",
"LabelSettingsEnableEReaderHelp": "E-czytnik jest wciąż w fazie rozwoju, ale użyj tego ustawienia, aby udostępnić go wszystkim użytkownikom (lub użyj przełącznika \"Funkcje eksperymentalne\" aby włączyć funkcję tylko dla Ciebie)",
"LabelSettingsExperimentalFeatures": "Funkcje eksperymentalne", "LabelSettingsExperimentalFeatures": "Funkcje eksperymentalne",
"LabelSettingsExperimentalFeaturesHelp": "Funkcje w trakcie rozwoju, które mogą zyskanć na Twojej opinii i pomocy w testowaniu. Kliknij, aby otworzyć dyskusję na githubie.", "LabelSettingsExperimentalFeaturesHelp": "Funkcje w trakcie rozwoju, które mogą zyskanć na Twojej opinii i pomocy w testowaniu. Kliknij, aby otworzyć dyskusję na githubie.",
"LabelSettingsFindCovers": "Szukanie okładek", "LabelSettingsFindCovers": "Szukanie okładek",
@@ -489,6 +498,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?",
+12 -2
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": "Эпизоды",
@@ -223,6 +224,7 @@
"LabelDuration": "Длина", "LabelDuration": "Длина",
"LabelDurationFound": "Найденная длина:", "LabelDurationFound": "Найденная длина:",
"LabelEbook": "Ebook", "LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Редактировать", "LabelEdit": "Редактировать",
"LabelEmail": "Email", "LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address", "LabelEmailSettingsFromAddress": "From Address",
@@ -250,6 +252,8 @@
"LabelGenre": "Жанр", "LabelGenre": "Жанр",
"LabelGenres": "Жанры", "LabelGenres": "Жанры",
"LabelHardDeleteFile": "Жесткое удаление файла", "LabelHardDeleteFile": "Жесткое удаление файла",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Часы", "LabelHour": "Часы",
"LabelIcon": "Иконка", "LabelIcon": "Иконка",
@@ -339,12 +343,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,14 +373,16 @@
"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": "Формат даты",
"LabelSettingsDisableWatcher": "Отключить отслеживание", "LabelSettingsDisableWatcher": "Отключить отслеживание",
"LabelSettingsDisableWatcherForLibrary": "Отключить отслеживание для библиотеки", "LabelSettingsDisableWatcherForLibrary": "Отключить отслеживание для библиотеки",
"LabelSettingsDisableWatcherHelp": "Отключает автоматическое добавление/обновление элементов, когда обнаружено изменение файлов. *Требуется перезапуск сервера", "LabelSettingsDisableWatcherHelp": "Отключает автоматическое добавление/обновление элементов, когда обнаружено изменение файлов. *Требуется перезапуск сервера",
"LabelSettingsEnableEReader": "Включить e-reader для всех пользователей",
"LabelSettingsEnableEReaderHelp": "E-reader все еще находится в стадии разработки, используйте эту настройку, чтобы открыть его для всех ваших пользователей (Только для Вас используйте переключатель \"Экспериментальные Функции\")",
"LabelSettingsExperimentalFeatures": "Экспериментальные функции", "LabelSettingsExperimentalFeatures": "Экспериментальные функции",
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.", "LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
"LabelSettingsFindCovers": "Найти обложки", "LabelSettingsFindCovers": "Найти обложки",
@@ -489,6 +498,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": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
+12 -2
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": "剧集",
@@ -223,6 +224,7 @@
"LabelDuration": "持续时间", "LabelDuration": "持续时间",
"LabelDurationFound": "找到持续时间:", "LabelDurationFound": "找到持续时间:",
"LabelEbook": "Ebook", "LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "编辑", "LabelEdit": "编辑",
"LabelEmail": "Email", "LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address", "LabelEmailSettingsFromAddress": "From Address",
@@ -250,6 +252,8 @@
"LabelGenre": "流派", "LabelGenre": "流派",
"LabelGenres": "流派", "LabelGenres": "流派",
"LabelHardDeleteFile": "完全删除文件", "LabelHardDeleteFile": "完全删除文件",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "小时", "LabelHour": "小时",
"LabelIcon": "图标", "LabelIcon": "图标",
@@ -339,12 +343,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,14 +373,16 @@
"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": "日期格式",
"LabelSettingsDisableWatcher": "禁用监视程序", "LabelSettingsDisableWatcher": "禁用监视程序",
"LabelSettingsDisableWatcherForLibrary": "禁用媒体库的文件夹监视程序", "LabelSettingsDisableWatcherForLibrary": "禁用媒体库的文件夹监视程序",
"LabelSettingsDisableWatcherHelp": "检测到文件更改时禁用自动添加和更新项目. *需要重启服务器", "LabelSettingsDisableWatcherHelp": "检测到文件更改时禁用自动添加和更新项目. *需要重启服务器",
"LabelSettingsEnableEReader": "为所有用户启用电子阅读器",
"LabelSettingsEnableEReaderHelp": "电子阅读器仍在开发中,但可以使用此设置向所有用户打开它(或使用 \"实验功能\" 切换仅供你使用)",
"LabelSettingsExperimentalFeatures": "实验功能", "LabelSettingsExperimentalFeatures": "实验功能",
"LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.", "LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.",
"LabelSettingsFindCovers": "查找封面", "LabelSettingsFindCovers": "查找封面",
@@ -489,6 +498,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": "你确定要强制重新扫描吗?",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.22", "version": "2.2.23",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.22", "version": "2.2.23",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.22", "version": "2.2.23",
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+26 -4
View File
@@ -32,7 +32,7 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
* Chapter editor and chapter lookup (using [Audnexus API](https://audnex.us/)) * Chapter editor and chapter lookup (using [Audnexus API](https://audnex.us/))
* Merge your audio files into a single m4b * Merge your audio files into a single m4b
* Embed metadata and cover image into your audio files (using [Tone](https://github.com/sandreas/tone)) * Embed metadata and cover image into your audio files (using [Tone](https://github.com/sandreas/tone))
* Basic ebook support and e-reader *(experimental)* * Basic ebook support and ereader
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose) Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose)
@@ -117,9 +117,11 @@ Add this to the site config file on your Apache server after you have changed th
For this to work you must enable at least the following mods using `a2enmod`: For this to work you must enable at least the following mods using `a2enmod`:
- `ssl` - `ssl`
- `proxy_module` - `proxy`
- `proxy_wstunnel_module` - `proxy_http`
- `rewrite_module` - `proxy_balancer`
- `proxy_wstunnel`
- `rewrite`
```bash ```bash
<IfModule mod_ssl.c> <IfModule mod_ssl.c>
@@ -144,6 +146,26 @@ For this to work you must enable at least the following mods using `a2enmod`:
</IfModule> </IfModule>
``` ```
Some SSL certificates like those signed by Let's Encrypt require ACME validation. To allow Let's Encrypt to write and confirm
the ACME challenge, edit your VirtualHost definition to prevent proxying traffic that queries `/.well-known` and instead
serve that directly:
```bash
<VirtualHost *:443>
# ...
# create the directory structure /.well-known/acme-challenges
# within DocumentRoot and give the HTTP user recursive write
# access to it.
DocumentRoot /path/to/local/directory
ProxyPreserveHost On
ProxyPass /.well-known !
ProxyPass / http://localhost:<audiobookshelf_port>/
# ...
</VirtualHost>
```
### SWAG Reverse Proxy ### SWAG Reverse Proxy
+6 -1
View File
@@ -148,7 +148,12 @@ class Server {
this.server = http.createServer(app) this.server = http.createServer(app)
router.use(this.auth.cors) router.use(this.auth.cors)
router.use(fileUpload()) router.use(fileUpload({
defCharset: 'utf8',
defParamCharset: 'utf8',
useTempFiles: true,
tempFileDir: Path.join(global.MetadataPath, 'tmp')
}))
router.use(express.urlencoded({ extended: true, limit: "5mb" })); router.use(express.urlencoded({ extended: true, limit: "5mb" }));
router.use(express.json({ limit: "5mb" })) router.use(express.json({ limit: "5mb" }))
+46 -2
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)
+3
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
+57 -12
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,41 @@ 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)
this.libraryFiles.forEach((lf) => { if (librarySettings.audiobooksOnly) {
if (lf.fileType === 'ebook') { hasUpdated = this.media.ebookFile
if (!this.media.ebookFile) { if (hasUpdated) {
this.media.setEbookFile(lf) // If library was set to audiobooks only then set primary ebook as supplementary
hasUpdated = true Logger.info(`[LibraryItem] Library is audiobooks only so setting ebook "${this.media.ebookFile.metadata.filename}" as supplementary`)
} else if (this.media.ebookFile.ino == lf.ino && this.media.ebookFile.updateFromLibraryFile(lf)) { // Update existing ebookFile
hasUpdated = true
}
} }
}) this.setPrimaryEbook(null)
} else if (this.media.ebookFile) {
const matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === this.media.ebookFile.ino)
if (matchingLibraryFile && this.media.ebookFile.updateFromLibraryFile(matchingLibraryFile)) {
hasUpdated = true
}
// Set any other ebook files as supplementary
const suppEbookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile && !lf.isSupplementary && this.media.ebookFile.ino !== lf.ino)
if (suppEbookLibraryFiles.length) {
for (const libraryFile of suppEbookLibraryFiles) {
libraryFile.isSupplementary = true
}
hasUpdated = true
}
} else {
const ebookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile && !lf.isSupplementary)
// Prefer epub ebook then fallback to first other ebook file
const ebookLibraryFile = ebookLibraryFiles.find(lf => lf.metadata.format === 'epub') || ebookLibraryFiles[0]
if (ebookLibraryFile) {
this.setPrimaryEbook(ebookLibraryFile)
hasUpdated = true
}
}
} }
// Set cover image if not set // Set cover image if not set
@@ -562,5 +592,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
+2 -1
View File
@@ -83,7 +83,8 @@ class Stream extends EventEmitter {
AudioMimeType.AIFF, AudioMimeType.AIFF,
AudioMimeType.WEBM, AudioMimeType.WEBM,
AudioMimeType.WEBMA, AudioMimeType.WEBMA,
AudioMimeType.AWB AudioMimeType.AWB,
AudioMimeType.CAF
] ]
} }
get codecsToForceAAC() { get codecsToForceAAC() {
+7
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
@@ -50,6 +53,10 @@ class LibraryFile {
return this.fileType === 'audio' || this.fileType === 'ebook' || this.fileType === 'video' return this.fileType === 'audio' || this.fileType === 'ebook' || this.fileType === 'video'
} }
get isEBookFile() {
return this.fileType === 'ebook'
}
get isOPFFile() { get isOPFFile() {
return this.metadata.ext === '.opf' return this.metadata.ext === '.opf'
} }
+14 -4
View File
@@ -370,10 +370,20 @@ class Book {
return payload return payload
} }
setEbookFile(libraryFile) { /**
var ebookFile = new EBookFile() * Set the EBookFile from a LibraryFile
ebookFile.setData(libraryFile) * If null then ebookFile will be removed from the book
this.ebookFile = ebookFile *
* @param {LibraryFile} [libraryFile]
*/
setEbookFile(libraryFile = null) {
if (!libraryFile) {
this.ebookFile = null
} else {
const ebookFile = new EBookFile()
ebookFile.setData(libraryFile)
this.ebookFile = ebookFile
}
} }
addAudioFile(audioFile) { addAudioFile(audioFile) {
+5 -2
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]
@@ -49,7 +49,6 @@ class ServerSettings {
// Misc Flags // Misc Flags
this.chromecastEnabled = false this.chromecastEnabled = false
this.enableEReader = false
this.dateFormat = 'MM/dd/yyyy' this.dateFormat = 'MM/dd/yyyy'
this.timeFormat = 'HH:mm' this.timeFormat = 'HH:mm'
this.language = 'en-us' this.language = 'en-us'
@@ -96,7 +95,6 @@ class ServerSettings {
this.sortingIgnorePrefix = !!settings.sortingIgnorePrefix this.sortingIgnorePrefix = !!settings.sortingIgnorePrefix
this.sortingPrefixes = settings.sortingPrefixes || ['the'] this.sortingPrefixes = settings.sortingPrefixes || ['the']
this.chromecastEnabled = !!settings.chromecastEnabled this.chromecastEnabled = !!settings.chromecastEnabled
this.enableEReader = !!settings.enableEReader
this.dateFormat = settings.dateFormat || 'MM/dd/yyyy' this.dateFormat = settings.dateFormat || 'MM/dd/yyyy'
this.timeFormat = settings.timeFormat || 'HH:mm' this.timeFormat = settings.timeFormat || 'HH:mm'
this.language = settings.language || 'en-us' this.language = settings.language || 'en-us'
@@ -158,7 +156,6 @@ class ServerSettings {
sortingIgnorePrefix: this.sortingIgnorePrefix, sortingIgnorePrefix: this.sortingIgnorePrefix,
sortingPrefixes: [...this.sortingPrefixes], sortingPrefixes: [...this.sortingPrefixes],
chromecastEnabled: this.chromecastEnabled, chromecastEnabled: this.chromecastEnabled,
enableEReader: this.enableEReader,
dateFormat: this.dateFormat, dateFormat: this.dateFormat,
timeFormat: this.timeFormat, timeFormat: this.timeFormat,
language: this.language, language: this.language,
+2 -1
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
+9 -13
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
+1 -16
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,
+46 -41
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) {
+2 -1
View File
@@ -48,7 +48,8 @@ module.exports.AudioMimeType = {
WEBM: 'audio/webm', WEBM: 'audio/webm',
WEBMA: 'audio/webm', WEBMA: 'audio/webm',
MKA: 'audio/x-matroska', MKA: 'audio/x-matroska',
AWB: 'audio/amr-wb' AWB: 'audio/amr-wb',
CAF: 'audio/x-caf'
} }
module.exports.VideoMimeType = { module.exports.VideoMimeType = {
+1 -1
View File
@@ -1,6 +1,6 @@
const globals = { const globals = {
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'], SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb'], SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'],
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
SupportedVideoTypes: ['mp4'], SupportedVideoTypes: ['mp4'],
TextFileTypes: ['txt', 'nfo'], TextFileTypes: ['txt', 'nfo'],
+5 -2
View File
@@ -13,7 +13,7 @@ module.exports = {
getFilteredLibraryItems(libraryItems, filterBy, user, feedsArray) { getFilteredLibraryItems(libraryItems, filterBy, user, feedsArray) {
let filtered = libraryItems let filtered = libraryItems
const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'missing', 'languages', 'tracks'] const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'missing', 'languages', 'tracks', 'ebooks']
const group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) { if (group) {
const filterVal = filterBy.replace(`${group}.`, '') const filterVal = filterBy.replace(`${group}.`, '')
@@ -62,6 +62,9 @@ module.exports = {
} else if (group === 'tracks') { } else if (group === 'tracks') {
if (filter === 'single') filtered = filtered.filter(li => li.isBook && li.media.numTracks === 1) if (filter === 'single') filtered = filtered.filter(li => li.isBook && li.media.numTracks === 1)
else if (filter === 'multi') filtered = filtered.filter(li => li.isBook && li.media.numTracks > 1) else if (filter === 'multi') filtered = filtered.filter(li => li.isBook && li.media.numTracks > 1)
} else if (group === 'ebooks') {
if (filter === 'ebook') filtered = filtered.filter(li => li.media.ebookFile)
else if (filter === 'supplementary') filtered = filtered.filter(li => li.libraryFiles.some(lf => lf.isEBookFile && lf.ino !== li.media.ebookFile?.ino))
} }
} else if (filterBy === 'issues') { } else if (filterBy === 'issues') {
filtered = filtered.filter(li => li.hasIssues) filtered = filtered.filter(li => li.hasIssues)
@@ -110,7 +113,7 @@ module.exports = {
if (filter === 'not-started' && itemProgress) return false if (filter === 'not-started' && itemProgress) return false
} }
if (!someBookIsUnfinished && filter === 'not-finished') { // Completely finished series if (!someBookIsUnfinished && (filter === 'not-finished' || filter === 'in-progress')) { // Completely finished series
return false return false
} else if (!someBookHasProgress && filter === 'in-progress') { // Series not started } else if (!someBookHasProgress && filter === 'in-progress') { // Series not started
return false return false
+21 -12
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])
} }