mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-05-31 04:05:40 -04:00
Merge branch 'advplyr:master' into dewyer/add-custom-metadata-provider
This commit is contained in:
commit
6ef4944d89
2
.github/ISSUE_TEMPLATE/bug.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@ -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."
|
||||||
|
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -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
|
||||||
|
@ -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"]
|
||||||
|
@ -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 || ' ' }}</p>
|
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</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
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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() {
|
@ -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: {
|
||||||
|
@ -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) => {
|
||||||
|
@ -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
|
||||||
|
@ -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() {
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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 l’utilisateur {0}",
|
"ButtonUserEdit": "Modifier l’utilisateur {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 l’auteur.",
|
||||||
"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 à l’exception 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 l’auteur 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 à l’Utilisateur",
|
"LabelBackToUser": "Retour à l’utilisateur",
|
||||||
"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 d’informations",
|
||||||
"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 l’image de couverture",
|
"LabelCoverImageURL": "URL vers l’image 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 l’appareil",
|
"LabelDeviceInfo": "Détail de l’appareil",
|
||||||
"LabelDeviceIsAvailableTo": "Device is available to...",
|
"LabelDeviceIsAvailableTo": "L’appareil 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 l’utilisateur",
|
"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 d’un livre numérique",
|
"LabelHasEbook": "Dispose d’un livre numérique",
|
||||||
"LabelHasSupplementaryEbook": "Dispose d’un livre numérique supplémentaire",
|
"LabelHasSupplementaryEbook": "Dispose d’un 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 l’image à 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 d’URI 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. L’utilisation d’un astérisque (<code>*</code>) comme seule entrée autorise n’importe quel URI.",
|
||||||
"LabelMore": "Plus",
|
"LabelMore": "Plus",
|
||||||
"LabelMoreInfo": "Plus d’info",
|
"LabelMoreInfo": "Plus d’informations",
|
||||||
"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) d’Apprise",
|
"LabelNotificationAppriseURL": "URL(s) d’Apprise",
|
||||||
"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 d’attente 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 d’attente 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 d’Episodes",
|
"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 l’indexation",
|
"LabelRSSFeedPreventIndexing": "Empêcher l’indexation",
|
||||||
"LabelRSSFeedSlug": "Identificateur d’adresse 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": "L’activation de ce paramètre ignorera les fichiers “ ebook ”, à moins qu’ils 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, l’analyseur tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps d’analyse.",
|
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, l’analyseur tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps d’analyse.",
|
||||||
"LabelSettingsHideSingleBookSeries": "Masquer les séries de livres uniques",
|
"LabelSettingsHideSingleBookSeries": "Masquer les séries de livres uniques",
|
||||||
@ -447,13 +447,13 @@
|
|||||||
"LabelSettingsHomePageBookshelfView": "La page d’accueil utilise la vue étagère",
|
"LabelSettingsHomePageBookshelfView": "La page d’accueil utilise la vue étagère",
|
||||||
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
|
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
|
||||||
"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>c’est-à-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 l’article lors d’une 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 l’article lors d’une 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": "c’est-à-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 d’heure",
|
"LabelSettingsTimeFormat": "Format d’heure",
|
||||||
"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": "d’affilé(s)",
|
"LabelStatsInARow": "d’affilé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 Aujourd’hui",
|
"LabelTimeListenedToday": "Nombres d’écoutes aujourd’hui",
|
||||||
"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, l’image de couverture et les chapitres.",
|
"LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, l’image 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 lorsqu’une correspondance est trouvée",
|
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une 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, l’auteur 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 d’utilisateur",
|
"LabelUsername": "Nom d’utilisateur",
|
||||||
"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 d’attente",
|
"MessageAddToPlayerQueue": "Ajouter en file d’attente",
|
||||||
"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 />l’URL de l’API 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>L’URL de l’API 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 n’incluent 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 n’incluent pas les fichiers de votre bibliothèque.",
|
||||||
"MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer l’option suivante pour autoriser la recherche par correspondance à écraser les données existantes.",
|
"MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer l’option suivante pour autoriser la recherche par correspondance à écraser les données existantes.",
|
||||||
"MessageBookshelfNoCollections": "Vous n’avez pas encore de collections",
|
"MessageBookshelfNoCollections": "Vous n’avez 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 n’est ouvert",
|
"MessageBookshelfNoRSSFeeds": "Aucun flux RSS n’est ouvert",
|
||||||
"MessageBookshelfNoSeries": "Vous n’avez aucune série",
|
"MessageBookshelfNoSeries": "Vous n’avez 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 ! L’intégration rapide ne sauvegardera pas vos fichiers audio. Assurez-vous d’avoir 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} » à l’appareil « {2} »?",
|
"MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer le livre numérique {0} « {1} » à l’appareil « {2} »?",
|
||||||
"MessageDownloadingEpisode": "Téléchargement de l’épisode",
|
"MessageDownloadingEpisode": "Téléchargement de l’épisode",
|
||||||
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l’ordre correct",
|
"MessageDragFilesIntoTrackOrder": "Faites glisser les fichiers dans l’ordre 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": "l’URL du flux sera {0}",
|
"MessageFeedURLWillBe": "L’URL 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 s’ils é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 s’ils é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 l’utilisateur « {0} » ?",
|
"MessageRemoveUserWarning": "Êtes-vous sûr de vouloir supprimer définitivement l’utilisateur « {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 l’utilisateur « root » peut utiliser un mot de passe vide",
|
"NoteChangeRootPassword": "seul l’utilisateur « root » peut utiliser un mot de passe vide",
|
||||||
"NoteChapterEditorTimes": "Information : l’horodatage 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 : l’horodatage 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 n’est 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 n’est 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 l’image",
|
"ToastAuthorImageRemoveFailed": "Échec de la suppression de l’image",
|
||||||
@ -750,4 +750,4 @@
|
|||||||
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
|
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
|
||||||
"ToastUserDeleteFailed": "Échec de la suppression de l’utilisateur",
|
"ToastUserDeleteFailed": "Échec de la suppression de l’utilisateur",
|
||||||
"ToastUserDeleteSuccess": "Utilisateur supprimé"
|
"ToastUserDeleteSuccess": "Utilisateur supprimé"
|
||||||
}
|
}
|
||||||
|
@ -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/)
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
})
|
})
|
||||||
|
@ -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`)
|
||||||
|
@ -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
|
||||||
//
|
//
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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')
|
||||||
|
@ -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 []
|
||||||
|
}
|
||||||
|
}
|
@ -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: {
|
||||||
|
42
server/utils/parsers/parseEbookMetadata.js
Normal file
42
server/utils/parsers/parseEbookMetadata.js
Normal 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
|
109
server/utils/parsers/parseEpubMetadata.js
Normal file
109
server/utils/parsers/parseEpubMetadata.js
Normal 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
|
@ -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)
|
||||||
}
|
}
|
@ -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) => {
|
||||||
|
@ -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" }
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
Loading…
x
Reference in New Issue
Block a user