Merge branch 'advplyr:master' into dewyer/add-custom-metadata-provider

This commit is contained in:
FlyinPancake 2024-01-13 01:08:23 +01:00 committed by GitHub
commit 6ef4944d89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 764 additions and 309 deletions

View File

@ -11,7 +11,7 @@ body:
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)." value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
- type: markdown - type: markdown
attributes: attributes:
value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug." value: "### Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug."
- type: markdown - type: markdown
attributes: attributes:
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant." value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."

View File

@ -1,7 +1,7 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Discord - name: Discord
url: https://discord.gg/pJsjuNCKRq url: https://discord.gg/HQgCbd6E75
about: Ask questions, get help troubleshooting, and join the Abs community here. about: Ask questions, get help troubleshooting, and join the Abs community here.
- name: Matrix - name: Matrix
url: https://matrix.to/#/#audiobookshelf:matrix.org url: https://matrix.to/#/#audiobookshelf:matrix.org

View File

@ -18,7 +18,8 @@ RUN apk update && \
ffmpeg \ ffmpeg \
make \ make \
python3 \ python3 \
g++ g++ \
tini
COPY --from=tone /usr/local/bin/tone /usr/local/bin/ COPY --from=tone /usr/local/bin/tone /usr/local/bin/
COPY --from=build /client/dist /client/dist COPY --from=build /client/dist /client/dist
@ -31,4 +32,5 @@ RUN apk del make python3 g++
EXPOSE 80 EXPOSE 80
ENTRYPOINT ["tini", "--"]
CMD ["node", "index.js"] CMD ["node", "index.js"]

View File

@ -8,10 +8,10 @@
<!-- Alternative bookshelf title/author/sort --> <!-- Alternative bookshelf title/author/sort -->
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }"> <div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }"> <div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
<div class="flex items-center"> <ui-tooltip :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
<span class="truncate">{{ displayTitle }}</span> <p ref="displayTitle" class="truncate">{{ displayTitle }}</p>
<widgets-explicit-indicator :explicit="isExplicit" /> <widgets-explicit-indicator :explicit="isExplicit" />
</div> </ui-tooltip>
</div> </div>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p> <p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p> <p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
@ -164,6 +164,7 @@ export default {
imageReady: false, imageReady: false,
selected: false, selected: false,
isSelectionMode: false, isSelectionMode: false,
displayTitleTruncated: false,
showCoverBg: false showCoverBg: false
} }
}, },
@ -642,6 +643,12 @@ export default {
} }
this.libraryItem = libraryItem this.libraryItem = libraryItem
this.$nextTick(() => {
if (this.$refs.displayTitle) {
this.displayTitleTruncated = this.$refs.displayTitle.scrollWidth > this.$refs.displayTitle.clientWidth
}
})
}, },
clickCard(e) { clickCard(e) {
if (this.processing) return if (this.processing) return

View File

@ -31,7 +31,7 @@
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn> <ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn>
</div> </div>
</div> </div>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" /> <modals-libraries-lazy-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
</div> </div>
</template> </template>

View File

@ -4,29 +4,32 @@
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span> <span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span>
<p class="px-4 text-xl">{{ $strings.HeaderChooseAFolder }}</p> <p class="px-4 text-xl">{{ $strings.HeaderChooseAFolder }}</p>
</div> </div>
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2"> <div v-if="rootDirs.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p> <p class="font-mono truncate">{{ selectedPath || '/' }}</p>
</div> </div>
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4 folder-container"> <div v-if="rootDirs.length" class="relative flex bg-primary bg-opacity-50 p-4 folder-container">
<div class="w-1/2 border-r border-bg h-full overflow-y-auto"> <div class="w-1/2 border-r border-bg h-full overflow-y-auto">
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack"> <div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center hover:bg-white/10" @click="goBack">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span> <span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2">..</p> <p class="text-base font-mono px-2">..</p>
</div> </div>
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" :class="dir.className" @click="selectDir(dir)"> <div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" :class="dir.className" @click="selectDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span> <span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p> <p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span> <span v-if="dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
</div> </div>
</div> </div>
<div class="w-1/2 h-full overflow-y-auto"> <div class="w-1/2 h-full overflow-y-auto">
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)"> <div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" @click="selectSubDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span> <span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p> <p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
</div> </div>
</div> </div>
<div v-if="loadingDirs" class="absolute inset-0 w-full h-full flex items-center justify-center bg-black/10">
<ui-loading-indicator />
</div>
</div> </div>
<div v-else-if="loadingFolders" class="py-12 text-center"> <div v-else-if="initialLoad" class="py-12 text-center">
<p>{{ $strings.MessageLoadingFolders }}</p> <p>{{ $strings.MessageLoadingFolders }}</p>
</div> </div>
<div v-else class="py-12 text-center max-w-sm mx-auto"> <div v-else class="py-12 text-center max-w-sm mx-auto">
@ -51,11 +54,12 @@ export default {
}, },
data() { data() {
return { return {
loadingFolders: false, initialLoad: false,
allFolders: [], loadingDirs: false,
isPosix: true,
rootDirs: [],
directories: [], directories: [],
selectedPath: '', selectedPath: '',
selectedFullPath: '',
subdirs: [], subdirs: [],
level: 0, level: 0,
currentDir: null, currentDir: null,
@ -98,59 +102,88 @@ export default {
} }
}, },
methods: { methods: {
goBack() { async goBack() {
var splitPaths = this.selectedPath.split('\\').slice(1) let selPath = this.selectedPath.replace(/^\//, '')
var prev = splitPaths.slice(0, -1).join('\\') var splitPaths = selPath.split('/')
var currDirs = this.allFolders let previousPath = ''
for (let i = 0; i < splitPaths.length; i++) { let lookupPath = ''
var _dir = currDirs.find((dir) => dir.dirname === splitPaths[i])
if (_dir && _dir.path.slice(1) === prev) { if (splitPaths.length > 2) {
this.directories = currDirs lookupPath = splitPaths.slice(0, -2).join('/')
this.selectDir(_dir)
return
} else if (_dir) {
currDirs = _dir.dirs
}
} }
previousPath = splitPaths.slice(0, -1).join('/')
if (!this.isPosix) {
// For windows drives add a trailing slash. e.g. C:/
if (!this.isPosix && lookupPath.endsWith(':')) {
lookupPath += '/'
}
if (!this.isPosix && previousPath.endsWith(':')) {
previousPath += '/'
}
} else {
// Add leading slash
if (previousPath) previousPath = '/' + previousPath
if (lookupPath) lookupPath = '/' + lookupPath
}
this.level--
this.subdirs = this.directories
this.selectedPath = previousPath
this.directories = await this.fetchDirs(lookupPath, this.level)
}, },
selectDir(dir) { async selectDir(dir) {
if (dir.isUsed) return if (dir.isUsed) return
this.selectedPath = dir.path this.selectedPath = dir.path
this.selectedFullPath = dir.fullPath
this.level = dir.level this.level = dir.level
this.subdirs = dir.dirs this.subdirs = await this.fetchDirs(dir.path, dir.level + 1)
}, },
selectSubDir(dir) { async selectSubDir(dir) {
if (dir.isUsed) return if (dir.isUsed) return
this.selectedPath = dir.path this.selectedPath = dir.path
this.selectedFullPath = dir.fullPath
this.level = dir.level this.level = dir.level
this.directories = this.subdirs this.directories = this.subdirs
this.subdirs = dir.dirs this.subdirs = await this.fetchDirs(dir.path, dir.level + 1)
}, },
selectFolder() { selectFolder() {
if (!this.selectedPath) { if (!this.selectedPath) {
console.error('No Selected path') console.error('No Selected path')
return return
} }
if (this.paths.find((p) => p.startsWith(this.selectedFullPath))) { if (this.paths.find((p) => p.startsWith(this.selectedPath))) {
this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`) this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`)
return return
} }
this.$emit('select', this.selectedFullPath) this.$emit('select', this.selectedPath)
this.selectedPath = '' this.selectedPath = ''
this.selectedFullPath = '' },
fetchDirs(path, level) {
this.loadingDirs = true
return this.$axios
.$get(`/api/filesystem?path=${path}&level=${level}`)
.then((data) => {
console.log('Fetched directories', data.directories)
this.isPosix = !!data.posix
return data.directories
})
.catch((error) => {
console.error('Failed to get filesystem paths', error)
this.$toast.error('Failed to get filesystem paths')
return []
})
.finally(() => {
this.loadingDirs = false
})
}, },
async init() { async init() {
this.loadingFolders = true this.initialLoad = true
this.allFolders = await this.$store.dispatch('libraries/loadFolders') this.rootDirs = await this.fetchDirs('', 0)
this.loadingFolders = false this.initialLoad = false
this.directories = this.allFolders this.directories = this.rootDirs
this.subdirs = [] this.subdirs = []
this.selectedPath = '' this.selectedPath = ''
this.selectedFullPath = ''
} }
}, },
mounted() { mounted() {

View File

@ -63,7 +63,7 @@ export default {
}, },
audioMetatags: { audioMetatags: {
id: 'audioMetatags', id: 'audioMetatags',
name: 'Audio file meta tags', name: 'Audio file meta tags OR ebook metadata',
include: true include: true
}, },
nfoFile: { nfoFile: {

View File

@ -68,7 +68,9 @@ export default {
selectAll: false, selectAll: false,
search: null, search: null,
searchTimeout: null, searchTimeout: null,
searchText: null searchText: null,
downloadedEpisodeGuidMap: {},
downloadedEpisodeUrlMap: {}
} }
}, },
watch: { watch: {
@ -122,11 +124,13 @@ export default {
}, },
methods: { methods: {
getIsEpisodeDownloaded(episode) { getIsEpisodeDownloaded(episode) {
return this.itemEpisodes.some((downloadedEpisode) => { if (episode.guid && !!this.downloadedEpisodeGuidMap[episode.guid]) {
if (episode.guid && downloadedEpisode.guid === episode.guid) return true return true
if (!downloadedEpisode.enclosure?.url) return false }
return this.getCleanEpisodeUrl(downloadedEpisode.enclosure.url) === episode.cleanUrl if (this.downloadedEpisodeUrlMap[episode.cleanUrl]) {
}) return true
}
return false
}, },
/** /**
* UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed. * UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed.
@ -219,6 +223,14 @@ export default {
}) })
}, },
init() { init() {
this.downloadedEpisodeGuidMap = {}
this.downloadedEpisodeUrlMap = {}
this.itemEpisodes.forEach((episode) => {
if (episode.guid) this.downloadedEpisodeGuidMap[episode.guid] = episode.id
if (episode.enclosure?.url) this.downloadedEpisodeUrlMap[this.getCleanEpisodeUrl(episode.enclosure.url)] = episode.id
})
this.episodesCleaned = this.episodes this.episodesCleaned = this.episodes
.filter((ep) => ep.enclosure?.url) .filter((ep) => ep.enclosure?.url)
.map((_ep) => { .map((_ep) => {

View File

@ -87,7 +87,7 @@ export default {
watch: { watch: {
libraryItem: { libraryItem: {
handler() { handler() {
this.init() this.refresh()
} }
} }
}, },
@ -515,6 +515,10 @@ export default {
filterSortChanged() { filterSortChanged() {
this.init() this.init()
}, },
refresh() {
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
this.init()
},
init() { init() {
this.destroyEpisodeComponents() this.destroyEpisodeComponents()
this.totalEpisodes = this.episodesList.length this.totalEpisodes = this.episodesList.length

View File

@ -15,6 +15,13 @@ export default {
type: String, type: String,
default: 'right' default: 'right'
}, },
/**
* Delay showing the tooltip after X milliseconds of hovering
*/
delayOnShow: {
type: Number,
default: 0
},
disabled: Boolean disabled: Boolean
}, },
data() { data() {
@ -22,7 +29,8 @@ export default {
tooltip: null, tooltip: null,
tooltipId: null, tooltipId: null,
isShowing: false, isShowing: false,
hideTimeout: null hideTimeout: null,
delayOnShowTimeout: null
} }
}, },
watch: { watch: {
@ -59,29 +67,44 @@ export default {
this.tooltip = tooltip this.tooltip = tooltip
}, },
setTooltipPosition(tooltip) { setTooltipPosition(tooltip) {
var boxChow = this.$refs.box.getBoundingClientRect() const boxRect = this.$refs.box.getBoundingClientRect()
const shouldMount = !tooltip.isConnected
var shouldMount = !tooltip.isConnected
// Calculate size of tooltip // Calculate size of tooltip
if (shouldMount) document.body.appendChild(tooltip) if (shouldMount) document.body.appendChild(tooltip)
var { width, height } = tooltip.getBoundingClientRect() const tooltipRect = tooltip.getBoundingClientRect()
if (shouldMount) tooltip.remove() if (shouldMount) tooltip.remove()
var top = 0 // Subtracting scrollbar size
var left = 0 const windowHeight = window.innerHeight - 8
const windowWidth = window.innerWidth - 8
let top = 0
let left = 0
if (this.direction === 'right') { if (this.direction === 'right') {
top = boxChow.top - height / 2 + boxChow.height / 2 top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2)
left = boxChow.left + boxChow.width + 4 left = Math.max(0, boxRect.left + boxRect.width + 4)
} else if (this.direction === 'bottom') { } else if (this.direction === 'bottom') {
top = boxChow.top + boxChow.height + 4 top = Math.max(0, boxRect.top + boxRect.height + 4)
left = boxChow.left - width / 2 + boxChow.width / 2 left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2)
} else if (this.direction === 'top') { } else if (this.direction === 'top') {
top = boxChow.top - height - 4 top = Math.max(0, boxRect.top - tooltipRect.height - 4)
left = boxChow.left - width / 2 + boxChow.width / 2 left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2)
} else if (this.direction === 'left') { } else if (this.direction === 'left') {
top = boxChow.top - height / 2 + boxChow.height / 2 top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2)
left = boxChow.left - width - 4 left = Math.max(0, boxRect.left - tooltipRect.width - 4)
} }
// Shift left if tooltip would overflow the window on the right
if (left + tooltipRect.width > windowWidth) {
left -= left + tooltipRect.width - windowWidth
}
// Shift up if tooltip would overflow the window on the bottom
if (top + tooltipRect.height > windowHeight) {
top -= top + tooltipRect.height - windowHeight
}
tooltip.style.top = top + 'px' tooltip.style.top = top + 'px'
tooltip.style.left = left + 'px' tooltip.style.left = left + 'px'
}, },
@ -107,15 +130,33 @@ export default {
this.isShowing = false this.isShowing = false
}, },
cancelHide() { cancelHide() {
if (this.hideTimeout) clearTimeout(this.hideTimeout) clearTimeout(this.hideTimeout)
}, },
mouseover() { mouseover() {
if (!this.isShowing) this.showTooltip() if (this.isShowing || this.disabled) return
if (this.delayOnShow) {
if (this.delayOnShowTimeout) {
// Delay already running
return
}
this.delayOnShowTimeout = setTimeout(() => {
this.showTooltip()
this.delayOnShowTimeout = null
}, this.delayOnShow)
} else {
this.showTooltip()
}
}, },
mouseleave() { mouseleave() {
if (this.isShowing) { if (!this.isShowing) {
this.hideTimeout = setTimeout(this.hideTooltip, 100) clearTimeout(this.delayOnShowTimeout)
this.delayOnShowTimeout = null
return
} }
this.hideTimeout = setTimeout(this.hideTooltip, 100)
} }
}, },
beforeDestroy() { beforeDestroy() {

View File

@ -178,9 +178,9 @@
</a> </a>
<p class="pl-4 pr-2 text-sm text-yellow-400"> <p class="pl-4 pr-2 text-sm text-yellow-400">
{{ $strings.MessageJoinUsOn }} {{ $strings.MessageJoinUsOn }}
<a class="underline" href="https://discord.gg/pJsjuNCKRq" target="_blank">discord</a> <a class="underline" href="https://discord.gg/HQgCbd6E75" target="_blank">discord</a>
</p> </p>
<a href="https://discord.gg/pJsjuNCKRq" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500"> <a href="https://discord.gg/HQgCbd6E75" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
<svg width="31" height="24" viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="31" height="24" viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)"> <g clip-path="url(#clip0)">
<path <path

View File

@ -54,9 +54,16 @@
<p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p> <p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p>
</button> </button>
<button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)"> <ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="playerQueueEpisodeIdMap[episode.id] ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" direction="top">
<span class="material-icons-outlined text-2xl">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span> <ui-icon-btn :icon="playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick(episode)" />
</button> <!-- <button class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)">
<span class="material-icons-outlined text-2xl">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span>
</button> -->
</ui-tooltip>
<ui-tooltip :text="$strings.LabelYourPlaylists" direction="top">
<ui-icon-btn icon="playlist_add" borderless @click="clickAddToPlaylist(episode)" />
</ui-tooltip>
</div> </div>
</div> </div>
@ -136,6 +143,15 @@ export default {
} }
}, },
methods: { methods: {
clickAddToPlaylist(episode) {
// Makeshift libraryItem
const libraryItem = {
id: episode.libraryItemId,
media: episode.podcast
}
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: libraryItem, episode }])
this.$store.commit('globals/setShowPlaylistsModal', true)
},
async clickEpisode(episode) { async clickEpisode(episode) {
if (this.openingItem) return if (this.openingItem) return
this.openingItem = true this.openingItem = true

View File

@ -1,10 +1,10 @@
{ {
"ButtonAdd": "Ajouter", "ButtonAdd": "Ajouter",
"ButtonAddChapters": "Ajouter le chapitre", "ButtonAddChapters": "Ajouter le chapitre",
"ButtonAddDevice": "Add Device", "ButtonAddDevice": "Ajouter un appareil",
"ButtonAddLibrary": "Add Library", "ButtonAddLibrary": "Ajouter une bibliothèque",
"ButtonAddPodcasts": "Ajouter des podcasts", "ButtonAddPodcasts": "Ajouter des podcasts",
"ButtonAddUser": "Add User", "ButtonAddUser": "Ajouter un utilisateur",
"ButtonAddYourFirstLibrary": "Ajouter votre première bibliothèque", "ButtonAddYourFirstLibrary": "Ajouter votre première bibliothèque",
"ButtonApply": "Appliquer", "ButtonApply": "Appliquer",
"ButtonApplyChapters": "Appliquer les chapitres", "ButtonApplyChapters": "Appliquer les chapitres",
@ -62,7 +62,7 @@
"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",
"ButtonResetToDefault": "Reset to default", "ButtonResetToDefault": "Réinitialiser aux valeurs par défaut",
"ButtonRestore": "Rétablir", "ButtonRestore": "Rétablir",
"ButtonSave": "Sauvegarder", "ButtonSave": "Sauvegarder",
"ButtonSaveAndClose": "Sauvegarder et Fermer", "ButtonSaveAndClose": "Sauvegarder et Fermer",
@ -87,9 +87,9 @@
"ButtonUserEdit": "Modifier lutilisateur {0}", "ButtonUserEdit": "Modifier lutilisateur {0}",
"ButtonViewAll": "Afficher tout", "ButtonViewAll": "Afficher tout",
"ButtonYes": "Oui", "ButtonYes": "Oui",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata", "ErrorUploadFetchMetadataAPI": "Erreur lors de la récupération des métadonnées",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", "ErrorUploadFetchMetadataNoResults": "Impossible de récupérer les métadonnées - essayez de mettre à jour le titre et/ou lauteur.",
"ErrorUploadLacksTitle": "Must have a title", "ErrorUploadLacksTitle": "Doit avoir un titre",
"HeaderAccount": "Compte", "HeaderAccount": "Compte",
"HeaderAdvanced": "Avancé", "HeaderAdvanced": "Avancé",
"HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise", "HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise",
@ -101,7 +101,7 @@
"HeaderChapters": "Chapitres", "HeaderChapters": "Chapitres",
"HeaderChooseAFolder": "Choisir un dossier", "HeaderChooseAFolder": "Choisir un dossier",
"HeaderCollection": "Collection", "HeaderCollection": "Collection",
"HeaderCollectionItems": "Entrées de la Collection", "HeaderCollectionItems": "Entrées de la collection",
"HeaderCover": "Couverture", "HeaderCover": "Couverture",
"HeaderCurrentDownloads": "Téléchargements en cours", "HeaderCurrentDownloads": "Téléchargements en cours",
"HeaderDetails": "Détails", "HeaderDetails": "Détails",
@ -114,10 +114,10 @@
"HeaderEreaderSettings": "Options Ereader", "HeaderEreaderSettings": "Options Ereader",
"HeaderFiles": "Fichiers", "HeaderFiles": "Fichiers",
"HeaderFindChapters": "Trouver les chapitres", "HeaderFindChapters": "Trouver les chapitres",
"HeaderIgnoredFiles": "Fichiers Ignorés", "HeaderIgnoredFiles": "Fichiers ignorés",
"HeaderItemFiles": "Fichiers des Articles", "HeaderItemFiles": "Fichiers des articles",
"HeaderItemMetadataUtils": "Outils de gestion des métadonnées", "HeaderItemMetadataUtils": "Outils de gestion des métadonnées",
"HeaderLastListeningSession": "Dernière Session découte", "HeaderLastListeningSession": "Dernière session découte",
"HeaderLatestEpisodes": "Dernier épisodes", "HeaderLatestEpisodes": "Dernier épisodes",
"HeaderLibraries": "Bibliothèque", "HeaderLibraries": "Bibliothèque",
"HeaderLibraryFiles": "Fichier de bibliothèque", "HeaderLibraryFiles": "Fichier de bibliothèque",
@ -130,15 +130,15 @@
"HeaderManageTags": "Gérer les étiquettes", "HeaderManageTags": "Gérer les étiquettes",
"HeaderMapDetails": "Édition en masse", "HeaderMapDetails": "Édition en masse",
"HeaderMatch": "Chercher", "HeaderMatch": "Chercher",
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataOrderOfPrecedence": "Ordre de priorité des métadonnées",
"HeaderMetadataToEmbed": "Métadonnée à intégrer", "HeaderMetadataToEmbed": "Métadonnées à intégrer",
"HeaderNewAccount": "Nouveau compte", "HeaderNewAccount": "Nouveau compte",
"HeaderNewLibrary": "Nouvelle bibliothèque", "HeaderNewLibrary": "Nouvelle bibliothèque",
"HeaderNotifications": "Notifications", "HeaderNotifications": "Notifications",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenIDConnectAuthentication": "Authentification via OpenID Connect",
"HeaderOpenRSSFeed": "Ouvrir Flux RSS", "HeaderOpenRSSFeed": "Ouvrir un flux RSS",
"HeaderOtherFiles": "Autres fichiers", "HeaderOtherFiles": "Autres fichiers",
"HeaderPasswordAuthentication": "Password Authentication", "HeaderPasswordAuthentication": "Authentification par mot de passe",
"HeaderPermissions": "Permissions", "HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Liste découte", "HeaderPlayerQueue": "Liste découte",
"HeaderPlaylist": "Liste de lecture", "HeaderPlaylist": "Liste de lecture",
@ -154,7 +154,7 @@
"HeaderSchedule": "Programmation", "HeaderSchedule": "Programmation",
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque", "HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
"HeaderSession": "Session", "HeaderSession": "Session",
"HeaderSetBackupSchedule": "Activer la Sauvegarde Automatique", "HeaderSetBackupSchedule": "Activer la sauvegarde automatique",
"HeaderSettings": "Paramètres", "HeaderSettings": "Paramètres",
"HeaderSettingsDisplay": "Affichage", "HeaderSettingsDisplay": "Affichage",
"HeaderSettingsExperimental": "Fonctionnalités expérimentales", "HeaderSettingsExperimental": "Fonctionnalités expérimentales",
@ -187,11 +187,11 @@
"LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection", "LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection",
"LabelAddToPlaylist": "Ajouter à la liste de lecture", "LabelAddToPlaylist": "Ajouter à la liste de lecture",
"LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture", "LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture",
"LabelAdminUsersOnly": "Admin users only", "LabelAdminUsersOnly": "Administrateurs uniquement",
"LabelAll": "Tout", "LabelAll": "Tout",
"LabelAllUsers": "Tous les utilisateurs", "LabelAllUsers": "Tous les utilisateurs",
"LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersExcludingGuests": "Tous les utilisateurs à lexception des invités",
"LabelAllUsersIncludingGuests": "All users including guests", "LabelAllUsersIncludingGuests": "Tous les utilisateurs, y compris les invités",
"LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque", "LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque",
"LabelAppend": "Ajouter", "LabelAppend": "Ajouter",
"LabelAuthor": "Auteur", "LabelAuthor": "Auteur",
@ -199,29 +199,29 @@
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)", "LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
"LabelAuthors": "Auteurs", "LabelAuthors": "Auteurs",
"LabelAutoDownloadEpisodes": "Téléchargement automatique dépisode", "LabelAutoDownloadEpisodes": "Téléchargement automatique dépisode",
"LabelAutoFetchMetadata": "Auto Fetch Metadata", "LabelAutoFetchMetadata": "Recherche automatique de métadonnées",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", "LabelAutoFetchMetadataHelp": "Récupère les métadonnées du titre, de lauteur et de la série pour simplifier le téléchargement. Il se peut que des métadonnées supplémentaires doivent être ajoutées après le téléchargement.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Lancement automatique",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirection automatique vers le fournisseur d'authentification lors de la navigation vers la page de connexion (chemin de remplacement manuel <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Enregistrement automatique",
"LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelAutoRegisterDescription": "Créer automatiquement de nouveaux utilisateurs après la connexion",
"LabelBackToUser": "Revenir à lUtilisateur", "LabelBackToUser": "Retour à lutilisateur",
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Emplacement de la sauvegarde",
"LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques", "LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques",
"LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes Enregistrées dans /metadata/backups", "LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes enregistrées dans /metadata/backups",
"LabelBackupsMaxBackupSize": "Taille maximale de la sauvegarde (en Go)", "LabelBackupsMaxBackupSize": "Taille maximale de la sauvegarde (en Go)",
"LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.", "LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.",
"LabelBackupsNumberToKeep": "Nombre de sauvegardes à maintenir", "LabelBackupsNumberToKeep": "Nombre de sauvegardes à conserver",
"LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.", "LabelBackupsNumberToKeepHelp": "Seule une sauvegarde sera supprimée à la fois. Si vous avez déjà plus de sauvegardes à effacer, vous devez les supprimer manuellement.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Livres", "LabelBooks": "Livres",
"LabelButtonText": "Button Text", "LabelButtonText": "Texte du bouton",
"LabelChangePassword": "Modifier le mot de passe", "LabelChangePassword": "Modifier le mot de passe",
"LabelChannels": "Canaux", "LabelChannels": "Canaux",
"LabelChapters": "Chapitres", "LabelChapters": "Chapitres",
"LabelChaptersFound": "Chapitres trouvés", "LabelChaptersFound": "chapitres trouvés",
"LabelChapterTitle": "Titres du chapitre", "LabelChapterTitle": "Titre du chapitre",
"LabelClickForMoreInfo": "Click for more info", "LabelClickForMoreInfo": "Cliquez ici pour plus dinformations",
"LabelClosePlayer": "Fermer le lecteur", "LabelClosePlayer": "Fermer le lecteur",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Réduire les séries", "LabelCollapseSeries": "Réduire les séries",
@ -235,20 +235,20 @@
"LabelCover": "Couverture", "LabelCover": "Couverture",
"LabelCoverImageURL": "URL vers limage de couverture", "LabelCoverImageURL": "URL vers limage de couverture",
"LabelCreatedAt": "Créé le", "LabelCreatedAt": "Créé le",
"LabelCronExpression": "Expression Cron", "LabelCronExpression": "Expression cron",
"LabelCurrent": "Courrant", "LabelCurrent": "Actuel",
"LabelCurrently": "En ce moment :", "LabelCurrently": "Actuellement :",
"LabelCustomCronExpression": "Expression cron personnalisée:", "LabelCustomCronExpression": "Expression cron personnalisée :",
"LabelDatetime": "Datetime", "LabelDatetime": "Date",
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)",
"LabelDescription": "Description", "LabelDescription": "Description",
"LabelDeselectAll": "Tout déselectionner", "LabelDeselectAll": "Tout déselectionner",
"LabelDevice": "Appareil", "LabelDevice": "Appareil",
"LabelDeviceInfo": "Détail de lappareil", "LabelDeviceInfo": "Détail de lappareil",
"LabelDeviceIsAvailableTo": "Device is available to...", "LabelDeviceIsAvailableTo": "Lappareil est disponible pour…",
"LabelDirectory": "Répertoire", "LabelDirectory": "Répertoire",
"LabelDiscFromFilename": "Disque depuis le fichier", "LabelDiscFromFilename": "Depuis le fichier",
"LabelDiscFromMetadata": "Disque depuis les métadonnées", "LabelDiscFromMetadata": "Depuis les métadonnées",
"LabelDiscover": "Découvrir", "LabelDiscover": "Découvrir",
"LabelDownload": "Téléchargement", "LabelDownload": "Téléchargement",
"LabelDownloadNEpisodes": "Télécharger {0} épisode(s)", "LabelDownloadNEpisodes": "Télécharger {0} épisode(s)",
@ -271,17 +271,17 @@
"LabelExample": "Exemple", "LabelExample": "Exemple",
"LabelExplicit": "Restriction", "LabelExplicit": "Restriction",
"LabelFeedURL": "URL du flux", "LabelFeedURL": "URL du flux",
"LabelFetchingMetadata": "Fetching Metadata", "LabelFetchingMetadata": "Récupération des métadonnées",
"LabelFile": "Fichier", "LabelFile": "Fichier",
"LabelFileBirthtime": "Création 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 utilisateur",
"LabelFindEpisodes": "Trouver des épisodes", "LabelFindEpisodes": "Trouver des épisodes",
"LabelFinished": "Fini(e)", "LabelFinished": "Terminé le",
"LabelFolder": "Dossier", "LabelFolder": "Dossier",
"LabelFolders": "Dossiers", "LabelFolders": "Dossiers",
"LabelFontFamily": "Famille de polices", "LabelFontFamily": "Polices de caractères",
"LabelFontScale": "Taille de la police de caractère", "LabelFontScale": "Taille de la police de caractère",
"LabelFormat": "Format", "LabelFormat": "Format",
"LabelGenre": "Genre", "LabelGenre": "Genre",
@ -289,16 +289,16 @@
"LabelHardDeleteFile": "Suppression du fichier", "LabelHardDeleteFile": "Suppression du fichier",
"LabelHasEbook": "Dispose dun livre numérique", "LabelHasEbook": "Dispose dun livre numérique",
"LabelHasSupplementaryEbook": "Dispose dun livre numérique supplémentaire", "LabelHasSupplementaryEbook": "Dispose dun livre numérique supplémentaire",
"LabelHighestPriority": "Highest priority", "LabelHighestPriority": "Priorité la plus élevée",
"LabelHost": "Hôte", "LabelHost": "Hôte",
"LabelHour": "Heure", "LabelHour": "Heure",
"LabelIcon": "Icone", "LabelIcon": "Icône",
"LabelImageURLFromTheWeb": "Image URL from the web", "LabelImageURLFromTheWeb": "URL de limage à partir du web",
"LabelIncludeInTracklist": "Inclure dans la liste des pistes", "LabelIncludeInTracklist": "Inclure dans la liste de lecture",
"LabelIncomplete": "Incomplet", "LabelIncomplete": "Incomplet",
"LabelInProgress": "En cours", "LabelInProgress": "En cours",
"LabelInterval": "Intervalle", "LabelInterval": "Intervalle",
"LabelIntervalCustomDailyWeekly": "Journalier / Hebdomadaire personnalisé", "LabelIntervalCustomDailyWeekly": "Personnaliser quotidiennement / hebdomadairement",
"LabelIntervalEvery12Hours": "Toutes les 12 heures", "LabelIntervalEvery12Hours": "Toutes les 12 heures",
"LabelIntervalEvery15Minutes": "Toutes les 15 minutes", "LabelIntervalEvery15Minutes": "Toutes les 15 minutes",
"LabelIntervalEvery2Hours": "Toutes les 2 heures", "LabelIntervalEvery2Hours": "Toutes les 2 heures",
@ -331,22 +331,22 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date", "LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date",
"LabelLowestPriority": "Lowest Priority", "LabelLowestPriority": "Priorité la plus basse",
"LabelMatchExistingUsersBy": "Match existing users by", "LabelMatchExistingUsersBy": "Faire correspondre les utilisateurs existants par",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMatchExistingUsersByDescription": "Utilisé pour connecter les utilisateurs existants. Une fois connectés, les utilisateurs seront associés à un identifiant unique provenant de votre fournisseur SSO.",
"LabelMediaPlayer": "Lecteur multimédia", "LabelMediaPlayer": "Lecteur multimédia",
"LabelMediaType": "Type de média", "LabelMediaType": "Type de média",
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataOrderOfPrecedenceDescription": "Les sources de métadonnées ayant une priorité plus élevée auront la priorité sur celles ayant une priorité moins élevée.",
"LabelMetadataProvider": "Fournisseur de métadonnées", "LabelMetadataProvider": "Fournisseur de métadonnées",
"LabelMetaTag": "Etiquette de métadonnée", "LabelMetaTag": "Balise de métadonnée",
"LabelMetaTags": "Etiquettes de métadonnée", "LabelMetaTags": "Balises de métadonnée",
"LabelMinute": "Minute", "LabelMinute": "Minute",
"LabelMissing": "Manquant", "LabelMissing": "Manquant",
"LabelMissingParts": "Parties manquantes", "LabelMissingParts": "Parties manquantes",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", "LabelMobileRedirectURIs": "URI de redirection mobile autorisés",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMobileRedirectURIsDescription": "Il s'agit d'une liste blanche dURI de redirection valides pour les applications mobiles. Celui par défaut est <code>audiobookshelf://oauth</code>, que vous pouvez supprimer ou compléter avec des URIs supplémentaires pour l'intégration d'applications tierces. Lutilisation dun astérisque (<code>*</code>) comme seule entrée autorise nimporte quel URI.",
"LabelMore": "Plus", "LabelMore": "Plus",
"LabelMoreInfo": "Plus dinfo", "LabelMoreInfo": "Plus dinformations",
"LabelName": "Nom", "LabelName": "Nom",
"LabelNarrator": "Narrateur", "LabelNarrator": "Narrateur",
"LabelNarrators": "Narrateurs", "LabelNarrators": "Narrateurs",
@ -358,7 +358,7 @@
"LabelNextScheduledRun": "Prochain lancement prévu", "LabelNextScheduledRun": "Prochain lancement prévu",
"LabelNoEpisodesSelected": "Aucun épisode sélectionné", "LabelNoEpisodesSelected": "Aucun épisode sélectionné",
"LabelNotes": "Notes", "LabelNotes": "Notes",
"LabelNotFinished": "Non terminé(e)", "LabelNotFinished": "Non terminé",
"LabelNotificationAppriseURL": "URL(s) dApprise", "LabelNotificationAppriseURL": "URL(s) dApprise",
"LabelNotificationAvailableVariables": "Variables disponibles", "LabelNotificationAvailableVariables": "Variables disponibles",
"LabelNotificationBodyTemplate": "Modèle de Message", "LabelNotificationBodyTemplate": "Modèle de Message",
@ -367,10 +367,10 @@
"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. Les notifications 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": "Pas commencé",
"LabelNumberOfBooks": "Nombre de Livres", "LabelNumberOfBooks": "Nombre de livres",
"LabelNumberOfEpisodes": "Nombre dEpisodes", "LabelNumberOfEpisodes": "Nombre dépisodes",
"LabelOpenRSSFeed": "Ouvrir le flux RSS", "LabelOpenRSSFeed": "Ouvrir le flux RSS",
"LabelOverwrite": "Écraser", "LabelOverwrite": "Écraser",
"LabelPassword": "Mot de passe", "LabelPassword": "Mot de passe",
@ -406,12 +406,12 @@
"LabelRegion": "Région", "LabelRegion": "Région",
"LabelReleaseDate": "Date de parution", "LabelReleaseDate": "Date de parution",
"LabelRemoveCover": "Supprimer la couverture", "LabelRemoveCover": "Supprimer la couverture",
"LabelRowsPerPage": "Rows per page", "LabelRowsPerPage": "Lignes par page",
"LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé", "LabelRSSFeedCustomOwnerEmail": "Courriel du 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",
"LabelRSSFeedSlug": "Identificateur dadresse du Flux RSS ", "LabelRSSFeedSlug": "Balise URL du flux RSS",
"LabelRSSFeedURL": "Adresse du flux RSS", "LabelRSSFeedURL": "Adresse du flux RSS",
"LabelSearchTerm": "Terme de recherche", "LabelSearchTerm": "Terme de recherche",
"LabelSearchTitle": "Titre de recherche", "LabelSearchTitle": "Titre de recherche",
@ -419,8 +419,8 @@
"LabelSeason": "Saison", "LabelSeason": "Saison",
"LabelSelectAllEpisodes": "Sélectionner tous les épisodes", "LabelSelectAllEpisodes": "Sélectionner tous les épisodes",
"LabelSelectEpisodesShowing": "Sélectionner {0} episode(s) en cours", "LabelSelectEpisodesShowing": "Sélectionner {0} episode(s) en cours",
"LabelSelectUsers": "Select users", "LabelSelectUsers": "Sélectionner les utilisateurs",
"LabelSendEbookToDevice": "Envoyer le livre numérique à...", "LabelSendEbookToDevice": "Envoyer le livre numérique à",
"LabelSequence": "Séquence", "LabelSequence": "Séquence",
"LabelSeries": "Séries", "LabelSeries": "Séries",
"LabelSeriesName": "Nom de la série", "LabelSeriesName": "Nom de la série",
@ -428,18 +428,18 @@
"LabelSetEbookAsPrimary": "Définir comme principale", "LabelSetEbookAsPrimary": "Définir comme principale",
"LabelSetEbookAsSupplementary": "Définir comme supplémentaire", "LabelSetEbookAsSupplementary": "Définir comme supplémentaire",
"LabelSettingsAudiobooksOnly": "Livres audios seulement", "LabelSettingsAudiobooksOnly": "Livres audios seulement",
"LabelSettingsAudiobooksOnlyHelp": "Lactivation de ce paramètre ignorera les fichiers “ ebook ”, à moins quils ne se trouvent dans un dossier de livres audio, auquel cas ils seront définis comme des livres numériques supplémentaires.", "LabelSettingsAudiobooksOnlyHelp": "L'activation de ce paramètre ignorera les fichiers de type « livre numériques », sauf s'ils se trouvent dans un dossier spécifique , auquel cas ils seront définis comme des livres numériques supplémentaires.",
"LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois", "LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois",
"LabelSettingsChromecastSupport": "Support du Chromecast", "LabelSettingsChromecastSupport": "Support du Chromecast",
"LabelSettingsDateFormat": "Format de date", "LabelSettingsDateFormat": "Format de date",
"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 des modifications de fichiers sont détectées. *Nécessite le redémarrage du serveur", "LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque des modifications de fichiers sont détectées. * nécessite le redémarrage du serveur",
"LabelSettingsEnableWatcher": "Activer la veille", "LabelSettingsEnableWatcher": "Activer la veille",
"LabelSettingsEnableWatcherForLibrary": "Activer la surveillance des dossiers pour la bibliothèque", "LabelSettingsEnableWatcherForLibrary": "Activer la surveillance des dossiers pour la bibliothèque",
"LabelSettingsEnableWatcherHelp": "Active la mise à jour automatique automatique lorsque des modifications de fichiers sont détectées. *Nécessite le redémarrage du serveur", "LabelSettingsEnableWatcherHelp": "Active la mise à jour automatique automatique lorsque des modifications de fichiers sont détectées. * nécessite le redémarrage du serveur",
"LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales", "LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales",
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion Github.", "LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion GitHub.",
"LabelSettingsFindCovers": "Chercher des couvertures de livre", "LabelSettingsFindCovers": "Chercher des couvertures de livre",
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, lanalyseur 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.",
"LabelSettingsHideSingleBookSeries": "Masquer les séries de livres uniques", "LabelSettingsHideSingleBookSeries": "Masquer les séries de livres uniques",
@ -447,13 +447,13 @@
"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",
"LabelSettingsParseSubtitles": "Analyser les sous-titres", "LabelSettingsParseSubtitles": "Analyser les sous-titres",
"LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du Livre Audio.<br>Les sous-titres doivent être séparés par « - »<br>i.e. « Titre du Livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »", "LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du livre audio.<br>Les sous-titres doivent être séparés par « - »<br>cest-à-dire : « Titre du livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »",
"LabelSettingsPreferMatchedMetadata": "Préférer les métadonnées par correspondance", "LabelSettingsPreferMatchedMetadata": "Préférer les métadonnées par correspondance",
"LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de larticle lors dune recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.", "LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de larticle lors dune recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.",
"LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN", "LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ISBN", "LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri", "LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »", "LabelSettingsSortingIgnorePrefixesHelp": "cest-à-dire : pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »",
"LabelSettingsSquareBookCovers": "Utiliser des couvertures carrées", "LabelSettingsSquareBookCovers": "Utiliser des couvertures carrées",
"LabelSettingsSquareBookCoversHelp": "Préférer les couvertures carrées par rapport aux couvertures standards de ratio 1.6:1.", "LabelSettingsSquareBookCoversHelp": "Préférer les couvertures carrées par rapport aux couvertures standards de ratio 1.6:1.",
"LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles", "LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles",
@ -461,30 +461,30 @@
"LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles", "LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles",
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items", "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items",
"LabelSettingsTimeFormat": "Format dheure", "LabelSettingsTimeFormat": "Format dheure",
"LabelShowAll": "Afficher Tout", "LabelShowAll": "Tout afficher",
"LabelSize": "Taille", "LabelSize": "Taille",
"LabelSleepTimer": "Minuterie", "LabelSleepTimer": "Minuterie",
"LabelSlug": "Slug", "LabelSlug": "Balise",
"LabelStart": "Démarrer", "LabelStart": "Démarrer",
"LabelStarted": "Démarré", "LabelStarted": "Démarré",
"LabelStartedAt": "Démarré à", "LabelStartedAt": "Démarré à",
"LabelStartTime": "Heure de Démarrage", "LabelStartTime": "Heure de démarrage",
"LabelStatsAudioTracks": "Pistes Audios", "LabelStatsAudioTracks": "Pistes Audios",
"LabelStatsAuthors": "Auteurs", "LabelStatsAuthors": "Auteurs",
"LabelStatsBestDay": "Meilleur Jour", "LabelStatsBestDay": "Meilleur jour",
"LabelStatsDailyAverage": "Moyenne Journalière", "LabelStatsDailyAverage": "Moyenne journalière",
"LabelStatsDays": "Jours", "LabelStatsDays": "Jours",
"LabelStatsDaysListened": "Jours découte", "LabelStatsDaysListened": "Jours découte",
"LabelStatsHours": "Heures", "LabelStatsHours": "Heures",
"LabelStatsInARow": "daffilé(s)", "LabelStatsInARow": "daffilée(s)",
"LabelStatsItemsFinished": "Articles terminés", "LabelStatsItemsFinished": "Articles terminés",
"LabelStatsItemsInLibrary": "Articles dans la Bibliothèque", "LabelStatsItemsInLibrary": "Articles dans la bibliothèque",
"LabelStatsMinutes": "minutes", "LabelStatsMinutes": "minutes",
"LabelStatsMinutesListening": "Minutes découte", "LabelStatsMinutesListening": "Minutes découte",
"LabelStatsOverallDays": "Jours au total", "LabelStatsOverallDays": "Nombre total de jours",
"LabelStatsOverallHours": "Heures au total", "LabelStatsOverallHours": "Nombre total d'heures",
"LabelStatsWeekListening": "Écoute de la semaine", "LabelStatsWeekListening": "Écoute de la semaine",
"LabelSubtitle": "Sous-Titre", "LabelSubtitle": "Sous-titre",
"LabelSupportedFileTypes": "Types de fichiers supportés", "LabelSupportedFileTypes": "Types de fichiers supportés",
"LabelTag": "Étiquette", "LabelTag": "Étiquette",
"LabelTags": "Étiquettes", "LabelTags": "Étiquettes",
@ -496,23 +496,23 @@
"LabelThemeLight": "Clair", "LabelThemeLight": "Clair",
"LabelTimeBase": "Base de temps", "LabelTimeBase": "Base de temps",
"LabelTimeListened": "Temps découte", "LabelTimeListened": "Temps découte",
"LabelTimeListenedToday": "Nombres découtes Aujourdhui", "LabelTimeListenedToday": "Nombres découtes aujourdhui",
"LabelTimeRemaining": "{0} restantes", "LabelTimeRemaining": "{0} restantes",
"LabelTimeToShift": "Temps de décalage en secondes", "LabelTimeToShift": "Temps de décalage en secondes",
"LabelTitle": "Titre", "LabelTitle": "Titre",
"LabelToolsEmbedMetadata": "Métadonnées Intégrées", "LabelToolsEmbedMetadata": "Métadonnées intégrées",
"LabelToolsEmbedMetadataDescription": "Intègre les métadonnées au fichier audio avec la couverture et les chapitres.", "LabelToolsEmbedMetadataDescription": "Intègre les métadonnées au fichier audio avec la couverture et les chapitres.",
"LabelToolsMakeM4b": "Créer un fichier Livre Audio M4B", "LabelToolsMakeM4b": "Créer un fichier livre audio M4B",
"LabelToolsMakeM4bDescription": "Génère un fichier Livre Audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.", "LabelToolsMakeM4bDescription": "Générer un fichier de livre audio .M4B avec des métadonnées intégrées, une image de couverture et des chapitres.",
"LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3", "LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3",
"LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, limage de couverture et les chapitres.", "LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, limage de couverture et les chapitres.",
"LabelTotalDuration": "Durée Totale", "LabelTotalDuration": "Durée totale",
"LabelTotalTimeListened": "Temps découte total", "LabelTotalTimeListened": "Temps découte total",
"LabelTrackFromFilename": "Piste depuis le fichier", "LabelTrackFromFilename": "Piste depuis le fichier",
"LabelTrackFromMetadata": "Piste depuis les métadonnées", "LabelTrackFromMetadata": "Piste depuis les métadonnées",
"LabelTracks": "Pistes", "LabelTracks": "Pistes",
"LabelTracksMultiTrack": "Piste multiple", "LabelTracksMultiTrack": "Piste multiple",
"LabelTracksNone": "No tracks", "LabelTracksNone": "Aucune piste",
"LabelTracksSingleTrack": "Piste simple", "LabelTracksSingleTrack": "Piste simple",
"LabelType": "Type", "LabelType": "Type",
"LabelUnabridged": "Version intégrale", "LabelUnabridged": "Version intégrale",
@ -524,9 +524,9 @@
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée", "LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée",
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers", "LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
"LabelUploaderDropFiles": "Déposer des fichiers", "LabelUploaderDropFiles": "Déposer des fichiers",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", "LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, lauteur et la série",
"LabelUseChapterTrack": "Utiliser la piste du chapitre", "LabelUseChapterTrack": "Utiliser la piste du chapitre",
"LabelUseFullTrack": "Utiliser la piste Complète", "LabelUseFullTrack": "Utiliser la piste complète",
"LabelUser": "Utilisateur", "LabelUser": "Utilisateur",
"LabelUsername": "Nom dutilisateur", "LabelUsername": "Nom dutilisateur",
"LabelValue": "Valeur", "LabelValue": "Valeur",
@ -541,14 +541,14 @@
"LabelYourPlaylists": "Vos listes de lecture", "LabelYourPlaylists": "Vos listes de lecture",
"LabelYourProgress": "Votre progression", "LabelYourProgress": "Votre progression",
"MessageAddToPlayerQueue": "Ajouter en file dattente", "MessageAddToPlayerQueue": "Ajouter en file dattente",
"MessageAppriseDescription": "Nécessite une instance d<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes. <br />lURL de lAPI Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "Nécessite une instance d<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.<br>LURL de lAPI Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les sauvegardes nincluent pas les fichiers de votre bibliothèque.", "MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les sauvegardes nincluent pas les fichiers de votre bibliothèque.",
"MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera dajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer loption suivante pour autoriser la recherche par correspondance à écraser les données existantes.", "MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera dajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer loption suivante pour autoriser la recherche par correspondance à écraser les données existantes.",
"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érie", "MessageBookshelfNoSeries": "Vous navez aucune série",
"MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio", "MessageChapterEndIsAfter": "La fin du chapitre se situe après 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",
"MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre", "MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre",
@ -558,15 +558,15 @@
"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": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?", "MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?",
"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} » ?",
"MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", "MessageConfirmDeleteLibraryItem": "Cette opération supprimera lélément de la base de données et de votre système de fichiers. Êtes-vous sûr ?",
"MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteLibraryItems": "Cette opération supprimera {0} éléments de la base de données et de votre système de fichiers. Êtes-vous sûr ?",
"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 ?",
"MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?", "MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?",
"MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr de vouloir marquer tous les épisodes comme non terminés ?", "MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr de vouloir marquer tous les épisodes comme non terminés ?",
"MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme terminées ?", "MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme terminées ?",
"MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme comme non terminés ?", "MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme comme non terminés ?",
"MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmQuickEmbed": "Attention ! Lintégration rapide ne sauvegardera pas vos fichiers audio. Assurez-vous davoir effectuer une sauvegarde de vos fichiers audio.<br><br>Souhaitez-vous continuer ?",
"MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?", "MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?",
"MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?",
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?", "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
@ -581,16 +581,16 @@
"MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer létiquette « {0} » en « {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} ».",
"MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmReScanLibraryItems": "Êtes-vous sûr de vouloir re-analyser {0} éléments ?",
"MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer le livre numérique {0} « {1} » à lappareil « {2} »?", "MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer le livre numérique {0} « {1} » à lappareil « {2} »?",
"MessageDownloadingEpisode": "Téléchargement de lépisode", "MessageDownloadingEpisode": "Téléchargement de lépisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans lordre correct", "MessageDragFilesIntoTrackOrder": "Faites glisser les fichiers dans lordre correct des pistes",
"MessageEmbedFinished": "Intégration terminée !", "MessageEmbedFinished": "Intégration terminée !",
"MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement", "MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement",
"MessageFeedURLWillBe": "lURL du flux sera {0}", "MessageFeedURLWillBe": "LURL du flux sera {0}",
"MessageFetching": "Récupération…", "MessageFetching": "Récupération…",
"MessageForceReScanDescription": "Analysera tous les fichiers de nouveau. Les étiquettes ID3 des fichiers audios, fichiers OPF, et les fichiers textes seront analysés comme sils étaient nouveaux.", "MessageForceReScanDescription": "analysera de nouveau tous les fichiers. Les étiquettes ID3 des fichiers audio, les fichiers OPF et les fichiers texte seront analysés comme sils étaient nouveaux.",
"MessageImportantNotice": "Information Importante !", "MessageImportantNotice": "Information importante !",
"MessageInsertChapterBelow": "Insérer le chapitre ci-dessous", "MessageInsertChapterBelow": "Insérer le chapitre ci-dessous",
"MessageItemsSelected": "{0} articles sélectionnés", "MessageItemsSelected": "{0} articles sélectionnés",
"MessageItemsUpdated": "{0} articles mis à jour", "MessageItemsUpdated": "{0} articles mis à jour",
@ -646,13 +646,13 @@
"MessageRemoveChapter": "Supprimer le chapitre", "MessageRemoveChapter": "Supprimer le chapitre",
"MessageRemoveEpisodes": "Suppression de {0} épisode(s)", "MessageRemoveEpisodes": "Suppression de {0} épisode(s)",
"MessageRemoveFromPlayerQueue": "Supprimer de la liste découte", "MessageRemoveFromPlayerQueue": "Supprimer de la liste découte",
"MessageRemoveUserWarning": "Êtes-vous certain de vouloir supprimer définitivement lutilisateur « {0} » ?", "MessageRemoveUserWarning": "Êtes-vous sûr de vouloir supprimer définitivement lutilisateur « {0} » ?",
"MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur", "MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur",
"MessageResetChaptersConfirm": "Êtes-vous certain de vouloir réinitialiser les chapitres et annuler les changements effectués ?", "MessageResetChaptersConfirm": "Êtes-vous sûr de vouloir réinitialiser les chapitres et annuler les changements effectués ?",
"MessageRestoreBackupConfirm": "Êtes-vous certain de vouloir restaurer la sauvegarde créée le", "MessageRestoreBackupConfirm": "Êtes-vous sûr de vouloir restaurer la sauvegarde créée le",
"MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.", "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.<br><br>Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br><br>Tous les clients utilisant votre serveur seront automatiquement mis à jour.",
"MessageSearchResultsFor": "Résultats de recherche pour", "MessageSearchResultsFor": "Résultats de recherche pour",
"MessageSelected": "{0} selected", "MessageSelected": "{0} sélectionnés",
"MessageServerCouldNotBeReached": "Serveur inaccessible", "MessageServerCouldNotBeReached": "Serveur inaccessible",
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre", "MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
"MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?", "MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?",
@ -663,10 +663,10 @@
"MessageValidCronExpression": "Expression cron valide", "MessageValidCronExpression": "Expression cron valide",
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur", "MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !", "MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",
"MessageYourAudiobookDurationIsLonger": "La durée de votre Livre Audio est plus longue que la durée trouvée", "MessageYourAudiobookDurationIsLonger": "La durée de votre livre audio est plus longue que la durée trouvée",
"MessageYourAudiobookDurationIsShorter": "La durée de votre Livre Audio est plus courte que la durée trouvée", "MessageYourAudiobookDurationIsShorter": "La durée de votre livre audio est plus courte que la durée trouvée",
"NoteChangeRootPassword": "seul lutilisateur « root » peut utiliser un mot de passe vide", "NoteChangeRootPassword": "seul lutilisateur « root » peut utiliser un mot de passe vide",
"NoteChapterEditorTimes": "Information : lhorodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du Livre Audio.", "NoteChapterEditorTimes": "Information : lhorodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du livre audio.",
"NoteFolderPicker": "Information : Les dossiers déjà surveillés ne sont pas affichés", "NoteFolderPicker": "Information : Les dossiers déjà surveillés ne sont pas affichés",
"NoteFolderPickerDebian": "Information : La sélection de dossier sur une installation debian nest pas finalisée. Merci de renseigner le chemin complet vers votre bibliothèque manuellement.", "NoteFolderPickerDebian": "Information : La sélection de dossier sur une installation debian nest pas finalisée. Merci de renseigner le chemin complet vers votre bibliothèque manuellement.",
"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.",
@ -677,8 +677,8 @@
"PlaceholderNewCollection": "Nom de la nouvelle collection", "PlaceholderNewCollection": "Nom de la nouvelle collection",
"PlaceholderNewFolderPath": "Nouveau chemin de dossier", "PlaceholderNewFolderPath": "Nouveau chemin de dossier",
"PlaceholderNewPlaylist": "Nouveau nom de liste de lecture", "PlaceholderNewPlaylist": "Nouveau nom de liste de lecture",
"PlaceholderSearch": "Recherche...", "PlaceholderSearch": "Recherche",
"PlaceholderSearchEpisode": "Recherche dépisode...", "PlaceholderSearchEpisode": "Recherche dépisode",
"ToastAccountUpdateFailed": "Échec de la mise à jour du compte", "ToastAccountUpdateFailed": "Échec de la mise à jour du compte",
"ToastAccountUpdateSuccess": "Compte mis à jour", "ToastAccountUpdateSuccess": "Compte mis à jour",
"ToastAuthorImageRemoveFailed": "Échec de la suppression de limage", "ToastAuthorImageRemoveFailed": "Échec de la suppression de limage",
@ -750,4 +750,4 @@
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket", "ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
"ToastUserDeleteFailed": "Échec de la suppression de lutilisateur", "ToastUserDeleteFailed": "Échec de la suppression de lutilisateur",
"ToastUserDeleteSuccess": "Utilisateur supprimé" "ToastUserDeleteSuccess": "Utilisateur supprimé"
} }

View File

@ -39,13 +39,15 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
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)
Join us on [Discord](https://discord.gg/pJsjuNCKRq) or [Matrix](https://matrix.to/#/#audiobookshelf:matrix.org) Join us on [Discord](https://discord.gg/HQgCbd6E75) or [Matrix](https://matrix.to/#/#audiobookshelf:matrix.org)
### Android App (beta) ### Android App (beta)
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app) Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
### iOS App (beta) ### iOS App (beta)
Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join the discussion](https://github.com/advplyr/audiobookshelf-app/discussions/60) **Beta is currently full. Apple has a hard limit of 10k beta testers. Updates will be posted in Discord/Matrix.**
Using Test Flight: https://testflight.apple.com/join/wiic7QIW ***(beta is full)***
### Build your own tools & clients ### Build your own tools & clients
Check out the [API documentation](https://api.audiobookshelf.org/) Check out the [API documentation](https://api.audiobookshelf.org/)

View File

@ -182,11 +182,11 @@ class Database {
if (process.env.QUERY_LOGGING === "log") { if (process.env.QUERY_LOGGING === "log") {
// Setting QUERY_LOGGING=log will log all Sequelize queries before they run // Setting QUERY_LOGGING=log will log all Sequelize queries before they run
Logger.info(`[Database] Query logging enabled`) Logger.info(`[Database] Query logging enabled`)
logging = (query) => Logger.dev(`Running the following query:\n ${query}`) logging = (query) => Logger.debug(`Running the following query:\n ${query}`)
} else if (process.env.QUERY_LOGGING === "benchmark") { } else if (process.env.QUERY_LOGGING === "benchmark") {
// Setting QUERY_LOGGING=benchmark will log all Sequelize queries and their execution times, after they run // Setting QUERY_LOGGING=benchmark will log all Sequelize queries and their execution times, after they run
Logger.info(`[Database] Query benchmarking enabled"`) Logger.info(`[Database] Query benchmarking enabled"`)
logging = (query, time) => Logger.dev(`Ran the following query in ${time}ms:\n ${query}`) logging = (query, time) => Logger.debug(`Ran the following query in ${time}ms:\n ${query}`)
benchmark = true benchmark = true
} }

View File

@ -5,7 +5,6 @@ class Logger {
constructor() { constructor() {
this.isDev = process.env.NODE_ENV !== 'production' this.isDev = process.env.NODE_ENV !== 'production'
this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE
this.hideDevLogs = process.env.HIDE_DEV_LOGS === undefined ? !this.isDev : process.env.HIDE_DEV_LOGS === '1'
this.socketListeners = [] this.socketListeners = []
this.logManager = null this.logManager = null
@ -88,15 +87,6 @@ class Logger {
this.debug(`Set Log Level to ${this.levelString}`) this.debug(`Set Log Level to ${this.levelString}`)
} }
/**
* Only to console and only for development
* @param {...any} args
*/
dev(...args) {
if (this.hideDevLogs) return
console.log(`[${this.timestamp}] DEV:`, ...args)
}
trace(...args) { trace(...args) {
if (this.logLevel > LogLevel.TRACE) return if (this.logLevel > LogLevel.TRACE) return
console.trace(`[${this.timestamp}] TRACE:`, ...args) console.trace(`[${this.timestamp}] TRACE:`, ...args)

View File

@ -1,31 +1,69 @@
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const Database = require('../Database')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const { toNumber } = require('../utils/index')
const fileUtils = require('../utils/fileUtils')
class FileSystemController { class FileSystemController {
constructor() { } constructor() { }
/**
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getPaths(req, res) { async getPaths(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[FileSystemController] Non-admin user attempting to get filesystem paths`, req.user) Logger.error(`[FileSystemController] Non-admin user attempting to get filesystem paths`, req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => { const relpath = req.query.path
return Path.sep + dirname const level = toNumber(req.query.level, 0)
})
// Do not include existing mapped library paths in response // Validate path. Must be absolute
const libraryFoldersPaths = await Database.libraryFolderModel.getAllLibraryFolderPaths() if (relpath && (!Path.isAbsolute(relpath) || !await fs.pathExists(relpath))) {
libraryFoldersPaths.forEach((path) => { Logger.error(`[FileSystemController] Invalid path in query string "${relpath}"`)
let dir = path || '' return res.status(400).send('Invalid "path" query string')
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '') }
excludedDirs.push(dir) Logger.debug(`[FileSystemController] Getting file paths at ${relpath || 'root'} (${level})`)
let directories = []
// Windows returns drives first
if (global.isWin) {
if (relpath) {
directories = await fileUtils.getDirectoriesInPath(relpath, level)
} else {
const drives = await fileUtils.getWindowsDrives().catch((error) => {
Logger.error(`[FileSystemController] Failed to get windows drives`, error)
return []
})
if (drives.length) {
directories = drives.map(d => {
return {
path: d,
dirname: d,
level: 0
}
})
}
}
} else {
directories = await fileUtils.getDirectoriesInPath(relpath || '/', level)
}
// Exclude some dirs from this project to be cleaner in Docker
const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc', '.devcontainer', '.nyc_output', '.github', '.vscode'].map(dirname => {
return fileUtils.filePathToPOSIX(Path.join(global.appRoot, dirname))
})
directories = directories.filter(dir => {
return !excludedDirs.includes(dir.path)
}) })
res.json({ res.json({
directories: await this.getDirectories(global.appRoot, '/', excludedDirs) posix: !global.isWin,
directories
}) })
} }

View File

@ -7,6 +7,8 @@ const imageType = require('../libs/imageType')
const globals = require('../utils/globals') const globals = require('../utils/globals')
const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils') const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils')
const { extractCoverArt } = require('../utils/ffmpegHelpers') const { extractCoverArt } = require('../utils/ffmpegHelpers')
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const CacheManager = require('../managers/CacheManager') const CacheManager = require('../managers/CacheManager')
class CoverManager { class CoverManager {
@ -234,6 +236,7 @@ class CoverManager {
/** /**
* Extract cover art from audio file and save for library item * Extract cover art from audio file and save for library item
*
* @param {import('../models/Book').AudioFileObject[]} audioFiles * @param {import('../models/Book').AudioFileObject[]} audioFiles
* @param {string} libraryItemId * @param {string} libraryItemId
* @param {string} [libraryItemPath] null for isFile library items * @param {string} [libraryItemPath] null for isFile library items
@ -268,6 +271,44 @@ class CoverManager {
return null return null
} }
/**
* Extract cover art from ebook and save for library item
*
* @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
* @param {string} libraryItemId
* @param {string} [libraryItemPath] null for isFile library items
* @returns {Promise<string>} returns cover path
*/
async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) {
if (!ebookFileScanData?.ebookCoverPath) return null
let coverDirPath = null
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
coverDirPath = libraryItemPath
} else {
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
}
await fs.ensureDir(coverDirPath)
let extname = Path.extname(ebookFileScanData.ebookCoverPath) || '.jpg'
if (extname === '.jpeg') extname = '.jpg'
const coverFilename = `cover${extname}`
const coverFilePath = Path.join(coverDirPath, coverFilename)
// TODO: Overwrite if exists?
const coverAlreadyExists = await fs.pathExists(coverFilePath)
if (coverAlreadyExists) {
Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for "${coverFilePath}" - overwriting`)
}
const success = await parseEbookMetadata.extractCoverImage(ebookFileScanData, coverFilePath)
if (success) {
await CacheManager.purgeCoverCache(libraryItemId)
return coverFilePath
}
return null
}
/** /**
* *
* @param {string} url * @param {string} url

View File

@ -233,7 +233,7 @@ class Library extends Model {
for (let i = 0; i < libraries.length; i++) { for (let i = 0; i < libraries.length; i++) {
const library = libraries[i] const library = libraries[i]
if (library.displayOrder !== i + 1) { if (library.displayOrder !== i + 1) {
Logger.dev(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`) Logger.debug(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`)
await library.update({ displayOrder: i + 1 }).catch((error) => { await library.update({ displayOrder: i + 1 }).catch((error) => {
Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error) Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error)
}) })

View File

@ -264,7 +264,7 @@ class LibraryItem extends Model {
for (const existingPodcastEpisode of existingPodcastEpisodes) { for (const existingPodcastEpisode of existingPodcastEpisodes) {
// Episode was removed // Episode was removed
if (!updatedPodcastEpisodes.some(ep => ep.id === existingPodcastEpisode.id)) { if (!updatedPodcastEpisodes.some(ep => ep.id === existingPodcastEpisode.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`)
await existingPodcastEpisode.destroy() await existingPodcastEpisode.destroy()
hasUpdates = true hasUpdates = true
} }
@ -272,7 +272,7 @@ class LibraryItem extends Model {
for (const updatedPodcastEpisode of updatedPodcastEpisodes) { for (const updatedPodcastEpisode of updatedPodcastEpisodes) {
const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id) const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id)
if (!existingEpisodeMatch) { if (!existingEpisodeMatch) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`)
await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode) await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode)
hasUpdates = true hasUpdates = true
} else { } else {
@ -283,7 +283,7 @@ class LibraryItem extends Model {
if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) { if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from "${existingValue}" to "${updatedEpisodeCleaned[key]}"`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from "${existingValue}" to "${updatedEpisodeCleaned[key]}"`)
episodeHasUpdates = true episodeHasUpdates = true
} }
} }
@ -304,7 +304,7 @@ class LibraryItem extends Model {
for (const existingAuthor of existingAuthors) { for (const existingAuthor of existingAuthors) {
// Author was removed from Book // Author was removed from Book
if (!updatedAuthors.some(au => au.id === existingAuthor.id)) { if (!updatedAuthors.some(au => au.id === existingAuthor.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`)
await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id) await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id)
hasUpdates = true hasUpdates = true
} }
@ -312,7 +312,7 @@ class LibraryItem extends Model {
for (const updatedAuthor of updatedAuthors) { for (const updatedAuthor of updatedAuthors) {
// Author was added // Author was added
if (!existingAuthors.some(au => au.id === updatedAuthor.id)) { if (!existingAuthors.some(au => au.id === updatedAuthor.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`)
await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id }) await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id })
hasUpdates = true hasUpdates = true
} }
@ -320,7 +320,7 @@ class LibraryItem extends Model {
for (const existingSeries of existingSeriesAll) { for (const existingSeries of existingSeriesAll) {
// Series was removed // Series was removed
if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) { if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`)
await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id) await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id)
hasUpdates = true hasUpdates = true
} }
@ -329,11 +329,11 @@ class LibraryItem extends Model {
// Series was added/updated // Series was added/updated
const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id) const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id)
if (!existingSeriesMatch) { if (!existingSeriesMatch) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`)
await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence }) await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence })
hasUpdates = true hasUpdates = true
} else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) { } else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`)
await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence }) await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence })
hasUpdates = true hasUpdates = true
} }
@ -346,7 +346,7 @@ class LibraryItem extends Model {
if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedMedia[key], existingValue, true)) { if (!areEquivalent(updatedMedia[key], existingValue, true)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`)
hasMediaUpdates = true hasMediaUpdates = true
} }
} }
@ -363,7 +363,7 @@ class LibraryItem extends Model {
if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) { if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from ${existingValue} to ${updatedLibraryItem[key]}`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from ${existingValue} to ${updatedLibraryItem[key]}`)
hasLibraryItemUpdates = true hasLibraryItemUpdates = true
} }
} }
@ -541,7 +541,7 @@ class LibraryItem extends Model {
}) })
} }
} }
Logger.dev(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)
let start = Date.now() let start = Date.now()
if (library.isBook) { if (library.isBook) {
@ -558,7 +558,7 @@ class LibraryItem extends Model {
total: continueSeriesPayload.count total: continueSeriesPayload.count
}) })
} }
Logger.dev(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
} else if (library.isPodcast) { } else if (library.isPodcast) {
// "Newest Episodes" shelf // "Newest Episodes" shelf
const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit) const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit)
@ -572,7 +572,7 @@ class LibraryItem extends Model {
total: newestEpisodesPayload.count total: newestEpisodesPayload.count
}) })
} }
Logger.dev(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
} }
start = Date.now() start = Date.now()
@ -588,7 +588,7 @@ class LibraryItem extends Model {
total: mostRecentPayload.count total: mostRecentPayload.count
}) })
} }
Logger.dev(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
if (library.isBook) { if (library.isBook) {
start = Date.now() start = Date.now()
@ -604,7 +604,7 @@ class LibraryItem extends Model {
total: seriesMostRecentPayload.count total: seriesMostRecentPayload.count
}) })
} }
Logger.dev(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
start = Date.now() start = Date.now()
// "Discover" shelf // "Discover" shelf
@ -619,7 +619,7 @@ class LibraryItem extends Model {
total: discoverLibraryItemsPayload.count total: discoverLibraryItemsPayload.count
}) })
} }
Logger.dev(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
} }
start = Date.now() start = Date.now()
@ -650,7 +650,7 @@ class LibraryItem extends Model {
}) })
} }
} }
Logger.dev(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
if (library.isBook) { if (library.isBook) {
start = Date.now() start = Date.now()
@ -666,7 +666,7 @@ class LibraryItem extends Model {
total: newestAuthorsPayload.count total: newestAuthorsPayload.count
}) })
} }
Logger.dev(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
} }
Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)

View File

@ -324,35 +324,6 @@ class ApiRouter {
this.router.delete('/custom-metadata-providers/admin/:id', MiscController.deleteCustomMetadataProviders.bind(this)) this.router.delete('/custom-metadata-providers/admin/:id', MiscController.deleteCustomMetadataProviders.bind(this))
} }
async getDirectories(dir, relpath, excludedDirs, level = 0) {
try {
const paths = await fs.readdir(dir)
let dirs = await Promise.all(paths.map(async dirname => {
const fullPath = Path.join(dir, dirname)
const path = Path.join(relpath, dirname)
const isDir = (await fs.lstat(fullPath)).isDirectory()
if (isDir && !excludedDirs.includes(path) && dirname !== 'node_modules') {
return {
path,
dirname,
fullPath,
level,
dirs: level < 4 ? (await this.getDirectories(fullPath, path, excludedDirs, level + 1)) : []
}
} else {
return false
}
}))
dirs = dirs.filter(d => d)
return dirs
} catch (error) {
Logger.error('Failed to readdir', dir, error)
return []
}
}
// //
// Helper Methods // Helper Methods
// //

View File

@ -36,6 +36,8 @@ class AbsMetadataFileScanner {
for (const key in abMetadata) { for (const key in abMetadata) {
// TODO: When to override with null or empty arrays? // TODO: When to override with null or empty arrays?
if (abMetadata[key] === undefined || abMetadata[key] === null) continue if (abMetadata[key] === undefined || abMetadata[key] === null) continue
if (key === 'authors' && !abMetadata.authors?.length) continue
if (key === 'genres' && !abMetadata.genres?.length) continue
if (key === 'tags' && !abMetadata.tags?.length) continue if (key === 'tags' && !abMetadata.tags?.length) continue
if (key === 'chapters' && !abMetadata.chapters?.length) continue if (key === 'chapters' && !abMetadata.chapters?.length) continue

View File

@ -3,8 +3,8 @@ const Path = require('path')
const sequelize = require('sequelize') const sequelize = require('sequelize')
const { LogLevel } = require('../utils/constants') const { LogLevel } = require('../utils/constants')
const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index') const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index')
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
const parseNameString = require('../utils/parsers/parseNameString') const parseNameString = require('../utils/parsers/parseNameString')
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const globals = require('../utils/globals') const globals = require('../utils/globals')
const AudioFileScanner = require('./AudioFileScanner') const AudioFileScanner = require('./AudioFileScanner')
const Database = require('../Database') const Database = require('../Database')
@ -170,7 +170,9 @@ class BookScanner {
hasMediaChanges = true hasMediaChanges = true
} }
const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, libraryItemData, libraryScan, librarySettings, existingLibraryItem.id) const ebookFileScanData = await parseEbookMetadata.parse(media.ebookFile)
const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings, existingLibraryItem.id)
let authorsUpdated = false let authorsUpdated = false
const bookAuthorsRemoved = [] const bookAuthorsRemoved = []
let seriesUpdated = false let seriesUpdated = false
@ -317,24 +319,34 @@ class BookScanner {
}) })
} }
// If no cover then extract cover from audio file if available OR search for cover if enabled in server settings // If no cover then extract cover from audio file OR from ebook
const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path
if (!media.coverPath) { if (!media.coverPath) {
const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir)
const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir)
if (extractedCoverPath) { if (extractedCoverPath) {
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`) libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`)
media.coverPath = extractedCoverPath media.coverPath = extractedCoverPath
hasMediaChanges = true hasMediaChanges = true
} else if (Database.serverSettings.scannerFindCovers) { } else if (ebookFileScanData?.ebookCoverPath) {
const authorName = media.authors.map(au => au.name).filter(au => au).join(', ') extractedCoverPath = await CoverManager.saveEbookCoverArt(ebookFileScanData, existingLibraryItem.id, libraryItemDir)
const coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan) if (extractedCoverPath) {
if (coverPath) { libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from ebook file to path "${extractedCoverPath}"`)
media.coverPath = coverPath media.coverPath = extractedCoverPath
hasMediaChanges = true hasMediaChanges = true
} }
} }
} }
// If no cover then search for cover if enabled in server settings
if (!media.coverPath && Database.serverSettings.scannerFindCovers) {
const authorName = media.authors.map(au => au.name).filter(au => au).join(', ')
const coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan)
if (coverPath) {
media.coverPath = coverPath
hasMediaChanges = true
}
}
existingLibraryItem.media = media existingLibraryItem.media = media
let libraryItemUpdated = false let libraryItemUpdated = false
@ -408,12 +420,14 @@ class BookScanner {
return null return null
} }
let ebookFileScanData = null
if (ebookLibraryFile) { if (ebookLibraryFile) {
ebookLibraryFile = ebookLibraryFile.toJSON() ebookLibraryFile = ebookLibraryFile.toJSON()
ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase() ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase()
ebookFileScanData = await parseEbookMetadata.parse(ebookLibraryFile)
} }
const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, libraryItemData, libraryScan, librarySettings) const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings)
bookMetadata.explicit = !!bookMetadata.explicit // Ensure boolean bookMetadata.explicit = !!bookMetadata.explicit // Ensure boolean
bookMetadata.abridged = !!bookMetadata.abridged // Ensure boolean bookMetadata.abridged = !!bookMetadata.abridged // Ensure boolean
@ -481,19 +495,28 @@ class BookScanner {
} }
} }
// If cover was not found in folder then check embedded covers in audio files OR search for cover // If cover was not found in folder then check embedded covers in audio files OR ebook file
const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path
if (!bookObject.coverPath) { if (!bookObject.coverPath) {
const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir)
// Extract and save embedded cover art
const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir)
if (extractedCoverPath) { if (extractedCoverPath) {
libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from audio file at "${extractedCoverPath}" for book "${bookObject.title}"`)
bookObject.coverPath = extractedCoverPath bookObject.coverPath = extractedCoverPath
} else if (Database.serverSettings.scannerFindCovers) { } else if (ebookFileScanData?.ebookCoverPath) {
const authorName = bookMetadata.authors.join(', ') extractedCoverPath = await CoverManager.saveEbookCoverArt(ebookFileScanData, libraryItemObj.id, libraryItemDir)
bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan) if (extractedCoverPath) {
libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from ebook file at "${extractedCoverPath}" for book "${bookObject.title}"`)
bookObject.coverPath = extractedCoverPath
}
} }
} }
// If cover not found then search for cover if enabled in settings
if (!bookObject.coverPath && Database.serverSettings.scannerFindCovers) {
const authorName = bookMetadata.authors.join(', ')
bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan)
}
libraryItemObj.book = bookObject libraryItemObj.book = bookObject
const libraryItem = await Database.libraryItemModel.create(libraryItemObj, { const libraryItem = await Database.libraryItemModel.create(libraryItemObj, {
include: { include: {
@ -570,13 +593,14 @@ class BookScanner {
/** /**
* *
* @param {import('../models/Book').AudioFileObject[]} audioFiles * @param {import('../models/Book').AudioFileObject[]} audioFiles
* @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
* @param {import('./LibraryItemScanData')} libraryItemData * @param {import('./LibraryItemScanData')} libraryItemData
* @param {LibraryScan} libraryScan * @param {LibraryScan} libraryScan
* @param {import('../models/Library').LibrarySettingsObject} librarySettings * @param {import('../models/Library').LibrarySettingsObject} librarySettings
* @param {string} [existingLibraryItemId] * @param {string} [existingLibraryItemId]
* @returns {Promise<BookMetadataObject>} * @returns {Promise<BookMetadataObject>}
*/ */
async getBookMetadataFromScanData(audioFiles, libraryItemData, libraryScan, librarySettings, existingLibraryItemId = null) { async getBookMetadataFromScanData(audioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings, existingLibraryItemId = null) {
// First set book metadata from folder/file names // First set book metadata from folder/file names
const bookMetadata = { const bookMetadata = {
title: libraryItemData.mediaMetadata.title, // required title: libraryItemData.mediaMetadata.title, // required
@ -599,7 +623,7 @@ class BookScanner {
coverPath: undefined coverPath: undefined
} }
const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId)
const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`) libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`)
for (const metadataSource of metadataPrecedence) { for (const metadataSource of metadataPrecedence) {
@ -627,13 +651,15 @@ class BookScanner {
* *
* @param {Object} bookMetadata * @param {Object} bookMetadata
* @param {import('../models/Book').AudioFileObject[]} audioFiles * @param {import('../models/Book').AudioFileObject[]} audioFiles
* @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
* @param {import('./LibraryItemScanData')} libraryItemData * @param {import('./LibraryItemScanData')} libraryItemData
* @param {LibraryScan} libraryScan * @param {LibraryScan} libraryScan
* @param {string} existingLibraryItemId * @param {string} existingLibraryItemId
*/ */
constructor(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) { constructor(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId) {
this.bookMetadata = bookMetadata this.bookMetadata = bookMetadata
this.audioFiles = audioFiles this.audioFiles = audioFiles
this.ebookFileScanData = ebookFileScanData
this.libraryItemData = libraryItemData this.libraryItemData = libraryItemData
this.libraryScan = libraryScan this.libraryScan = libraryScan
this.existingLibraryItemId = existingLibraryItemId this.existingLibraryItemId = existingLibraryItemId
@ -647,13 +673,42 @@ class BookScanner {
} }
/** /**
* Metadata from audio file meta tags * Metadata from audio file meta tags OR metadata from ebook file
*/ */
audioMetatags() { audioMetatags() {
if (!this.audioFiles.length) return if (this.audioFiles.length) {
// Modifies bookMetadata with metadata mapped from audio file meta tags // Modifies bookMetadata with metadata mapped from audio file meta tags
const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title
AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan) AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan)
} else if (this.ebookFileScanData) {
const ebookMetdataObject = this.ebookFileScanData.metadata
for (const key in ebookMetdataObject) {
if (key === 'tags') {
if (ebookMetdataObject.tags.length) {
this.bookMetadata.tags = ebookMetdataObject.tags
}
} else if (key === 'genres') {
if (ebookMetdataObject.genres.length) {
this.bookMetadata.genres = ebookMetdataObject.genres
}
} else if (key === 'authors') {
if (ebookMetdataObject.authors?.length) {
this.bookMetadata.authors = ebookMetdataObject.authors
}
} else if (key === 'narrators') {
if (ebookMetdataObject.narrators?.length) {
this.bookMetadata.narrators = ebookMetdataObject.narrators
}
} else if (key === 'series') {
if (ebookMetdataObject.series?.length) {
this.bookMetadata.series = ebookMetdataObject.series
}
} else if (ebookMetdataObject[key] && key !== 'sequence') {
this.bookMetadata[key] = ebookMetdataObject[key]
}
}
}
return null
} }
/** /**

View File

@ -2,7 +2,6 @@ const uuidv4 = require("uuid").v4
const Path = require('path') const Path = require('path')
const { LogLevel } = require('../utils/constants') const { LogLevel } = require('../utils/constants')
const { getTitleIgnorePrefix } = require('../utils/index') const { getTitleIgnorePrefix } = require('../utils/index')
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
const AudioFileScanner = require('./AudioFileScanner') const AudioFileScanner = require('./AudioFileScanner')
const Database = require('../Database') const Database = require('../Database')
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')

View File

@ -1,6 +1,7 @@
const axios = require('axios') const axios = require('axios')
const Path = require('path') const Path = require('path')
const ssrfFilter = require('ssrf-req-filter') const ssrfFilter = require('ssrf-req-filter')
const exec = require('child_process').exec
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const rra = require('../libs/recursiveReaddirAsync') const rra = require('../libs/recursiveReaddirAsync')
const Logger = require('../Logger') const Logger = require('../Logger')
@ -378,3 +379,65 @@ module.exports.isWritable = async (directory) => {
} }
} }
/**
* Get Windows drives as array e.g. ["C:/", "F:/"]
*
* @returns {Promise<string[]>}
*/
module.exports.getWindowsDrives = async () => {
if (!global.isWin) {
return []
}
return new Promise((resolve, reject) => {
exec('wmic logicaldisk get name', async (error, stdout, stderr) => {
if (error) {
reject(error)
return
}
let drives = stdout?.split(/\r?\n/).map(line => line.trim()).filter(line => line).slice(1)
const validDrives = []
for (const drive of drives) {
let drivepath = drive + '/'
if (await fs.pathExists(drivepath)) {
validDrives.push(drivepath)
} else {
Logger.error(`Invalid drive ${drivepath}`)
}
}
resolve(validDrives)
})
})
}
/**
* Get array of directory paths in a directory
*
* @param {string} dirPath
* @param {number} level
* @returns {Promise<{ path:string, dirname:string, level:number }[]>}
*/
module.exports.getDirectoriesInPath = async (dirPath, level) => {
try {
const paths = await fs.readdir(dirPath)
let dirs = await Promise.all(paths.map(async dirname => {
const fullPath = Path.join(dirPath, dirname)
const lstat = await fs.lstat(fullPath).catch((error) => {
Logger.debug(`Failed to lstat "${fullPath}"`, error)
return null
})
if (!lstat?.isDirectory()) return null
return {
path: this.filePathToPOSIX(fullPath),
dirname,
level
}
}))
dirs = dirs.filter(d => d)
return dirs
} catch (error) {
Logger.error('Failed to readdir', dirPath, error)
return []
}
}

View File

@ -1,4 +1,5 @@
const xml = require('../../libs/xml') const xml = require('../../libs/xml')
const escapeForXML = require('../../libs/xml/escapeForXML')
/** /**
* Generate OPML file string for podcasts in a library * Generate OPML file string for podcasts in a library
@ -12,18 +13,18 @@ module.exports.generate = (podcasts, indent = true) => {
if (!podcast.feedURL) return if (!podcast.feedURL) return
const feedAttributes = { const feedAttributes = {
type: 'rss', type: 'rss',
text: podcast.title, text: escapeForXML(podcast.title),
title: podcast.title, title: escapeForXML(podcast.title),
xmlUrl: podcast.feedURL xmlUrl: escapeForXML(podcast.feedURL)
} }
if (podcast.description) { if (podcast.description) {
feedAttributes.description = podcast.description feedAttributes.description = escapeForXML(podcast.description)
} }
if (podcast.itunesPageUrl) { if (podcast.itunesPageUrl) {
feedAttributes.htmlUrl = podcast.itunesPageUrl feedAttributes.htmlUrl = escapeForXML(podcast.itunesPageUrl)
} }
if (podcast.language) { if (podcast.language) {
feedAttributes.language = podcast.language feedAttributes.language = escapeForXML(podcast.language)
} }
bodyItems.push({ bodyItems.push({
outline: { outline: {

View File

@ -0,0 +1,42 @@
const parseEpubMetadata = require('./parseEpubMetadata')
/**
* @typedef EBookFileScanData
* @property {string} path
* @property {string} ebookFormat
* @property {string} ebookCoverPath internal image path
* @property {import('../../scanner/BookScanner').BookMetadataObject} metadata
*/
/**
* Parse metadata from ebook file
*
* @param {import('../../models/Book').EBookFileObject} ebookFile
* @returns {Promise<EBookFileScanData>}
*/
async function parse(ebookFile) {
if (!ebookFile) return null
if (ebookFile.ebookFormat === 'epub') {
return parseEpubMetadata.parse(ebookFile.metadata.path)
}
return null
}
module.exports.parse = parse
/**
* Extract cover from ebook file
*
* @param {EBookFileScanData} ebookFileScanData
* @param {string} outputCoverPath
* @returns {Promise<boolean>}
*/
async function extractCoverImage(ebookFileScanData, outputCoverPath) {
if (!ebookFileScanData?.ebookCoverPath) return false
if (ebookFileScanData.ebookFormat === 'epub') {
return parseEpubMetadata.extractCoverImage(ebookFileScanData.path, ebookFileScanData.ebookCoverPath, outputCoverPath)
}
return false
}
module.exports.extractCoverImage = extractCoverImage

View File

@ -0,0 +1,109 @@
const Path = require('path')
const Logger = require('../../Logger')
const StreamZip = require('../../libs/nodeStreamZip')
const parseOpfMetadata = require('./parseOpfMetadata')
const { xmlToJSON } = require('../index')
/**
* Extract file from epub and return string content
*
* @param {string} epubPath
* @param {string} filepath
* @returns {Promise<string>}
*/
async function extractFileFromEpub(epubPath, filepath) {
const zip = new StreamZip.async({ file: epubPath })
const data = await zip.entryData(filepath).catch((error) => {
Logger.error(`[parseEpubMetadata] Failed to extract ${filepath} from epub at "${epubPath}"`, error)
})
const filedata = data?.toString('utf8')
await zip.close()
return filedata
}
/**
* Extract an XML file from epub and return JSON
*
* @param {string} epubPath
* @param {string} xmlFilepath
* @returns {Promise<Object>}
*/
async function extractXmlToJson(epubPath, xmlFilepath) {
const filedata = await extractFileFromEpub(epubPath, xmlFilepath)
if (!filedata) return null
return xmlToJSON(filedata)
}
/**
* Extract cover image from epub return true if success
*
* @param {string} epubPath
* @param {string} epubImageFilepath
* @param {string} outputCoverPath
* @returns {Promise<boolean>}
*/
async function extractCoverImage(epubPath, epubImageFilepath, outputCoverPath) {
const zip = new StreamZip.async({ file: epubPath })
const success = await zip.extract(epubImageFilepath, outputCoverPath).then(() => true).catch((error) => {
Logger.error(`[parseEpubMetadata] Failed to extract image ${epubImageFilepath} from epub at "${epubPath}"`, error)
return false
})
await zip.close()
return success
}
module.exports.extractCoverImage = extractCoverImage
/**
* Parse metadata from epub
*
* @param {string} epubPath
* @returns {Promise<import('./parseEbookMetadata').EBookFileScanData>}
*/
async function parse(epubPath) {
Logger.debug(`Parsing metadata from epub at "${epubPath}"`)
// Entrypoint of the epub that contains the filepath to the package document (opf file)
const containerJson = await extractXmlToJson(epubPath, 'META-INF/container.xml')
// Get package document opf filepath from container.xml
const packageDocPath = containerJson.container?.rootfiles?.[0]?.rootfile?.[0]?.$?.['full-path']
if (!packageDocPath) {
Logger.error(`Failed to get package doc path in Container.xml`, JSON.stringify(containerJson, null, 2))
return null
}
// Extract package document to JSON
const packageJson = await extractXmlToJson(epubPath, packageDocPath)
if (!packageJson) {
return null
}
// Parse metadata from package document opf file
const opfMetadata = parseOpfMetadata.parseOpfMetadataJson(packageJson)
if (!opfMetadata) {
Logger.error(`Unable to parse metadata in package doc with json`, JSON.stringify(packageJson, null, 2))
return null
}
const payload = {
path: epubPath,
ebookFormat: 'epub',
metadata: opfMetadata
}
// Attempt to find filepath to cover image
const manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find(item => item.$?.['media-type']?.startsWith('image/'))
let coverImagePath = manifestFirstImage?.$?.href
if (coverImagePath) {
const packageDirname = Path.dirname(packageDocPath)
payload.ebookCoverPath = Path.posix.join(packageDirname, coverImagePath)
} else {
Logger.warn(`Cover image not found in manifest for epub at "${epubPath}"`)
}
return payload
}
module.exports.parse = parse

View File

@ -103,15 +103,24 @@ function fetchSeries(metadataMeta) {
if (!metadataMeta) return [] if (!metadataMeta) return []
const result = [] const result = []
for (let i = 0; i < metadataMeta.length; i++) { for (let i = 0; i < metadataMeta.length; i++) {
if (metadataMeta[i].$?.name === "calibre:series" && metadataMeta[i].$.content?.trim()) { if (metadataMeta[i].$?.name === 'calibre:series' && metadataMeta[i].$.content?.trim()) {
const name = metadataMeta[i].$.content.trim() const name = metadataMeta[i].$.content.trim()
let sequence = null let sequence = null
if (metadataMeta[i + 1]?.$?.name === "calibre:series_index" && metadataMeta[i + 1].$?.content?.trim()) { if (metadataMeta[i + 1]?.$?.name === 'calibre:series_index' && metadataMeta[i + 1].$?.content?.trim()) {
sequence = metadataMeta[i + 1].$.content.trim() sequence = metadataMeta[i + 1].$.content.trim()
} }
result.push({ name, sequence }) result.push({ name, sequence })
} }
} }
// If one series was found with no series_index then check if any series_index meta can be found
// this is to support when calibre:series_index is not directly underneath calibre:series
if (result.length === 1 && !result[0].sequence) {
const seriesIndexMeta = metadataMeta.find(m => m.$?.name === 'calibre:series_index' && m.$.content?.trim())
if (seriesIndexMeta) {
result[0].sequence = seriesIndexMeta.$.content.trim()
}
}
return result return result
} }
@ -136,11 +145,7 @@ function stripPrefix(str) {
return str.split(':').pop() return str.split(':').pop()
} }
module.exports.parseOpfMetadataXML = async (xml) => { module.exports.parseOpfMetadataJson = (json) => {
const json = await xmlToJSON(xml)
if (!json) return null
// Handle <package ...> or with prefix <ns0:package ...> // Handle <package ...> or with prefix <ns0:package ...>
const packageKey = Object.keys(json).find(key => stripPrefix(key) === 'package') const packageKey = Object.keys(json).find(key => stripPrefix(key) === 'package')
if (!packageKey) return null if (!packageKey) return null
@ -167,7 +172,7 @@ module.exports.parseOpfMetadataXML = async (xml) => {
const creators = parseCreators(metadata) const creators = parseCreators(metadata)
const authors = (fetchCreators(creators, 'aut') || []).map(au => au?.trim()).filter(au => au) const authors = (fetchCreators(creators, 'aut') || []).map(au => au?.trim()).filter(au => au)
const narrators = (fetchNarrators(creators, metadata) || []).map(nrt => nrt?.trim()).filter(nrt => nrt) const narrators = (fetchNarrators(creators, metadata) || []).map(nrt => nrt?.trim()).filter(nrt => nrt)
const data = { return {
title: fetchTitle(metadata), title: fetchTitle(metadata),
subtitle: fetchSubtitle(metadata), subtitle: fetchSubtitle(metadata),
authors, authors,
@ -182,5 +187,10 @@ module.exports.parseOpfMetadataXML = async (xml) => {
series: fetchSeries(metadataMeta), series: fetchSeries(metadataMeta),
tags: fetchTags(metadata) tags: fetchTags(metadata)
} }
return data }
module.exports.parseOpfMetadataXML = async (xml) => {
const json = await xmlToJSON(xml)
if (!json) return null
return this.parseOpfMetadataJson(json)
} }

View File

@ -233,7 +233,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
method: 'GET', method: 'GET',
timeout: 12000, timeout: 12000,
responseType: 'arraybuffer', responseType: 'arraybuffer',
headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml' }, headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8' },
httpAgent: ssrfFilter(feedUrl), httpAgent: ssrfFilter(feedUrl),
httpsAgent: ssrfFilter(feedUrl) httpsAgent: ssrfFilter(feedUrl)
}).then(async (data) => { }).then(async (data) => {

View File

@ -110,4 +110,21 @@ describe('parseOpfMetadata - test series', async () => {
{ "name": "Serie 1", "sequence": null } { "name": "Serie 1", "sequence": null }
]) ])
}) })
it('test series and series index not directly underneath', async () => {
const opf = `
<?xml version='1.0' encoding='UTF-8'?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid">
<metadata>
<meta name="calibre:series" content="Serie 1"/>
<meta name="calibre:title_sort" content="Test Title"/>
<meta name="calibre:series_index" content="1"/>
</metadata>
</package>
`
const parsedOpf = await parseOpfMetadataXML(opf)
expect(parsedOpf.series).to.deep.equal([
{ "name": "Serie 1", "sequence": "1" }
])
})
}) })