mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
42 Commits
fix_set_to
...
progress_u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2054accdc9 | ||
|
|
7d8b857c77 | ||
|
|
0107cb4782 | ||
|
|
f273eee807 | ||
|
|
4af21b079a | ||
|
|
c9eaf2db2d | ||
|
|
a5fb0d9cdb | ||
|
|
53c80d9798 | ||
|
|
832165716b | ||
|
|
d9f2d8bf1d | ||
|
|
a7a3a56509 | ||
|
|
4082fadf90 | ||
|
|
93160b83bf | ||
|
|
472240f994 | ||
|
|
c3f0fb8e5e | ||
|
|
b156ebeb9f | ||
|
|
e4c775c847 | ||
|
|
45e8e72759 | ||
|
|
0ae7340889 | ||
|
|
8c38987d92 | ||
|
|
878f0787ba | ||
|
|
880d85eaef | ||
|
|
f7aaebc1de | ||
|
|
d96ebbe82d | ||
|
|
70d67156f0 | ||
|
|
f293b317be | ||
|
|
1f23794f88 | ||
|
|
e6bfd118f6 | ||
|
|
1166400ab1 | ||
|
|
55f0ac871b | ||
|
|
3584f6e24f | ||
|
|
23bf2594c8 | ||
|
|
8fb460ce05 | ||
|
|
8c4bbfd6a2 | ||
|
|
742961e0b8 | ||
|
|
e9a705587a | ||
|
|
bf6d81b333 | ||
|
|
9c44fc0d01 | ||
|
|
5017e7ce9e | ||
|
|
de25763a74 | ||
|
|
a894ceb9cf | ||
|
|
387e58a714 |
@@ -116,7 +116,7 @@
|
||||
</div>
|
||||
|
||||
<div class="w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0" :style="{ bottom: streamLibraryItem ? '224px' : '65px' }">
|
||||
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
||||
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1 cursor-pointer" @click="clickChangelog">v{{ $config.version }}</p>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,17 @@
|
||||
<div class="grow" />
|
||||
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
||||
</div>
|
||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
||||
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
||||
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
||||
</div>
|
||||
<div class="grow" />
|
||||
<div v-if="book.matchConfidence" class="rounded-full px-2 py-1 text-xs whitespace-nowrap text-white" :class="book.matchConfidence > 0.95 ? 'bg-success/80' : 'bg-info/80'">{{ $strings.LabelMatchConfidence }}: {{ (book.matchConfidence * 100).toFixed(0) }}%</div>
|
||||
</div>
|
||||
|
||||
<div v-if="book.series?.length" class="flex py-1 -mx-1">
|
||||
<div v-for="(series, index) in book.series" :key="index" class="bg-white/10 rounded-full px-1 py-0.5 mx-1">
|
||||
<p class="leading-3 text-xs text-gray-400">
|
||||
|
||||
@@ -101,7 +101,8 @@
|
||||
<!-- Podcast Episode # -->
|
||||
<div cy-id="podcastEpisodeNumber" v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black/90 box-shadow-md z-10" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">
|
||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||
Episode
|
||||
<span v-if="recentEpisodeNumber">#{{ recentEpisodeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -200,6 +201,9 @@ export default {
|
||||
dateFormat() {
|
||||
return this.store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.store.getters['getServerSetting']('timeFormat')
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
},
|
||||
@@ -345,6 +349,10 @@ export default {
|
||||
if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear])
|
||||
return '\u00A0'
|
||||
}
|
||||
if (this.orderBy === 'progress') {
|
||||
if (!this.userProgressLastUpdated) return '\u00A0'
|
||||
return this.$getString('LabelLastProgressDate', [this.$formatDatetime(this.userProgressLastUpdated, this.dateFormat, this.timeFormat)])
|
||||
}
|
||||
return null
|
||||
},
|
||||
episodeProgress() {
|
||||
@@ -377,6 +385,10 @@ export default {
|
||||
let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0
|
||||
return Math.max(Math.min(1, progressPercent), 0)
|
||||
},
|
||||
userProgressLastUpdated() {
|
||||
if (!this.userProgress) return null
|
||||
return this.userProgress.lastUpdate
|
||||
},
|
||||
itemIsFinished() {
|
||||
if (this.booksInSeries) return this.seriesIsFinished
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black/5 overflow-auto focus:outline-hidden text-sm" role="menu">
|
||||
<ul v-show="showMenu" class="librarySortMenu absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black/5 overflow-auto focus:outline-hidden text-sm" role="menu">
|
||||
<template v-for="item in selectItems">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
@@ -130,6 +130,10 @@ export default {
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLibrarySortByProgress,
|
||||
value: 'progress'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
@@ -191,3 +195,9 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.librarySortMenu {
|
||||
max-height: calc(100vh - 125px);
|
||||
}
|
||||
</style>
|
||||
@@ -99,22 +99,32 @@ export default {
|
||||
return `/api/items/${this.libraryItemId}/ebook`
|
||||
},
|
||||
themeRules() {
|
||||
const isDark = this.ereaderSettings.theme === 'dark'
|
||||
const fontColor = isDark ? '#fff' : '#000'
|
||||
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
|
||||
const theme = this.ereaderSettings.theme
|
||||
const isDark = theme === 'dark'
|
||||
const isSepia = theme === 'sepia'
|
||||
|
||||
const fontColor = isDark
|
||||
? '#fff'
|
||||
: isSepia
|
||||
? '#5b4636'
|
||||
: '#000'
|
||||
|
||||
const backgroundColor = isDark
|
||||
? 'rgb(35 35 35)'
|
||||
: isSepia
|
||||
? 'rgb(244, 236, 216)'
|
||||
: 'rgb(255, 255, 255)'
|
||||
|
||||
const lineSpacing = this.ereaderSettings.lineSpacing / 100
|
||||
|
||||
const fontScale = this.ereaderSettings.fontScale / 100
|
||||
|
||||
const textStroke = this.ereaderSettings.textStroke / 100
|
||||
const fontScale = this.ereaderSettings.fontScale / 100
|
||||
const textStroke = this.ereaderSettings.textStroke / 100
|
||||
|
||||
return {
|
||||
'*': {
|
||||
color: `${fontColor}!important`,
|
||||
'background-color': `${backgroundColor}!important`,
|
||||
'line-height': lineSpacing * fontScale + 'rem!important',
|
||||
'-webkit-text-stroke': textStroke + 'px ' + fontColor + '!important'
|
||||
'line-height': `${lineSpacing * fontScale}rem!important`,
|
||||
'-webkit-text-stroke': `${textStroke}px ${fontColor}!important`
|
||||
},
|
||||
a: {
|
||||
color: `${fontColor}!important`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': !!streamLibraryItem }">
|
||||
<div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black data-[theme=sepia]:bg-[rgb(244,236,216)] data-[theme=sepia]:text-[#5b4636]" :class="{ 'reader-player-open': !!streamLibraryItem }">
|
||||
<div class="absolute top-4 left-4 z-20 flex items-center">
|
||||
<button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100">
|
||||
<span class="material-symbols text-2xl">menu</span>
|
||||
@@ -27,7 +27,12 @@
|
||||
|
||||
<!-- TOC side nav -->
|
||||
<div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
|
||||
<div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent>
|
||||
<div
|
||||
v-if="isEpub"
|
||||
class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black group-data-[theme=sepia]:bg-[rgb(244,236,216)] group-data-[theme=sepia]:text-[#5b4636]"
|
||||
:class="tocOpen ? 'translate-x-0' : '-translate-x-96'"
|
||||
@click.stop.prevent
|
||||
>
|
||||
<div class="flex flex-col p-4 h-full">
|
||||
<div class="flex items-center mb-2">
|
||||
<button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100">
|
||||
@@ -37,7 +42,7 @@
|
||||
<p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p>
|
||||
</div>
|
||||
<form @submit.prevent="searchBook" @click.stop.prevent>
|
||||
<ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" />
|
||||
<ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" custom-input-class="text-inherit !bg-inherit" class="h-8 w-full text-sm flex mb-2" />
|
||||
</form>
|
||||
|
||||
<div class="overflow-y-auto">
|
||||
@@ -181,6 +186,10 @@ export default {
|
||||
text: this.$strings.LabelThemeDark,
|
||||
value: 'dark'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelThemeSepia,
|
||||
value: 'sepia'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelThemeLight,
|
||||
value: 'light'
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.26.1",
|
||||
"version": "2.26.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.26.0",
|
||||
"version": "2.26.3",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.26.1",
|
||||
"version": "2.26.3",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -6,80 +6,82 @@
|
||||
</div>
|
||||
|
||||
<div v-if="listeningSessions.length" class="block max-w-full relative">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary/40">
|
||||
<th class="w-6 min-w-6 text-left hidden md:table-cell h-11">
|
||||
<ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" />
|
||||
</th>
|
||||
<th v-if="numSelected" class="grow text-left" :colspan="7">
|
||||
<div class="flex items-center">
|
||||
<p>{{ $getString('MessageSelected', [numSelected]) }}</p>
|
||||
<div class="grow" />
|
||||
<ui-btn small color="bg-error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="grow sm:grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
|
||||
<th v-if="!numSelected" class="w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer" @click.stop="sortColumn('playMethod')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelPlayMethod }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th v-if="!numSelected" class="w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary/40">
|
||||
<th class="w-6 min-w-6 text-left hidden md:table-cell h-11">
|
||||
<ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" />
|
||||
</th>
|
||||
<th v-if="numSelected" class="grow text-left" :colspan="7">
|
||||
<div class="flex items-center">
|
||||
<p>{{ $getString('MessageSelected', [numSelected]) }}</p>
|
||||
<div class="grow" />
|
||||
<ui-btn small color="bg-error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="grow sm:grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
|
||||
<th v-if="!numSelected" class="w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer" @click.stop="sortColumn('playMethod')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelPlayMethod }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th v-if="!numSelected" class="w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
<tr v-for="session in listeningSessions" :key="session.id" :class="{ selected: session.selected }" class="cursor-pointer" @click="clickSessionRow(session)">
|
||||
<td class="hidden md:table-cell py-1 max-w-6 relative">
|
||||
<ui-checkbox v-model="session.selected" small checkbox-bg="bg" />
|
||||
<!-- overlay of the checkbox so that the entire box is clickable -->
|
||||
<div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" />
|
||||
</td>
|
||||
<td class="py-1 grow sm:grow-0 sm:w-48 sm:max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell w-20 min-w-20">
|
||||
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell w-26 min-w-26">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell max-w-32 min-w-32">
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<tr v-for="session in listeningSessions" :key="session.id" :class="{ selected: session.selected }" class="cursor-pointer" @click="clickSessionRow(session)">
|
||||
<td class="hidden md:table-cell py-1 max-w-6 relative">
|
||||
<ui-checkbox v-model="session.selected" small checkbox-bg="bg" />
|
||||
<!-- overlay of the checkbox so that the entire box is clickable -->
|
||||
<div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" />
|
||||
</td>
|
||||
<td class="py-1 grow sm:grow-0 sm:w-48 sm:max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell w-20 min-w-20">
|
||||
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell w-26 min-w-26">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell max-w-32 min-w-32">
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!-- table bottom options -->
|
||||
<div class="flex items-center my-2">
|
||||
<div class="grow" />
|
||||
|
||||
@@ -19,39 +19,41 @@
|
||||
<div class="py-2">
|
||||
<h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderListeningSessions }}</h1>
|
||||
<div v-if="listeningSessions.length">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary/40">
|
||||
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
|
||||
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
|
||||
<th class="grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||
</tr>
|
||||
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
||||
<td class="py-1 max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell min-w-32 max-w-32">
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary/40">
|
||||
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
|
||||
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
|
||||
<th class="grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||
</tr>
|
||||
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
||||
<td class="py-1 max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell min-w-32 max-w-32">
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center justify-end py-1">
|
||||
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||
<p class="text-sm mx-1">{{ $getString('LabelPaginationPageXOfY', [currentPage + 1, numPages]) }}</p>
|
||||
|
||||
@@ -92,7 +92,7 @@ export const actions = {
|
||||
if (state.settings.orderBy == 'media.duration') {
|
||||
settingsUpdate.orderBy = 'media.numTracks'
|
||||
}
|
||||
if (state.settings.orderBy == 'media.metadata.publishedYear') {
|
||||
if (state.settings.orderBy == 'media.metadata.publishedYear' || state.settings.orderBy == 'progress') {
|
||||
settingsUpdate.orderBy = 'media.metadata.title'
|
||||
}
|
||||
const invalidFilters = ['series', 'authors', 'narrators', 'publishers', 'publishedDecades', 'languages', 'progress', 'issues', 'ebooks', 'abridged']
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"ButtonAdd": "Přidat",
|
||||
"ButtonAddApiKey": "Přidat API klíč",
|
||||
"ButtonAddChapters": "Přidat kapitoly",
|
||||
"ButtonAddDevice": "Přidat zařízení",
|
||||
"ButtonAddLibrary": "Přidat knihovnu",
|
||||
@@ -20,6 +21,7 @@
|
||||
"ButtonChooseAFolder": "Vybrat složku",
|
||||
"ButtonChooseFiles": "Vybrat soubory",
|
||||
"ButtonClearFilter": "Vymazat filtr",
|
||||
"ButtonClose": "Zavřít",
|
||||
"ButtonCloseFeed": "Zavřít kanál",
|
||||
"ButtonCloseSession": "Zavřít otevřenou relaci",
|
||||
"ButtonCollections": "Kolekce",
|
||||
@@ -119,6 +121,7 @@
|
||||
"HeaderAccount": "Účet",
|
||||
"HeaderAddCustomMetadataProvider": "Přidat vlastního poskytovatele metadat",
|
||||
"HeaderAdvanced": "Pokročilé",
|
||||
"HeaderApiKeys": "API klíče",
|
||||
"HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise",
|
||||
"HeaderAudioTracks": "Zvukové stopy",
|
||||
"HeaderAudiobookTools": "Nástroje pro správu souborů audioknih",
|
||||
@@ -162,6 +165,7 @@
|
||||
"HeaderMetadataOrderOfPrecedence": "Pořadí priorit metadat",
|
||||
"HeaderMetadataToEmbed": "Metadata k vložení",
|
||||
"HeaderNewAccount": "Nový účet",
|
||||
"HeaderNewApiKey": "Nový API klíč",
|
||||
"HeaderNewLibrary": "Nová knihovna",
|
||||
"HeaderNotificationCreate": "Vytvořit notifikaci",
|
||||
"HeaderNotificationUpdate": "Aktualizovat notifikaci",
|
||||
@@ -206,6 +210,7 @@
|
||||
"HeaderTableOfContents": "Obsah",
|
||||
"HeaderTools": "Nástroje",
|
||||
"HeaderUpdateAccount": "Aktualizovat účet",
|
||||
"HeaderUpdateApiKey": "Aktualizovat API klíč",
|
||||
"HeaderUpdateAuthor": "Aktualizovat autora",
|
||||
"HeaderUpdateDetails": "Aktualizovat podrobnosti",
|
||||
"HeaderUpdateLibrary": "Aktualizovat knihovnu",
|
||||
@@ -235,6 +240,10 @@
|
||||
"LabelAllUsersExcludingGuests": "Všichni uživatelé kromě hostů",
|
||||
"LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů",
|
||||
"LabelAlreadyInYourLibrary": "Již ve vaší knihovně",
|
||||
"LabelApiKeyCreated": "API klíč \"{0}\" byl úspěšně vytvořen.",
|
||||
"LabelApiKeyCreatedDescription": "Zkopírujte si API klíč nyní, později již nebude možné jej zobrazit.",
|
||||
"LabelApiKeyUser": "Vydávat se za uživatele",
|
||||
"LabelApiKeyUserDescription": "Tento API klíč bude mít stejná oprávnění jako uživatel za něhož vystupuje. V protokolech to bude vypadat jako kdyby požadavky vytvářel přímo daný uživatel.",
|
||||
"LabelApiToken": "API Token",
|
||||
"LabelAppend": "Připojit",
|
||||
"LabelAudioBitrate": "Bitový tok zvuku (např. 128k)",
|
||||
@@ -346,6 +355,10 @@
|
||||
"LabelExample": "Příklad",
|
||||
"LabelExpandSeries": "Rozbalit série",
|
||||
"LabelExpandSubSeries": "Rozbalit podsérie",
|
||||
"LabelExpired": "Expirovaný",
|
||||
"LabelExpiresAt": "Expiruje v",
|
||||
"LabelExpiresInSeconds": "Expiruje za (sekundy)",
|
||||
"LabelExpiresNever": "Nikdy",
|
||||
"LabelExplicit": "Explicitně",
|
||||
"LabelExplicitChecked": "Explicitní (zaškrtnuto)",
|
||||
"LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)",
|
||||
@@ -455,6 +468,7 @@
|
||||
"LabelNewestEpisodes": "Nejnovější epizody",
|
||||
"LabelNextBackupDate": "Datum příští zálohy",
|
||||
"LabelNextScheduledRun": "Další naplánované spuštění",
|
||||
"LabelNoApiKeys": "Žádné API klíče",
|
||||
"LabelNoCustomMetadataProviders": "Žádní vlastní poskytovatelé metadat",
|
||||
"LabelNoEpisodesSelected": "Nebyly vybrány žádné epizody",
|
||||
"LabelNotFinished": "Nedokončeno",
|
||||
@@ -544,6 +558,7 @@
|
||||
"LabelSelectAll": "Vybrat vše",
|
||||
"LabelSelectAllEpisodes": "Vybrat všechny epizody",
|
||||
"LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují",
|
||||
"LabelSelectUser": "Vybrat uživatele",
|
||||
"LabelSelectUsers": "Vybrat uživatele",
|
||||
"LabelSendEbookToDevice": "Odeslat e-knihu do...",
|
||||
"LabelSequence": "Sekvence",
|
||||
@@ -708,7 +723,9 @@
|
||||
"MessageAddToPlayerQueue": "Přidat do fronty přehrávače",
|
||||
"MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nebo API, které bude zpracovávat stejné požadavky. <br />Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese <code>http://192.168.1.1:8337</code> pak byste měli zadat <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "Ujistěte se, že používáte ASIN ze správného regionu Audible a ne z Amazonu.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "Zastaralé API tokeny budou v budoucnu odstraněny. Použijte místo nich <a href=\"/config/api-keys\">API klíče</a>.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Po uložení restartujte server, aby se změny OIDC použily.",
|
||||
"MessageAuthenticationSecurityMessage": "Bezpečnost autentizace byla vylepšena. Všichni uživatelé se musí znovu přihlásit.",
|
||||
"MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v <code>/metadata/items</code> a <code>/metadata/authors</code>. Zálohy <strong>ne</strong> zahrnují všechny soubory uložené ve složkách knihovny.",
|
||||
"MessageBackupsLocationEditNote": "Poznámka: Změna umístění záloh nepřesune ani nezmění existující zálohy",
|
||||
"MessageBackupsLocationNoEditNote": "Poznámka: Umístění záloh je nastavené z proměnných prostředí a nelze zde změnit.",
|
||||
@@ -730,6 +747,7 @@
|
||||
"MessageChaptersNotFound": "Kapitoly nenalezeny",
|
||||
"MessageCheckingCron": "Kontrola cronu...",
|
||||
"MessageConfirmCloseFeed": "Opravdu chcete zavřít tento kanál?",
|
||||
"MessageConfirmDeleteApiKey": "Opravdu chcete vymazat API klíč \"{0}\"?",
|
||||
"MessageConfirmDeleteBackup": "Opravdu chcete smazat zálohu pro {0}?",
|
||||
"MessageConfirmDeleteDevice": "Opravdu chcete vymazat zařízení e-reader \"{0}\"?",
|
||||
"MessageConfirmDeleteFile": "Tento krok smaže soubor ze souborového systému. Jsi si jisti?",
|
||||
@@ -1001,6 +1019,8 @@
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Fronta stahování epizod je prázdná",
|
||||
"ToastEpisodeUpdateSuccess": "{0} epizod aktualizováno",
|
||||
"ToastErrorCannotShare": "Na tomto zařízení nelze nativně sdílet",
|
||||
"ToastFailedToCreate": "Nepodařilo se vytvořit",
|
||||
"ToastFailedToDelete": "Nepodařilo se odstranit",
|
||||
"ToastFailedToLoadData": "Nepodařilo se načíst data",
|
||||
"ToastFailedToMatch": "Nepodařilo se spárovat",
|
||||
"ToastFailedToShare": "Sdílení selhalo",
|
||||
@@ -1032,6 +1052,7 @@
|
||||
"ToastMustHaveAtLeastOnePath": "Musí mít minimálně jednu cestu",
|
||||
"ToastNameEmailRequired": "Jméno a email jsou vyžadovány",
|
||||
"ToastNameRequired": "Jméno je vyžadováno",
|
||||
"ToastNewApiKeyUserError": "Je nutné vybrat uživatele",
|
||||
"ToastNewEpisodesFound": "{0} nových epizod bylo nalezeno",
|
||||
"ToastNewUserCreatedFailed": "Chyba při vytváření účtu: \"{0}\"",
|
||||
"ToastNewUserCreatedSuccess": "Vytvořen nový účet",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"LabelLogLevelWarn": "Warnungen",
|
||||
"LabelLookForNewEpisodesAfterDate": "Suche nach neuen Episoden nach diesem Datum",
|
||||
"LabelLowestPriority": "Niedrigste Priorität",
|
||||
"LabelMatchConfidence": "Zuversicht",
|
||||
"LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit",
|
||||
"LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID vom SSO-Anbieter zugeordnet",
|
||||
"LabelMaxEpisodesToDownload": "Max. Anzahl an Episoden zum Herunterladen, 0 für unbegrenzte Episoden.",
|
||||
@@ -723,6 +724,7 @@
|
||||
"MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen",
|
||||
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würdest du <code>http://192.168.1.1:8337/notify</code> eingeben.",
|
||||
"MessageAsinCheck": "Stellen Sie sicher, dass Sie die ASIN aus der richtigen Audible Region verwenden, nicht Amazon.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "Alte API tokens werden in Zukunft entfernt. Benutze stattdessen <a href=\"/config/api-keys\">API Keys</a>.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.",
|
||||
"MessageAuthenticationSecurityMessage": "Die Anmeldung wurde abgesichert. Benutzersitzungen werden getrennt, alle Benutzer müssen sich erneut anmelden.",
|
||||
"MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.",
|
||||
|
||||
@@ -418,6 +418,7 @@
|
||||
"LabelLanguages": "Languages",
|
||||
"LabelLastBookAdded": "Last Book Added",
|
||||
"LabelLastBookUpdated": "Last Book Updated",
|
||||
"LabelLastProgressDate": "Last progress: {0}",
|
||||
"LabelLastSeen": "Last Seen",
|
||||
"LabelLastTime": "Last Time",
|
||||
"LabelLastUpdate": "Last Update",
|
||||
@@ -430,6 +431,7 @@
|
||||
"LabelLibraryFilterSublistEmpty": "No {0}",
|
||||
"LabelLibraryItem": "Library Item",
|
||||
"LabelLibraryName": "Library Name",
|
||||
"LabelLibrarySortByProgress": "Progress Updated",
|
||||
"LabelLimit": "Limit",
|
||||
"LabelLineSpacing": "Line spacing",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
@@ -438,6 +440,7 @@
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
|
||||
"LabelLowestPriority": "Lowest Priority",
|
||||
"LabelMatchConfidence": "Confidence",
|
||||
"LabelMatchExistingUsersBy": "Match existing users by",
|
||||
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
|
||||
"LabelMaxEpisodesToDownload": "Max # of episodes to download. Use 0 for unlimited.",
|
||||
@@ -655,6 +658,7 @@
|
||||
"LabelTheme": "Theme",
|
||||
"LabelThemeDark": "Dark",
|
||||
"LabelThemeLight": "Light",
|
||||
"LabelThemeSepia": "Sepia",
|
||||
"LabelTimeBase": "Time Base",
|
||||
"LabelTimeDurationXHours": "{0} hours",
|
||||
"LabelTimeDurationXMinutes": "{0} minutes",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"ButtonAdd": "Ajouter",
|
||||
"ButtonAddApiKey": "Ajouter une clé API",
|
||||
"ButtonAddChapters": "Ajouter des chapitres",
|
||||
"ButtonAddDevice": "Ajouter un appareil",
|
||||
"ButtonAddLibrary": "Ajouter une bibliothèque",
|
||||
@@ -20,6 +21,7 @@
|
||||
"ButtonChooseAFolder": "Sélectionner un dossier",
|
||||
"ButtonChooseFiles": "Sélectionner des fichiers",
|
||||
"ButtonClearFilter": "Effacer le filtre",
|
||||
"ButtonClose": "Fermer",
|
||||
"ButtonCloseFeed": "Fermer le flux",
|
||||
"ButtonCloseSession": "Fermer la session",
|
||||
"ButtonCollections": "Collections",
|
||||
@@ -119,6 +121,7 @@
|
||||
"HeaderAccount": "Compte",
|
||||
"HeaderAddCustomMetadataProvider": "Ajouter un fournisseur de métadonnées personnalisé",
|
||||
"HeaderAdvanced": "Avancé",
|
||||
"HeaderApiKeys": "Clés API",
|
||||
"HeaderAppriseNotificationSettings": "Configuration des notifications Apprise",
|
||||
"HeaderAudioTracks": "Pistes audio",
|
||||
"HeaderAudiobookTools": "Outils de gestion de fichiers de livres audio",
|
||||
@@ -162,6 +165,7 @@
|
||||
"HeaderMetadataOrderOfPrecedence": "Ordre de priorité des métadonnées",
|
||||
"HeaderMetadataToEmbed": "Métadonnées à intégrer",
|
||||
"HeaderNewAccount": "Nouveau compte",
|
||||
"HeaderNewApiKey": "Nouvelle clé API",
|
||||
"HeaderNewLibrary": "Nouvelle bibliothèque",
|
||||
"HeaderNotificationCreate": "Créer une notification",
|
||||
"HeaderNotificationUpdate": "Mise à jour de la notification",
|
||||
@@ -177,6 +181,7 @@
|
||||
"HeaderPlaylist": "Liste de lecture",
|
||||
"HeaderPlaylistItems": "Éléments de la liste de lecture",
|
||||
"HeaderPodcastsToAdd": "Podcasts à ajouter",
|
||||
"HeaderPresets": "Préréglages",
|
||||
"HeaderPreviewCover": "Prévisualiser la couverture",
|
||||
"HeaderRSSFeedGeneral": "Détails du flux RSS",
|
||||
"HeaderRSSFeedIsOpen": "Le flux RSS est actif",
|
||||
@@ -205,6 +210,7 @@
|
||||
"HeaderTableOfContents": "Table des matières",
|
||||
"HeaderTools": "Outils",
|
||||
"HeaderUpdateAccount": "Mettre à jour le compte",
|
||||
"HeaderUpdateApiKey": "Mettre à jour la clé API",
|
||||
"HeaderUpdateAuthor": "Mettre à jour l’auteur",
|
||||
"HeaderUpdateDetails": "Mettre à jour les détails",
|
||||
"HeaderUpdateLibrary": "Mettre à jour la bibliothèque",
|
||||
@@ -234,6 +240,10 @@
|
||||
"LabelAllUsersExcludingGuests": "Tous les utilisateurs à l’exception des invités",
|
||||
"LabelAllUsersIncludingGuests": "Tous les utilisateurs, y compris les invités",
|
||||
"LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque",
|
||||
"LabelApiKeyCreated": "La clé API « {0} » a été créée avec succès.",
|
||||
"LabelApiKeyCreatedDescription": "Assurez-vous de copier la clé API maintenant car vous ne pourrez plus la voir.",
|
||||
"LabelApiKeyUser": "Agir au nom de l’utilisateur",
|
||||
"LabelApiKeyUserDescription": "Cette clé API disposera des mêmes autorisations que l’utilisateur pour lequel elle agit. Elle apparaîtra dans les journaux comme si c’était l’utilisateur qui effectuait la requête.",
|
||||
"LabelApiToken": "Token API",
|
||||
"LabelAppend": "Ajouter",
|
||||
"LabelAudioBitrate": "Débit audio (par exemple 128k)",
|
||||
@@ -345,6 +355,10 @@
|
||||
"LabelExample": "Exemple",
|
||||
"LabelExpandSeries": "Développer la série",
|
||||
"LabelExpandSubSeries": "Développer les sous-séries",
|
||||
"LabelExpired": "Expiré",
|
||||
"LabelExpiresAt": "Expire à",
|
||||
"LabelExpiresInSeconds": "Expire dans (secondes)",
|
||||
"LabelExpiresNever": "Jamais",
|
||||
"LabelExplicit": "Restriction",
|
||||
"LabelExplicitChecked": "Explicite (vérifié)",
|
||||
"LabelExplicitUnchecked": "Non explicite (non vérifié)",
|
||||
@@ -454,6 +468,7 @@
|
||||
"LabelNewestEpisodes": "Épisodes récents",
|
||||
"LabelNextBackupDate": "Date de la prochaine sauvegarde",
|
||||
"LabelNextScheduledRun": "Prochain lancement prévu",
|
||||
"LabelNoApiKeys": "Aucune clé API",
|
||||
"LabelNoCustomMetadataProviders": "Aucun fournisseurs de métadonnées personnalisés",
|
||||
"LabelNoEpisodesSelected": "Aucun épisode sélectionné",
|
||||
"LabelNotFinished": "Non terminé",
|
||||
@@ -543,6 +558,7 @@
|
||||
"LabelSelectAll": "Tout sélectionner",
|
||||
"LabelSelectAllEpisodes": "Sélectionner tous les épisodes",
|
||||
"LabelSelectEpisodesShowing": "Sélectionner {0} épisode(s) en cours",
|
||||
"LabelSelectUser": "Sélectionner l’utilisateur",
|
||||
"LabelSelectUsers": "Sélectionner les utilisateurs",
|
||||
"LabelSendEbookToDevice": "Envoyer le livre numérique à…",
|
||||
"LabelSequence": "Séquence",
|
||||
@@ -707,7 +723,9 @@
|
||||
"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>.",
|
||||
"MessageAsinCheck": "Assurez-vous d’utiliser l’ASIN de la bonne région Audible, et non d’Amazon.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "Les jetons d’API hérités seront supprimés à l’avenir. Utilisez plutôt les <a href=\"/config/api-keys\">clés API</a>.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Redémarrez votre serveur après avoir enregistré pour appliquer les modifications OIDC.",
|
||||
"MessageAuthenticationSecurityMessage": "L’authentification a été améliorée pour plus de sécurité. Tous les utilisateurs doivent se reconnecter.",
|
||||
"MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression des utilisateurs, les détails des éléments de la bibliothèque, les paramètres du serveur et les images stockées dans <code>/metadata/items</code> & <code>/metadata/authors</code>. Les sauvegardes <strong>n’incluent pas</strong> les fichiers stockés dans les dossiers de votre bibliothèque.",
|
||||
"MessageBackupsLocationEditNote": "Remarque : Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes",
|
||||
"MessageBackupsLocationNoEditNote": "Remarque : l’emplacement de sauvegarde est défini via une variable d’environnement et ne peut pas être modifié ici.",
|
||||
@@ -729,6 +747,7 @@
|
||||
"MessageChaptersNotFound": "Chapitres non trouvés",
|
||||
"MessageCheckingCron": "Vérification du cron…",
|
||||
"MessageConfirmCloseFeed": "Êtes-vous sûr·e de vouloir fermer ce flux ?",
|
||||
"MessageConfirmDeleteApiKey": "Êtes-vous sûr de vouloir supprimer la clé API « {0} » ?",
|
||||
"MessageConfirmDeleteBackup": "Êtes-vous sûr·e de vouloir supprimer la sauvegarde de « {0} » ?",
|
||||
"MessageConfirmDeleteDevice": "Êtes-vous sûr·e de vouloir supprimer la liseuse « {0} » ?",
|
||||
"MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?",
|
||||
@@ -756,6 +775,7 @@
|
||||
"MessageConfirmRemoveAuthor": "Êtes-vous sûr·e de vouloir supprimer l’auteur « {0} » ?",
|
||||
"MessageConfirmRemoveCollection": "Êtes-vous sûr·e de vouloir supprimer la collection « {0} » ?",
|
||||
"MessageConfirmRemoveEpisode": "Êtes-vous sûr·e de vouloir supprimer l’épisode « {0} » ?",
|
||||
"MessageConfirmRemoveEpisodeNote": "Remarque : cela ne supprime pas le fichier audio, sauf si vous activez « Supprimer définitivement le fichier »",
|
||||
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr·e de vouloir supprimer {0} épisodes ?",
|
||||
"MessageConfirmRemoveListeningSessions": "Êtes-vous sûr·e de vouloir supprimer {0} sessions d’écoute ?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Êtes-vous sûr·e de vouloir supprimer tous les fichiers « metatadata.{0} » des dossiers d’éléments de votre bibliothèque ?",
|
||||
@@ -917,6 +937,8 @@
|
||||
"NotificationOnBackupCompletedDescription": "Déclenché lorsqu’une sauvegarde est terminée",
|
||||
"NotificationOnBackupFailedDescription": "Déclenché lorsqu'une sauvegarde échoue",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Déclenché lorsqu’un épisode de podcast est téléchargé automatiquement",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Déclenché lorsque les téléchargements automatiques d’épisodes sont désactivés en raison d’un trop grand nombre de tentatives infructueuses",
|
||||
"NotificationOnRSSFeedFailedDescription": "Déclenché lorsque la demande de flux RSS échoue pour un téléchargement automatique d’épisode",
|
||||
"NotificationOnTestDescription": "Événement pour tester le système de notification",
|
||||
"PlaceholderNewCollection": "Nom de la nouvelle collection",
|
||||
"PlaceholderNewFolderPath": "Nouveau chemin de dossier",
|
||||
@@ -997,6 +1019,8 @@
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "File d’attente de téléchargement des épisodes effacée",
|
||||
"ToastEpisodeUpdateSuccess": "{0} épisodes mis à jour",
|
||||
"ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil",
|
||||
"ToastFailedToCreate": "Échec de la création",
|
||||
"ToastFailedToDelete": "Échec de la suppression",
|
||||
"ToastFailedToLoadData": "Échec du chargement des données",
|
||||
"ToastFailedToMatch": "Échec de la correspondance",
|
||||
"ToastFailedToShare": "Échec du partage",
|
||||
@@ -1028,6 +1052,7 @@
|
||||
"ToastMustHaveAtLeastOnePath": "Doit avoir au moins un chemin",
|
||||
"ToastNameEmailRequired": "Le nom et le courriel sont requis",
|
||||
"ToastNameRequired": "Le nom est requis",
|
||||
"ToastNewApiKeyUserError": "Vous devez sélectionner un utilisateur",
|
||||
"ToastNewEpisodesFound": "{0} nouveaux épisodes trouvés",
|
||||
"ToastNewUserCreatedFailed": "La création du compte à échouée : « {0} »",
|
||||
"ToastNewUserCreatedSuccess": "Nouveau compte créé",
|
||||
|
||||
@@ -723,6 +723,7 @@
|
||||
"MessageAddToPlayerQueue": "Dodaj u redoslijed izvođenja",
|
||||
"MessageAppriseDescription": "Da biste se koristili ovom značajkom, treba vam instanca <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API-ja</a> ili API koji može rukovati istom vrstom zahtjeva.<br />The Adresa Apprise API-ja treba biti puna URL putanja za slanje obavijesti, npr. ako vam se API instanca poslužuje na adresi <code>http://192.168.1.1:8337</code> trebate upisati <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "Upišite ASIN iz odgovarajuće Audibleove regije, ne s Amazonov.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "Starije API tokene ćemo ukloniti. Umjesto njih, koristite se <a href=\"/config/api-keys\">API ključevima</a> .",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Ponovno pokrenite poslužitelj da biste primijenili OIDC promjene.",
|
||||
"MessageAuthenticationSecurityMessage": "Provjera autentičnosti poboljšana je radi sigurnosti. Svi se korisnici moraju ponovno prijaviti.",
|
||||
"MessageBackupsDescription": "Sigurnosne kopije sadrže korisnike, korisnikov napredak medija, pojedinosti knjižničke građe, postavke poslužitelja i slike koje se spremaju u <code>/metadata/items</code> & <code>/metadata/authors</code>. Sigurnosne kopije ne sadrže niti jednu datoteku iz mapa knjižnice.",
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"ButtonAdd": "Aggiungi",
|
||||
"ButtonAddApiKey": "Aggiungi chiave API",
|
||||
"ButtonAddChapters": "Aggiungi Capitoli",
|
||||
"ButtonAddDevice": "Aggiungi Dispositivo",
|
||||
"ButtonAddLibrary": "Aggiungi Libreria",
|
||||
"ButtonAddPodcasts": "Aggiungi Podcast",
|
||||
"ButtonAddUser": "Aggiungi User",
|
||||
"ButtonAddUser": "Aggiungi Utente",
|
||||
"ButtonAddYourFirstLibrary": "Aggiungi la tua prima libreria",
|
||||
"ButtonApply": "Applica",
|
||||
"ButtonApplyChapters": "Applica",
|
||||
"ButtonApplyChapters": "Applica Capitoli",
|
||||
"ButtonAuthors": "Autori",
|
||||
"ButtonBack": "Indietro",
|
||||
"ButtonBatchEditPopulateFromExisting": "Popola da esistente",
|
||||
"ButtonBatchEditPopulateMapDetails": "Inserisci i dettagli della mappa",
|
||||
"ButtonBrowseForFolder": "Per Cartella",
|
||||
"ButtonBrowseForFolder": "Sfoglia per Cartella",
|
||||
"ButtonCancel": "Annulla",
|
||||
"ButtonCancelEncode": "Ferma la codifica",
|
||||
"ButtonChangeRootPassword": "Cambia la Password di root",
|
||||
@@ -20,6 +21,7 @@
|
||||
"ButtonChooseAFolder": "Seleziona la Cartella",
|
||||
"ButtonChooseFiles": "Seleziona i File",
|
||||
"ButtonClearFilter": "Elimina filtri",
|
||||
"ButtonClose": "Chiudi",
|
||||
"ButtonCloseFeed": "Chiudi flusso",
|
||||
"ButtonCloseSession": "Chiudi la sessione aperta",
|
||||
"ButtonCollections": "Raccolte",
|
||||
|
||||
@@ -357,7 +357,7 @@
|
||||
"LabelExpandSubSeries": "Развернуть подсерию",
|
||||
"LabelExpired": "Истекший",
|
||||
"LabelExpiresAt": "Истекает в",
|
||||
"LabelExpiresInSeconds": "Истекает через (seconds)",
|
||||
"LabelExpiresInSeconds": "Истекает через (секунд)",
|
||||
"LabelExpiresNever": "Никогда",
|
||||
"LabelExplicit": "18+",
|
||||
"LabelExplicitChecked": "18+ (отмечено)",
|
||||
@@ -438,6 +438,7 @@
|
||||
"LabelLogLevelWarn": "Предупреждение",
|
||||
"LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты",
|
||||
"LabelLowestPriority": "Самый низкий приоритет",
|
||||
"LabelMatchConfidence": "Уверенность",
|
||||
"LabelMatchExistingUsersBy": "Сопоставление существующих пользователей по",
|
||||
"LabelMatchExistingUsersByDescription": "Используется для подключения существующих пользователей. После подключения пользователям будет присвоен уникальный идентификатор от поставщика единого входа",
|
||||
"LabelMaxEpisodesToDownload": "Максимальное количество эпизодов для загрузки. Используйте 0 для неограниченного количества.",
|
||||
@@ -723,6 +724,7 @@
|
||||
"MessageAddToPlayerQueue": "Добавить в очередь проигрывателя",
|
||||
"MessageAppriseDescription": "Для использования этой функции необходимо иметь запущенный экземпляр <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> или api которое обрабатывает те же самые запросы. <br />URL-адрес API Apprise должен быть полным URL-адресом для отправки уведомления, т.е., если API запущено по адресу <code>http://192.168.1.1:8337</code> тогда нужно указать <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "Убедитесь, что вы используете ASIN из правильной региональной зоны Audible, а не из Amazon.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "Устаревшие токены API в будущем будут удалены. Вместо них используйте <a href=\"/config/api-keys\">API-ключи</a>.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Перезапустите ваш сервер после сохранения для применения изменений в OIDC.",
|
||||
"MessageAuthenticationSecurityMessage": "В целях безопасности была улучшена аутентификация. Всем пользователям необходимо повторно войти в систему.",
|
||||
"MessageBackupsDescription": "Бэкап включает пользователей, прогресс пользователей, данные элементов библиотеки, настройки сервера и изображения хранящиеся в <code>/metadata/items</code> и <code>/metadata/authors</code>. Бэкапы <strong>НЕ</strong> сохраняют файлы из папок библиотек.",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"ButtonAdd": "Ekle",
|
||||
"ButtonAddApiKey": "API Anahtarı Ekle",
|
||||
"ButtonAddChapters": "Bölüm Ekle",
|
||||
"ButtonAddDevice": "Cihaz Ekle",
|
||||
"ButtonAddLibrary": "Kütüphane Ekle",
|
||||
@@ -20,6 +21,7 @@
|
||||
"ButtonChooseAFolder": "Klasör seç",
|
||||
"ButtonChooseFiles": "Dosya seç",
|
||||
"ButtonClearFilter": "Filtreyi Temizle",
|
||||
"ButtonClose": "Kapat",
|
||||
"ButtonCloseFeed": "Akışı Kapat",
|
||||
"ButtonCloseSession": "Acık Oturumu Kapat",
|
||||
"ButtonCollections": "Koleksiyonlar",
|
||||
@@ -95,7 +97,17 @@
|
||||
"ButtonSearch": "Ara",
|
||||
"ButtonSelectFolderPath": "Klasör Yolunu Seç",
|
||||
"ButtonSeries": "Seriler",
|
||||
"ButtonShare": "Paylaş",
|
||||
"ButtonStats": "İstatistikler",
|
||||
"ButtonSubmit": "Gönder",
|
||||
"ButtonTest": "Dene",
|
||||
"ButtonUnlinkOpenId": "OpenID ilişiğini kaldır",
|
||||
"ButtonUpload": "Yükle",
|
||||
"ButtonUploadBackup": "Yedeği Yükle",
|
||||
"ButtonUploadCover": "Kapağı Yükle",
|
||||
"ButtonUploadOPMLFile": "OPML Dosyası Yükle",
|
||||
"ButtonUserDelete": "{0} kullanıcısını sil.",
|
||||
"ButtonUserEdit": "{0} kullanıcısını düzenle",
|
||||
"ButtonViewAll": "Tümünü Görüntüle",
|
||||
"ButtonYes": "Evet",
|
||||
"ErrorUploadFetchMetadataAPI": "Üst veriyi almakta hata",
|
||||
@@ -104,6 +116,7 @@
|
||||
"HeaderAccount": "Hesap",
|
||||
"HeaderAddCustomMetadataProvider": "Özel Üstveri Sağlayıcısı Ekle",
|
||||
"HeaderAdvanced": "Gelişmiş",
|
||||
"HeaderApiKeys": "API Anahtarları",
|
||||
"HeaderAppriseNotificationSettings": "Bildirim Ayarlarının Haberini Ver",
|
||||
"HeaderAudioTracks": "Ses Kanalları",
|
||||
"HeaderAudiobookTools": "Sesli Kitap Dosya Yönetim Araçları",
|
||||
@@ -111,13 +124,23 @@
|
||||
"HeaderBackups": "Yedeklemeler",
|
||||
"HeaderChangePassword": "Parolayı Değiştir",
|
||||
"HeaderChapters": "Bölümler",
|
||||
"HeaderChooseAFolder": "Klasör Seç",
|
||||
"HeaderCollection": "Koleksiyon",
|
||||
"HeaderCollectionItems": "Koleksiyon Öğeleri",
|
||||
"HeaderCover": "Kapak",
|
||||
"HeaderCurrentDownloads": "Geçerli İndirmeler",
|
||||
"HeaderCustomMessageOnLogin": "Girişteki Kişiselleştirilmiş Mesaj",
|
||||
"HeaderCustomMetadataProviders": "Kişiselleştirilmiş Metadata Sağlayıcıları",
|
||||
"HeaderDetails": "Detaylar",
|
||||
"HeaderDownloadQueue": "Kuyruktakileri İndir",
|
||||
"HeaderEbookFiles": "Ebook Dosyaları",
|
||||
"HeaderEmail": "Email",
|
||||
"HeaderEmailSettings": "Email Ayarları",
|
||||
"HeaderEpisodes": "Bölümler",
|
||||
"HeaderEreaderDevices": "Ekitap Cihazları",
|
||||
"HeaderEreaderSettings": "Ereader Ayarları",
|
||||
"HeaderFiles": "Dosyalar",
|
||||
"HeaderFindChapters": "Bölümleri Bul",
|
||||
"HeaderIgnoredFiles": "Görmezden Gelinen Dosyalar",
|
||||
"HeaderItemFiles": "Öğe Dosyaları",
|
||||
"HeaderItemMetadataUtils": "Öğe Üstveri Araçları",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"LabelLogLevelWarn": "Увага",
|
||||
"LabelLookForNewEpisodesAfterDate": "Шукати нові епізоди після вказаної дати",
|
||||
"LabelLowestPriority": "Найнижчий пріоритет",
|
||||
"LabelMatchConfidence": "Впевненість",
|
||||
"LabelMatchExistingUsersBy": "Шукати наявних користувачів за",
|
||||
"LabelMatchExistingUsersByDescription": "Використовується для підключення наявних користувачів. Після підключення користувач отримає унікальний id від вашого сервісу SSO",
|
||||
"LabelMaxEpisodesToDownload": "Максимальна кількість епізодів для скачування. Використовуйте 0 для необмеженої кількості.",
|
||||
@@ -723,6 +724,7 @@
|
||||
"MessageAddToPlayerQueue": "Додати до черги відтворення",
|
||||
"MessageAppriseDescription": "Щоб скористатися цією функцією, вам потрібно мати запущену <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> або API, що оброблятиме ті ж запити. <br />Аби надсилати сповіщення, URL-адреса API Apprise мусить бути повною, наприклад, якщо ваш API розміщено за адресою <code>http://192.168.1.1:8337</code>, то необхідно вказати адресу <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "Переконайтесь, що ви використовуєте ASIN з правильної регіональної Audible зони, а не з Amazon.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "Застарілі токени API будуть видалені в майбутньому. Натомість використовуйте <a href=\"/config/api-keys\">Ключі API</a>.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Перезавантажте сервер після збереження, щоб застосувати зміни OIDC.",
|
||||
"MessageAuthenticationSecurityMessage": "Автентифікацію покращено для безпеки. Усім користувачам потрібно повторно увійти в систему.",
|
||||
"MessageBackupsDescription": "Резервні копії містять користувачів, прогрес, подробиці елементів бібліотеки, налаштування сервера та зображення з <code>/metadata/items</code> та <code>/metadata/authors</code>. Резервні копії <strong>не</strong> містять жодних файлів з тек бібліотеки.",
|
||||
@@ -836,7 +838,7 @@
|
||||
"MessageNoItems": "Елементи відсутні",
|
||||
"MessageNoItemsFound": "Елементів не знайдено",
|
||||
"MessageNoListeningSessions": "Сеанси прослуховування відсутні",
|
||||
"MessageNoLogs": "Немає журналів",
|
||||
"MessageNoLogs": "Немає журналів'",
|
||||
"MessageNoMediaProgress": "Прогрес відсутній",
|
||||
"MessageNoNotifications": "Сповіщення відсутні",
|
||||
"MessageNoPodcastFeed": "Некоректний подкаст: немає каналу",
|
||||
|
||||
@@ -240,10 +240,10 @@
|
||||
"LabelAllUsersExcludingGuests": "除访客外的所有用户",
|
||||
"LabelAllUsersIncludingGuests": "包括访客的所有用户",
|
||||
"LabelAlreadyInYourLibrary": "已存在你的库中",
|
||||
"LabelApiKeyCreated": "API 密钥 \"{0}\" 创建成功。",
|
||||
"LabelApiKeyCreatedDescription": "请确保现在就复制 API 密钥,之后将无法再次查看。",
|
||||
"LabelApiKeyCreated": "API 密钥 \"{0}\" 创建成功.",
|
||||
"LabelApiKeyCreatedDescription": "请确保现在就复制 API 密钥, 之后将无法再次查看.",
|
||||
"LabelApiKeyUser": "代用户操作",
|
||||
"LabelApiKeyUserDescription": "此 API 密钥将具有与其代理的用户相同的权限。在日志中,其请求将被视为由该用户直接发出。",
|
||||
"LabelApiKeyUserDescription": "此 API 密钥将具有与其代理的用户相同的权限. 在日志中, 其请求将被视为由该用户直接发出.",
|
||||
"LabelApiToken": "API 令牌",
|
||||
"LabelAppend": "附加",
|
||||
"LabelAudioBitrate": "音频比特率 (例如: 128k)",
|
||||
@@ -329,7 +329,7 @@
|
||||
"LabelEmailSettingsRejectUnauthorized": "拒绝未经授权的证书",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "禁用SSL证书验证可能会使你的连接面临安全风险, 例如中间人攻击. 只有当你了解其中的含义并信任所连接的邮件服务器时, 才能禁用此选项.",
|
||||
"LabelEmailSettingsSecure": "安全",
|
||||
"LabelEmailSettingsSecureHelp": "开启此选项时,将始终通过TLS连接服务器。关闭此选项时,仅在服务器支持STARTTLS扩展时使用TLS。在大多数情况下,如果连接到端口465,请将此项设为开启。如果连接到端口587或25,请将此设置保持为关闭。(来自nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsSecureHelp": "开启此选项时, 将始终通过TLS连接服务器. 关闭此选项时, 仅在服务器支持STARTTLS扩展时使用TLS. 在大多数情况下, 如果连接到端口465, 请将此项设为开启. 如果连接到端口587或25, 请将此设置保持为关闭. (来自nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsTestAddress": "测试地址",
|
||||
"LabelEmbeddedCover": "嵌入封面",
|
||||
"LabelEnable": "启用",
|
||||
@@ -357,10 +357,10 @@
|
||||
"LabelExpandSubSeries": "展开子系列",
|
||||
"LabelExpired": "已过期",
|
||||
"LabelExpiresAt": "过期时间",
|
||||
"LabelExpiresInSeconds": "有效期(秒)",
|
||||
"LabelExpiresInSeconds": "有效期 (秒)",
|
||||
"LabelExpiresNever": "从不",
|
||||
"LabelExplicit": "含成人内容",
|
||||
"LabelExplicitChecked": "成人内容(已核实)",
|
||||
"LabelExplicitChecked": "成人内容 (已核实)",
|
||||
"LabelExplicitUnchecked": "无成人内容 (未核实)",
|
||||
"LabelExportOPML": "导出 OPML",
|
||||
"LabelFeedURL": "源 URL",
|
||||
@@ -438,6 +438,7 @@
|
||||
"LabelLogLevelWarn": "警告",
|
||||
"LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集",
|
||||
"LabelLowestPriority": "最低优先级",
|
||||
"LabelMatchConfidence": "置信度",
|
||||
"LabelMatchExistingUsersBy": "匹配现有用户",
|
||||
"LabelMatchExistingUsersByDescription": "用于连接现有用户. 连接后, 用户将通过 SSO 提供商提供的唯一 id 进行匹配",
|
||||
"LabelMaxEpisodesToDownload": "可下载的最大集数. 输入 0 表示无限制.",
|
||||
@@ -723,14 +724,15 @@
|
||||
"MessageAddToPlayerQueue": "添加到播放队列",
|
||||
"MessageAppriseDescription": "要使用此功能,你需要运行一个 <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> 实例或一个可以处理这些相同请求的 API. <br />Apprise API Url 应该是发送通知的完整 URL 路径, 例如: 如果你的 API 实例运行在 <code>http://192.168.1.1:8337</code>, 那么你可以输入 <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "确保你使用的 ASIN 来自正确的 Audible 地区, 而不是亚马逊.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "旧版 API 令牌将来会被移除. 请改用 <a href=\"/config/api-keys\">API 密钥</a>.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "保存后重新启动服务器以应用 OIDC 更改.",
|
||||
"MessageAuthenticationSecurityMessage": "身份验证安全性已增强,所有用户都需要重新登录。",
|
||||
"MessageAuthenticationSecurityMessage": "身份验证安全性已增强, 所有用户都需要重新登录.",
|
||||
"MessageBackupsDescription": "备份包括用户, 用户进度, 媒体库项目详细信息, 服务器设置和图像, 存储在 <code>/metadata/items</code> & <code>/metadata/authors</code>. 备份不包括存储在你的媒体库文件夹中的任何文件.",
|
||||
"MessageBackupsLocationEditNote": "注意: 更新备份位置不会移动或修改现有备份",
|
||||
"MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.",
|
||||
"MessageBackupsLocationPathEmpty": "备份位置路径不能为空",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息,填入上方所有勾选的编辑框中",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息, 填入上方所有勾选的编辑框中",
|
||||
"MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.",
|
||||
"MessageBookshelfNoCollections": "你尚未进行任何收藏",
|
||||
"MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.",
|
||||
@@ -746,7 +748,7 @@
|
||||
"MessageChaptersNotFound": "未找到章节",
|
||||
"MessageCheckingCron": "检查计划任务...",
|
||||
"MessageConfirmCloseFeed": "你确定要关闭此订阅源吗?",
|
||||
"MessageConfirmDeleteApiKey": "你确定要删除 API 密钥 \"{0}\" 吗?",
|
||||
"MessageConfirmDeleteApiKey": "你确定要删除 API 密钥 \"{0}\" 吗?",
|
||||
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
|
||||
"MessageConfirmDeleteDevice": "你确定要删除电子阅读器设备 \"{0}\" 吗?",
|
||||
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
|
||||
@@ -774,7 +776,7 @@
|
||||
"MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodeNote": "注意:此操作不会删除音频文件,除非勾选“完全删除文件”选项",
|
||||
"MessageConfirmRemoveEpisodeNote": "注意: 此操作不会删除音频文件, 除非勾选 \"完全删除文件\" 选项",
|
||||
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
|
||||
"MessageConfirmRemoveListeningSessions": "你确定要移除 {0} 收听会话吗?",
|
||||
"MessageConfirmRemoveMetadataFiles": "你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?",
|
||||
@@ -866,7 +868,7 @@
|
||||
"MessageRemoveEpisodes": "移除 {0} 剧集",
|
||||
"MessageRemoveFromPlayerQueue": "从播放队列中移除",
|
||||
"MessageRemoveUserWarning": "是否确实要永久删除用户 \"{0}\"?",
|
||||
"MessageReportBugsAndContribute": "反馈问题、建议功能或参与贡献,请访问",
|
||||
"MessageReportBugsAndContribute": "反馈问题, 建议功能或参与贡献, 请访问",
|
||||
"MessageResetChaptersConfirm": "你确定要重置章节并撤消你所做的更改吗?",
|
||||
"MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份",
|
||||
"MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.",
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.26.1",
|
||||
"version": "2.26.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.26.0",
|
||||
"version": "2.26.3",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.26.1",
|
||||
"version": "2.26.3",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -280,7 +280,7 @@ class MeController {
|
||||
}
|
||||
|
||||
const { password, newPassword } = req.body
|
||||
if (!password || !newPassword || typeof password !== 'string' || typeof newPassword !== 'string') {
|
||||
if ((typeof password !== 'string' && password !== null) || (typeof newPassword !== 'string' && newPassword !== null)) {
|
||||
return res.status(400).send('Missing or invalid password or new password')
|
||||
}
|
||||
|
||||
@@ -450,7 +450,7 @@ class MeController {
|
||||
if (updated) {
|
||||
await Database.updateSetting(Database.emailSettings)
|
||||
SocketAuthority.clientEmitter(req.user.id, 'ereader-devices-updated', {
|
||||
ereaderDevices: Database.emailSettings.ereaderDevices
|
||||
ereaderDevices: Database.emailSettings.getEReaderDevices(req.user)
|
||||
})
|
||||
}
|
||||
res.json({
|
||||
|
||||
@@ -288,7 +288,12 @@ class SessionController {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const audioTrack = playbackSession.audioTracks.find((t) => t.index === audioTrackIndex)
|
||||
let audioTrack = playbackSession.audioTracks.find((t) => toNumber(t.index, 1) === audioTrackIndex)
|
||||
|
||||
// Support clients passing 0 or 1 for podcast episode audio track index (handles old episodes pre-v2.21.0 having null index)
|
||||
if (!audioTrack && playbackSession.mediaType === 'podcast' && audioTrackIndex === 0) {
|
||||
audioTrack = playbackSession.audioTracks[0]
|
||||
}
|
||||
if (!audioTrack) {
|
||||
Logger.error(`[SessionController] Unable to find audio track with index=${audioTrackIndex}`)
|
||||
return res.sendStatus(404)
|
||||
|
||||
@@ -7,7 +7,7 @@ const FantLab = require('../providers/FantLab')
|
||||
const AudiobookCovers = require('../providers/AudiobookCovers')
|
||||
const CustomProviderAdapter = require('../providers/CustomProviderAdapter')
|
||||
const Logger = require('../Logger')
|
||||
const { levenshteinDistance, escapeRegExp } = require('../utils/index')
|
||||
const { levenshteinDistance, levenshteinSimilarity, escapeRegExp, isValidASIN } = require('../utils/index')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
class BookFinder {
|
||||
@@ -385,7 +385,11 @@ class BookFinder {
|
||||
|
||||
if (!title) return books
|
||||
|
||||
books = await this.runSearch(title, author, provider, asin, maxTitleDistance, maxAuthorDistance)
|
||||
const isTitleAsin = isValidASIN(title.toUpperCase())
|
||||
|
||||
let actualTitleQuery = title
|
||||
let actualAuthorQuery = author
|
||||
books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance)
|
||||
|
||||
if (!books.length && maxFuzzySearches > 0) {
|
||||
// Normalize title and author
|
||||
@@ -408,19 +412,26 @@ class BookFinder {
|
||||
for (const titlePart of titleParts) titleCandidates.add(titlePart)
|
||||
titleCandidates = titleCandidates.getCandidates()
|
||||
for (const titleCandidate of titleCandidates) {
|
||||
if (titleCandidate == title && authorCandidate == author) continue // We already tried this
|
||||
if (titleCandidate == actualTitleQuery && authorCandidate == actualAuthorQuery) continue // We already tried this
|
||||
if (++numFuzzySearches > maxFuzzySearches) break loop_author
|
||||
books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance)
|
||||
actualTitleQuery = titleCandidate
|
||||
actualAuthorQuery = authorCandidate
|
||||
books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance)
|
||||
if (books.length) break loop_author
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (books.length) {
|
||||
const resultsHaveDuration = provider.startsWith('audible')
|
||||
if (resultsHaveDuration && libraryItem?.media?.duration) {
|
||||
const libraryItemDurationMinutes = libraryItem.media.duration / 60
|
||||
// If provider results have duration, sort by ascendinge duration difference from libraryItem
|
||||
const isAudibleProvider = provider.startsWith('audible')
|
||||
const libraryItemDurationMinutes = libraryItem?.media?.duration ? libraryItem.media.duration / 60 : null
|
||||
|
||||
books.forEach((book) => {
|
||||
if (typeof book !== 'object' || !isAudibleProvider) return
|
||||
book.matchConfidence = this.calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin)
|
||||
})
|
||||
|
||||
if (isAudibleProvider && libraryItemDurationMinutes) {
|
||||
books.sort((a, b) => {
|
||||
const aDuration = a.duration || Number.POSITIVE_INFINITY
|
||||
const bDuration = b.duration || Number.POSITIVE_INFINITY
|
||||
@@ -433,6 +444,120 @@ class BookFinder {
|
||||
return books
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate match confidence score for a book
|
||||
* @param {Object} book - The book object to calculate confidence for
|
||||
* @param {number|null} libraryItemDurationMinutes - Duration of library item in minutes
|
||||
* @param {string} actualTitleQuery - Actual title query
|
||||
* @param {string} actualAuthorQuery - Actual author query
|
||||
* @param {boolean} isTitleAsin - Whether the title is an ASIN
|
||||
* @returns {number|null} - Match confidence score or null if not applicable
|
||||
*/
|
||||
calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin) {
|
||||
// ASIN results are always a match
|
||||
if (isTitleAsin) return 1.0
|
||||
|
||||
let durationScore
|
||||
if (libraryItemDurationMinutes && typeof book.duration === 'number') {
|
||||
const durationDiff = Math.abs(book.duration - libraryItemDurationMinutes)
|
||||
// Duration scores:
|
||||
// diff | score
|
||||
// 0 | 1.0
|
||||
// 1 | 1.0
|
||||
// 2 | 0.9
|
||||
// 3 | 0.8
|
||||
// 4 | 0.7
|
||||
// 5 | 0.6
|
||||
// 6 | 0.48
|
||||
// 7 | 0.36
|
||||
// 8 | 0.24
|
||||
// 9 | 0.12
|
||||
// 10 | 0.0
|
||||
if (durationDiff <= 1) {
|
||||
// Covers durationDiff = 0 for score 1.0
|
||||
durationScore = 1.0
|
||||
} else if (durationDiff <= 5) {
|
||||
// (1, 5] - Score from 1.0 down to 0.6
|
||||
// Linearly interpolates between (1, 1.0) and (5, 0.6)
|
||||
// Equation: y = 1.0 - 0.08 * x
|
||||
durationScore = 1.1 - 0.1 * durationDiff
|
||||
} else if (durationDiff <= 10) {
|
||||
// (5, 10] - Score from 0.6 down to 0.0
|
||||
// Linearly interpolates between (5, 0.6) and (10, 0.0)
|
||||
// Equation: y = 1.2 - 0.12 * x
|
||||
durationScore = 1.2 - 0.12 * durationDiff
|
||||
} else {
|
||||
// durationDiff > 10 - Score is 0.0
|
||||
durationScore = 0.0
|
||||
}
|
||||
Logger.debug(`[BookFinder] Duration diff: ${durationDiff}, durationScore: ${durationScore}`)
|
||||
} else {
|
||||
// Default score if library item duration or book duration is not available
|
||||
durationScore = 0.1
|
||||
}
|
||||
|
||||
const calculateTitleScore = (titleQuery, book, keepSubtitle = false) => {
|
||||
const cleanTitle = cleanTitleForCompares(book.title || '', keepSubtitle)
|
||||
const cleanSubtitle = keepSubtitle && book.subtitle ? `: ${book.subtitle}` : ''
|
||||
const normBookTitle = `${cleanTitle}${cleanSubtitle}`
|
||||
const normTitleQuery = cleanTitleForCompares(titleQuery, keepSubtitle)
|
||||
const titleSimilarity = levenshteinSimilarity(normTitleQuery, normBookTitle)
|
||||
Logger.debug(`[BookFinder] keepSubtitle: ${keepSubtitle}, normBookTitle: ${normBookTitle}, normTitleQuery: ${normTitleQuery}, titleSimilarity: ${titleSimilarity}`)
|
||||
return titleSimilarity
|
||||
}
|
||||
const titleQueryHasSubtitle = hasSubtitle(actualTitleQuery)
|
||||
const titleScore = calculateTitleScore(actualTitleQuery, book, titleQueryHasSubtitle)
|
||||
|
||||
let authorScore
|
||||
const normAuthorQuery = cleanAuthorForCompares(actualAuthorQuery)
|
||||
const normBookAuthor = cleanAuthorForCompares(book.author || '')
|
||||
if (!normAuthorQuery) {
|
||||
// Original query had no author
|
||||
authorScore = 1.0 // Neutral score
|
||||
} else {
|
||||
// Original query HAS an author (cleanedQueryAuthorForScore is not empty)
|
||||
if (normBookAuthor) {
|
||||
const bookAuthorParts = normBookAuthor.split(',').map((name) => name.trim().toLowerCase())
|
||||
// Filter out empty parts that might result from ", ," or trailing/leading commas
|
||||
const validBookAuthorParts = bookAuthorParts.filter((p) => p.length > 0)
|
||||
|
||||
if (validBookAuthorParts.length === 0) {
|
||||
// Book author string was present but effectively empty (e.g. ",,")
|
||||
// Since cleanedQueryAuthorForScore is non-empty here, this is a mismatch.
|
||||
authorScore = 0.0
|
||||
} else {
|
||||
let maxPartScore = levenshteinSimilarity(normAuthorQuery, normBookAuthor)
|
||||
Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, normBookAuthor: ${normBookAuthor}, similarity: ${maxPartScore}`)
|
||||
if (validBookAuthorParts.length > 1 || normBookAuthor.includes(',')) {
|
||||
validBookAuthorParts.forEach((part) => {
|
||||
// part is guaranteed to be non-empty here
|
||||
// cleanedQueryAuthorForScore is also guaranteed non-empty here.
|
||||
// levenshteinDistance lowercases by default, but part is already lowercased.
|
||||
const similarity = levenshteinSimilarity(normAuthorQuery, part)
|
||||
Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, bookAuthorPart: ${part}, similarity: ${similarity}`)
|
||||
const currentPartScore = similarity
|
||||
maxPartScore = Math.max(maxPartScore, currentPartScore)
|
||||
})
|
||||
}
|
||||
authorScore = maxPartScore
|
||||
}
|
||||
} else {
|
||||
// Book has NO author (or not a string, or empty string)
|
||||
// Query has an author (cleanedQueryAuthorForScore is non-empty), book does not.
|
||||
authorScore = 0.0
|
||||
}
|
||||
}
|
||||
|
||||
const W_DURATION = 0.7
|
||||
const W_TITLE = 0.2
|
||||
const W_AUTHOR = 0.1
|
||||
|
||||
Logger.debug(`[BookFinder] Duration score: ${durationScore}, Title score: ${titleScore}, Author score: ${authorScore}`)
|
||||
const confidence = W_DURATION * durationScore + W_TITLE * titleScore + W_AUTHOR * authorScore
|
||||
Logger.debug(`[BookFinder] Confidence: ${confidence}`)
|
||||
return Math.max(0, Math.min(1, confidence))
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for books
|
||||
*
|
||||
@@ -464,6 +589,7 @@ class BookFinder {
|
||||
} else {
|
||||
books = await this.getGoogleBooksResults(title, author)
|
||||
}
|
||||
|
||||
books.forEach((book) => {
|
||||
if (book.description) {
|
||||
book.description = htmlSanitizer.sanitize(book.description)
|
||||
@@ -505,6 +631,9 @@ class BookFinder {
|
||||
}
|
||||
module.exports = new BookFinder()
|
||||
|
||||
function hasSubtitle(title) {
|
||||
return title.includes(':') || title.includes(' - ')
|
||||
}
|
||||
function stripSubtitle(title) {
|
||||
if (title.includes(':')) {
|
||||
return title.split(':')[0].trim()
|
||||
@@ -523,12 +652,12 @@ function replaceAccentedChars(str) {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanTitleForCompares(title) {
|
||||
function cleanTitleForCompares(title, keepSubtitle = false) {
|
||||
if (!title) return ''
|
||||
title = stripRedundantSpaces(title)
|
||||
|
||||
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
|
||||
let stripped = stripSubtitle(title)
|
||||
let stripped = keepSubtitle ? title : stripSubtitle(title)
|
||||
|
||||
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
|
||||
let cleaned = stripped.replace(/ *\([^)]*\) */g, '')
|
||||
|
||||
@@ -185,6 +185,7 @@ class PodcastEpisode extends Model {
|
||||
const track = structuredClone(this.audioFile)
|
||||
track.startOffset = 0
|
||||
track.title = this.audioFile.metadata.filename
|
||||
track.index = 1 // Podcast episodes only have one track
|
||||
track.contentUrl = `/api/items/${libraryItemId}/file/${track.ino}`
|
||||
return track
|
||||
}
|
||||
|
||||
@@ -34,6 +34,14 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
|
||||
}
|
||||
module.exports.levenshteinDistance = levenshteinDistance
|
||||
|
||||
const levenshteinSimilarity = (str1, str2, caseSensitive = false) => {
|
||||
const distance = levenshteinDistance(str1, str2, caseSensitive)
|
||||
const maxLength = Math.max(str1.length, str2.length)
|
||||
if (maxLength === 0) return 1
|
||||
return 1 - distance / maxLength
|
||||
}
|
||||
module.exports.levenshteinSimilarity = levenshteinSimilarity
|
||||
|
||||
module.exports.isObject = (val) => {
|
||||
return val !== null && typeof val === 'object'
|
||||
}
|
||||
|
||||
@@ -399,9 +399,6 @@ module.exports = {
|
||||
if (filterGroup !== 'series' && sortBy === 'sequence') {
|
||||
sortBy = 'media.metadata.title'
|
||||
}
|
||||
if (filterGroup !== 'progress' && sortBy === 'progress') {
|
||||
sortBy = 'media.metadata.title'
|
||||
}
|
||||
const includeRSSFeed = include.includes('rssfeed')
|
||||
const includeMediaItemShare = !!user?.isAdminOrUp && include.includes('share')
|
||||
|
||||
@@ -532,6 +529,18 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
|
||||
// When sorting by progress but not filtering by progress, include media progresses
|
||||
if (filterGroup !== 'progress' && sortBy === 'progress') {
|
||||
bookIncludes.push({
|
||||
model: Database.mediaProgressModel,
|
||||
attributes: ['id', 'isFinished', 'currentTime', 'ebookProgress', 'updatedAt'],
|
||||
where: {
|
||||
userId: user.id
|
||||
},
|
||||
required: false
|
||||
})
|
||||
}
|
||||
|
||||
let { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue)
|
||||
let bookWhere = Array.isArray(mediaWhere) ? mediaWhere : [mediaWhere]
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ const bookFinder = require('../../../server/finders/BookFinder')
|
||||
const { LogLevel } = require('../../../server/utils/constants')
|
||||
const Logger = require('../../../server/Logger')
|
||||
Logger.setLogLevel(LogLevel.INFO)
|
||||
const { levenshteinDistance } = require('../../../server/utils/index')
|
||||
|
||||
// levenshteinDistance is needed for manual calculation of expected scores in tests.
|
||||
// Assuming it's accessible for testing purposes or we mock/replicate its basic behavior if needed.
|
||||
// For now, we'll assume bookFinder.search uses it internally correctly.
|
||||
// const { levenshteinDistance } = require('../../../server/utils/index') // Not used directly in test logic, but for reasoning.
|
||||
|
||||
describe('TitleCandidates', () => {
|
||||
describe('cleanAuthor non-empty', () => {
|
||||
@@ -326,31 +332,262 @@ describe('search', () => {
|
||||
const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }]
|
||||
|
||||
beforeEach(() => {
|
||||
runSearchStub.withArgs(t, a, provider).resolves(unsorted)
|
||||
runSearchStub.withArgs(t, a, provider).resolves(structuredClone(unsorted))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('returns results sorted by library item duration diff', async () => {
|
||||
expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted)
|
||||
const result = (await bookFinder.search(libraryItem, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
|
||||
expect(result).to.deep.equal(sorted)
|
||||
})
|
||||
|
||||
it('returns unsorted results if library item is null', async () => {
|
||||
expect(await bookFinder.search(null, provider, t, a)).to.deep.equal(unsorted)
|
||||
const result = (await bookFinder.search(null, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
|
||||
expect(result).to.deep.equal(unsorted)
|
||||
})
|
||||
|
||||
it('returns unsorted results if library item duration is undefined', async () => {
|
||||
expect(await bookFinder.search({ media: {} }, provider, t, a)).to.deep.equal(unsorted)
|
||||
const result = (await bookFinder.search({ media: {} }, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
|
||||
expect(result).to.deep.equal(unsorted)
|
||||
})
|
||||
|
||||
it('returns unsorted results if library item media is undefined', async () => {
|
||||
expect(await bookFinder.search({}, provider, t, a)).to.deep.equal(unsorted)
|
||||
const result = (await bookFinder.search({}, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
|
||||
expect(result).to.deep.equal(unsorted)
|
||||
})
|
||||
|
||||
it('should return a result last if it has no duration', async () => {
|
||||
const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]
|
||||
const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}]
|
||||
runSearchStub.withArgs(t, a, provider).resolves(unsorted)
|
||||
runSearchStub.withArgs(t, a, provider).resolves(structuredClone(unsorted))
|
||||
const result = (await bookFinder.search(libraryItem, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
|
||||
expect(result).to.deep.equal(sorted)
|
||||
})
|
||||
})
|
||||
|
||||
expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted)
|
||||
describe('matchConfidence score', () => {
|
||||
const W_DURATION = 0.7
|
||||
const W_TITLE = 0.2
|
||||
const W_AUTHOR = 0.1
|
||||
const DEFAULT_DURATION_SCORE_MISSING_INFO = 0.1
|
||||
|
||||
const libraryItemPerfectDuration = { media: { duration: 600 } } // 10 minutes
|
||||
|
||||
// Helper to calculate expected title/author score based on Levenshtein
|
||||
// Assumes queryPart and bookPart are already "cleaned" for length calculation consistency with BookFinder.js
|
||||
const calculateStringMatchScore = (cleanedQueryPart, cleanedBookPart) => {
|
||||
if (!cleanedQueryPart) return cleanedBookPart ? 0 : 1 // query empty: 1 if book empty, else 0
|
||||
if (!cleanedBookPart) return 0 // query non-empty, book empty: 0
|
||||
|
||||
// Use the imported levenshteinDistance. It defaults to case-insensitive, which is what we want.
|
||||
const distance = levenshteinDistance(cleanedQueryPart, cleanedBookPart)
|
||||
return Math.max(0, 1 - distance / Math.max(cleanedQueryPart.length, cleanedBookPart.length))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
runSearchStub.resolves([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('for audible provider', () => {
|
||||
const provider = 'audible'
|
||||
|
||||
it('should be 1.0 for perfect duration, title, and author match', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
|
||||
// durationScore = 1.0 (diff 0 <= 1 min)
|
||||
// titleScore = 1.0 (exact match)
|
||||
// authorScore = 1.0 (exact match)
|
||||
const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0
|
||||
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
|
||||
})
|
||||
|
||||
it('should correctly score a large duration mismatch', async () => {
|
||||
const bookResults = [{ duration: 21, title: 'The Great Novel', author: 'John Doe' }] // 21 min, diff = 11 min
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
|
||||
// durationScore = 0.0
|
||||
// titleScore = 1.0
|
||||
// authorScore = 1.0
|
||||
const expectedConfidence = W_DURATION * 0.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0
|
||||
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
|
||||
})
|
||||
|
||||
it('should correctly score a medium duration mismatch', async () => {
|
||||
const bookResults = [{ duration: 16, title: 'The Great Novel', author: 'John Doe' }] // 16 min, diff = 6 min
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
|
||||
// durationScore = 1.2 - 6 * 0.12 = 0.48
|
||||
// titleScore = 1.0
|
||||
// authorScore = 1.0
|
||||
const expectedConfidence = W_DURATION * 0.48 + W_TITLE * 1.0 + W_AUTHOR * 1.0
|
||||
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
|
||||
})
|
||||
|
||||
it('should correctly score a minor duration mismatch', async () => {
|
||||
const bookResults = [{ duration: 14, title: 'The Great Novel', author: 'John Doe' }] // 14 min, diff = 4 min
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
|
||||
// durationScore = 1.1 - 4 * 0.1 = 0.7
|
||||
// titleScore = 1.0
|
||||
// authorScore = 1.0
|
||||
const expectedConfidence = W_DURATION * 0.7 + W_TITLE * 1.0 + W_AUTHOR * 1.0
|
||||
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
|
||||
})
|
||||
|
||||
it('should correctly score a tiny duration mismatch', async () => {
|
||||
const bookResults = [{ duration: 11, title: 'The Great Novel', author: 'John Doe' }] // 11 min, diff = 1 min
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
|
||||
// durationScore = 1.0
|
||||
// titleScore = 1.0
|
||||
// authorScore = 1.0
|
||||
const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0
|
||||
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
|
||||
})
|
||||
|
||||
it('should use default duration score if libraryItem duration is missing', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search({ media: {} }, provider, 'The Great Novel', 'John Doe')
|
||||
// durationScore = DEFAULT_DURATION_SCORE_MISSING_INFO (0.2)
|
||||
const expectedConfidence = W_DURATION * DEFAULT_DURATION_SCORE_MISSING_INFO + W_TITLE * 1.0 + W_AUTHOR * 1.0
|
||||
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
|
||||
})
|
||||
|
||||
it('should use default duration score if book duration is missing', async () => {
|
||||
const bookResults = [{ title: 'The Great Novel', author: 'John Doe' }] // No duration in book
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
|
||||
// durationScore = DEFAULT_DURATION_SCORE_MISSING_INFO (0.2)
|
||||
const expectedConfidence = W_DURATION * DEFAULT_DURATION_SCORE_MISSING_INFO + W_TITLE * 1.0 + W_AUTHOR * 1.0
|
||||
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
|
||||
})
|
||||
|
||||
it('should correctly score a partial title match', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'Novel', author: 'John Doe' }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
// Query: 'Novel Ex', Book: 'Novel'
|
||||
// cleanTitleForCompares('Novel Ex') -> 'novel ex' (length 8)
|
||||
// cleanTitleForCompares('Novel') -> 'novel' (length 5)
|
||||
// levenshteinDistance('novel ex', 'novel') = 3
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'Novel Ex', 'John Doe')
|
||||
const expectedTitleScore = calculateStringMatchScore('novel ex', 'novel') // 1 - (3/8) = 0.625
|
||||
const expectedConfidence = W_DURATION * 1.0 + W_TITLE * expectedTitleScore + W_AUTHOR * 1.0
|
||||
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
|
||||
})
|
||||
|
||||
it('should correctly score a partial author match (comma-separated)', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'Jane Smith, Jon Doee' }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
// Query: 'Jon Doe', Book part: 'Jon Doee'
|
||||
// cleanAuthorForCompares('Jon Doe') -> 'jon doe' (length 7)
|
||||
// book author part (already lowercased) -> 'jon doee' (length 8)
|
||||
// levenshteinDistance('jon doe', 'jon doee') = 1
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'Jon Doe')
|
||||
// For the author part 'jon doee':
|
||||
const expectedAuthorPartScore = calculateStringMatchScore('jon doe', 'jon doee') // 1 - (1/7)
|
||||
// Assuming 'jane smith' gives a lower or 0 score, max score will be from 'jon doee'
|
||||
const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * expectedAuthorPartScore
|
||||
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
|
||||
})
|
||||
|
||||
it('should give authorScore 0 if query has author but book does not', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', author: null }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
|
||||
// authorScore = 0.0
|
||||
const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 0.0
|
||||
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
|
||||
})
|
||||
|
||||
it('should give authorScore 1.0 if query has no author', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', '') // Empty author
|
||||
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
|
||||
})
|
||||
|
||||
it('handles book author string that is only commas correctly (score 0)', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', author: ',, ,, ,' }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
|
||||
// cleanedQueryAuthorForScore = "john doe"
|
||||
// book.author leads to validBookAuthorParts being empty.
|
||||
// authorScore = 0.0
|
||||
const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 0.0
|
||||
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
|
||||
})
|
||||
|
||||
it('should return 1.0 for ASIN results', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'B000F28ZJ4', null)
|
||||
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
|
||||
})
|
||||
|
||||
it('should return 1.0 when author matches one of the book authors', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe, Jane Smith' }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
|
||||
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
|
||||
})
|
||||
|
||||
it('should return 1.0 when author query and multiple book authors are the same', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe, Jane Smith' }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe, Jane Smith')
|
||||
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
|
||||
})
|
||||
|
||||
it('should correctly score against a book with a subtitle when the query has a subtitle', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', subtitle: 'A Novel', author: 'John Doe' }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel: A Novel', 'John Doe')
|
||||
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
|
||||
})
|
||||
|
||||
it('should correctly score against a book with a subtitle when the query does not have a subtitle', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', subtitle: 'A Novel', author: 'John Doe' }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
|
||||
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
|
||||
})
|
||||
|
||||
describe('after fuzzy searches', () => {
|
||||
it('should return 1.0 for a title candidate match', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
|
||||
runSearchStub.resolves([])
|
||||
runSearchStub.withArgs('the great novel', 'john doe').resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel - A Novel', 'John Doe')
|
||||
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
|
||||
})
|
||||
|
||||
it('should return 1.0 for an author candidate match', async () => {
|
||||
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
|
||||
runSearchStub.resolves([])
|
||||
runSearchStub.withArgs('the great novel', 'john doe').resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe, Jane Smith')
|
||||
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('for non-audible provider (e.g., google)', () => {
|
||||
const provider = 'google'
|
||||
it('should have not have matchConfidence', async () => {
|
||||
const bookResults = [{ title: 'The Great Novel', author: 'John Doe' }]
|
||||
runSearchStub.resolves(bookResults)
|
||||
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
|
||||
expect(results[0]).to.not.have.property('matchConfidence')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user