Compare commits

...

67 Commits

Author SHA1 Message Date
advplyr
f9a668cb41 Version bump 2.2.20 2023-05-05 17:03:42 -05:00
advplyr
c848f366de Update:Audio file disc meta tag support for TPA #1749 2023-05-03 17:33:01 -05:00
advplyr
25daab2f34 Update:Show publisher on book page #1751 2023-05-03 17:29:54 -05:00
advplyr
7170ab7239 Add:Dutch language option 2023-05-03 07:52:32 -05:00
advplyr
063b3bb8db Merge pull request #1747 from 92Kev/Add-Dutch
Add Dutch translation
2023-05-03 07:43:55 -05:00
advplyr
6eb6a7b115 Merge pull request #1750 from pilabor/master
Added `part` to supported tags for `file_tag_seriespart`
2023-05-03 07:43:16 -05:00
Andreas
d0972348b9 Added part to supported tags for file_tag_seriespart
Since `part` is a supported tag for `m4b` files, it's now added as another fallback option.
2023-05-03 10:40:11 +02:00
92Kev
0e70af77c6 Update nl.json
Translated some remaining terms, changed others to more appropriate Dutch translations
2023-05-03 09:40:33 +02:00
92Kev
4efca78602 Merge branch 'advplyr:master' into Add-Dutch 2023-05-03 07:52:51 +02:00
advplyr
87d10bd6f5 Merge pull request #1748 from jkuehnemundt/de-lang
Update de lang file
2023-05-02 15:45:32 -05:00
Jannik Kühnemundt
0f82aed4ce Update de.json 2023-05-02 21:49:03 +02:00
Jannik Kühnemundt
58f10ad7af Update de.json 2023-05-02 21:45:13 +02:00
92Kev
68dcf87aea Update nl.json
Add Dutch translations
2023-05-02 08:55:14 +02:00
92Kev
c2f85deb11 Added Dutch language string file
Initial translation to Dutch from English string file
2023-05-02 08:51:57 +02:00
advplyr
0dd3a52cc8 Add narrator confirm translations 2023-05-01 16:25:01 -05:00
advplyr
c07c73c649 Remove sortablejs 2023-05-01 16:24:31 -05:00
advplyr
dbde5f773c Merge pull request #1745 from springsunx/patch-1
update zh-cn translation
2023-05-01 14:45:08 -05:00
SunX
68bf038205 update zh-cn translation 2023-05-01 11:29:33 +08:00
advplyr
eb7f66c89e Add:Narrators page #860 #1139 2023-04-30 14:11:54 -05:00
advplyr
58ebde2982 Update:Podcast episode audio files More Info option 2023-04-30 09:45:28 -05:00
advplyr
604a671549 Update:Show tags and podcast type on library item page 2023-04-30 09:41:49 -05:00
advplyr
5286b53334 Add:Progress bar on series covers #1734 2023-04-29 16:26:56 -05:00
advplyr
4db26f9f79 Add:Log user and ip on successful login #1740 2023-04-28 16:16:47 -05:00
advplyr
ff8a58c7bc Remove log about not modifying permissions 2023-04-28 16:08:57 -05:00
advplyr
6f67c7bfa2 Merge pull request #1686 from divyangbw/feat-user-access-by-tag-enhancement
Invert Tag Selection
2023-04-27 17:20:16 -05:00
advplyr
e9f5bd9bfe Merge branch 'master' into feat-user-access-by-tag-enhancement 2023-04-27 17:18:56 -05:00
advplyr
56e213d654 Update itemTagsSelected migration 2023-04-27 17:18:54 -05:00
advplyr
98323de64c Merge pull request #1736 from divyangbw/fix-fr-json-corrupted
Remove what seems to be a paste error in the french translations
2023-04-27 16:44:13 -05:00
Divyang Joshi
4a13712b1c fix: Remove what seems to be a paste error in the french translations 2023-04-27 17:07:09 -04:00
Divyang Joshi
0387436111 feat: add support for inverting the selection on libraries and tags 2023-04-27 17:02:15 -04:00
advplyr
7685ead000 Merge pull request #1729 from apineiro97/master
update es translation
2023-04-27 04:46:39 -05:00
advplyr
8665d66923 Merge pull request #1730 from Hallo951/master
Update german strings
2023-04-27 04:46:15 -05:00
Hallo951
9a808602c4 Update german strings 2023-04-27 10:41:06 +02:00
Arturo Pineiro
813e553dbb update es translation 2023-04-26 18:20:46 -07:00
advplyr
be050a7d57 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-04-26 18:15:55 -05:00
advplyr
065675697d Fix:Catch exception when failing to download podcast episodes 2023-04-26 18:15:50 -05:00
advplyr
8f6832fc2e Merge pull request #1727 from tomazed/translation-fr
update on pending translation
2023-04-26 10:50:54 -05:00
Tomazed
bdb154a6e5 update on pending translation 2023-04-26 17:39:15 +02:00
advplyr
f557289274 Update:Set lang in HTML tag #1399 2023-04-25 19:00:57 -05:00
advplyr
a5627a1b52 Add:Search for narrators #1495 2023-04-24 18:25:30 -05:00
advplyr
33f20d54cc Updated docker template xml 2023-04-23 17:08:36 -05:00
advplyr
dadd41cb5c Fix:Podcast episode quick match crash #1711 2023-04-21 17:49:25 -05:00
advplyr
35e27e4f61 Merge pull request #1710 from Weldawadyathink/audiobook-covers-2
Add AudiobookCovers.com metadata provider
2023-04-21 16:17:48 -05:00
advplyr
84839bea44 Cleanup audiobookcovers.com addition 2023-04-21 16:17:52 -05:00
Spenser Bushey
1342897858 Removed useless comments 2023-04-20 16:39:04 -07:00
advplyr
c32efb8db8 Fix:Podcast episode search modal search filter #1699 2023-04-20 17:51:06 -05:00
Spenser Bushey
f9ed412e4e Add AudiobookCovers.com metadata provider
AudiobookCovers.com acts as a cover-only metadata provider, therefore will only show up in the covers selector.
2023-04-19 22:13:52 -07:00
advplyr
6ae3ad508e Readme update 2023-04-19 18:03:43 -05:00
advplyr
24af702b41 Merge pull request #1707 from coldshouldermedia/master
Updated SWAG Reverse Proxy guide
2023-04-19 17:59:59 -05:00
advplyr
a57ff20f35 Merge pull request #1692 from divyangbw/fix-show-all-genres-on-match-tab
fix: Make sure all existing genres also show up on the match tab
2023-04-19 17:39:04 -05:00
advplyr
39e710deb1 Merge pull request #1706 from ajaxbits/master
Update:Fix filename issue in tone, bump to v0.1.5
2023-04-19 17:22:24 -05:00
advplyr
3b6fa73ac0 Update tone in debian package to v0.1.5 2023-04-19 17:22:25 -05:00
coldshouldermedia
e2dd66d450 Updated SWAG Reverse Proxy guide 2023-04-19 16:19:29 -06:00
Alex Jackson
b1b53a1eae Update:Bump tone version in devcontainer to v0.1.5 2023-04-19 16:40:46 -05:00
Alex Jackson
6f73345f39 Merge branch 'advplyr:master' into master 2023-04-19 16:24:21 -05:00
Alex Jackson
c7b4b3bd3e Update:Bump tone version
Addresses #1703. Paths with quotations were not handled by tone<v0.1.3.
2023-04-19 16:22:15 -05:00
advplyr
98d543e3e5 Merge pull request #1695 from jkuehnemundt/de-lang-fix
Improve German (de.json) translation
2023-04-18 18:07:43 -05:00
advplyr
4de4e958a0 Merge pull request #1684 from ThinkSalat/bugfix/notification-url-blur
blur the url input when clicking submit to add info currently in input
2023-04-18 18:06:28 -05:00
advplyr
cc5e92ec8e Update client/components/modals/notification/NotificationEditModal.vue 2023-04-18 18:06:22 -05:00
Jannik Kühnemundt
6cb9dfaa85 Update de.json 2023-04-18 17:55:59 +02:00
Jannik Kühnemundt
8790166ac1 Fix further translation 2023-04-18 16:37:34 +02:00
Divyang Joshi
3b97e2146d fix: Make sure all existing genres also show up on the match tab 2023-04-17 20:09:59 -05:00
advplyr
0bb1cf002d Fix:Crash when podcasts put empty spaces with episode file path in RSS feed #1650 2023-04-17 17:03:58 -05:00
advplyr
307c7ebc9d Merge pull request #1688 from Machou/patch-1
Update fr.json
2023-04-17 16:35:31 -05:00
Jannik Kühnemundt
cc1b41995d Fixed german translation 2023-04-17 23:17:40 +02:00
Machou
730d60575e Update fr.json 2023-04-17 22:42:48 +02:00
Shawn Salat
1b96297cc7 blur the url input when clicking submit to add info currently in input 2023-04-17 08:25:20 -06:00
61 changed files with 1751 additions and 365 deletions

View File

@@ -12,4 +12,4 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/*
# Move tone executable to appropriate directory
COPY --from=sandreas/tone:v0.1.2 /usr/local/bin/tone /usr/local/bin/
COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/

View File

@@ -6,7 +6,7 @@ RUN npm ci && npm cache clean --force
RUN npm run generate
### STAGE 1: Build server ###
FROM sandreas/tone:v0.1.2 AS tone
FROM sandreas/tone:v0.1.5 AS tone
FROM node:16-alpine
ENV NODE_ENV=production

View File

@@ -50,7 +50,7 @@ install_ffmpeg() {
echo "Starting FFMPEG Install"
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.2/tone-0.1.2-linux-x64.tar.gz --output-document=tone-0.1.2-linux-x64.tar.gz"
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.5/tone-0.1.5-linux-x64.tar.gz --output-document=tone-0.1.5-linux-x64.tar.gz"
if ! cd "$FFMPEG_INSTALL_DIR"; then
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
@@ -66,8 +66,8 @@ install_ffmpeg() {
# Temp downloading tone library to the ffmpeg dir
echo "Getting tone.."
$WGET_TONE
tar xvf tone-0.1.2-linux-x64.tar.gz --strip-components=1
rm tone-0.1.2-linux-x64.tar.gz
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1
rm tone-0.1.5-linux-x64.tar.gz
echo "Good to go on Ffmpeg (& tone)... hopefully"
}

View File

@@ -112,7 +112,7 @@ input[type=number] {
background-color: #373838;
}
.tracksTable tr:hover {
.tracksTable tr:hover:not(:has(th)) {
background-color: #474747;
}

View File

@@ -28,6 +28,9 @@
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-authors-slider>
<widgets-narrators-slider v-else-if="shelf.type === 'narrators'" :key="index + '.'" :items="shelf.entities" :height="100 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-narrators-slider>
</template>
</div>
<!-- Regular bookshelf view -->
@@ -185,8 +188,8 @@ export default {
this.shelves = categories
},
async setShelvesFromSearch() {
var shelves = []
if (this.results.books && this.results.books.length) {
const shelves = []
if (this.results.books?.length) {
shelves.push({
id: 'books',
label: 'Books',
@@ -196,7 +199,7 @@ export default {
})
}
if (this.results.podcasts && this.results.podcasts.length) {
if (this.results.podcasts?.length) {
shelves.push({
id: 'podcasts',
label: 'Podcasts',
@@ -206,7 +209,7 @@ export default {
})
}
if (this.results.series && this.results.series.length) {
if (this.results.series?.length) {
shelves.push({
id: 'series',
label: 'Series',
@@ -221,7 +224,7 @@ export default {
})
})
}
if (this.results.tags && this.results.tags.length) {
if (this.results.tags?.length) {
shelves.push({
id: 'tags',
label: 'Tags',
@@ -236,7 +239,7 @@ export default {
})
})
}
if (this.results.authors && this.results.authors.length) {
if (this.results.authors?.length) {
shelves.push({
id: 'authors',
label: 'Authors',
@@ -250,6 +253,20 @@ export default {
})
})
}
if (this.results.narrators?.length) {
shelves.push({
id: 'narrators',
label: 'Narrators',
labelStringKey: 'LabelNarrators',
type: 'narrators',
entities: this.results.narrators.map((n) => {
return {
...n,
type: 'narrator'
}
})
})
}
this.shelves = shelves
},
scan() {

View File

@@ -41,6 +41,11 @@
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
</template>
</div>
<div v-if="shelf.type === 'narrators'" class="flex items-center">
<template v-for="entity in shelf.entities">
<cards-narrator-card :key="entity.name" :width="150" :height="100" :narrator="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
</template>
</div>
</div>
</div>
@@ -88,6 +93,7 @@ export default {
return this.bookCoverWidth * this.bookCoverAspectRatio
},
shelfHeight() {
if (this.shelf.type === 'narrators') return 148
return this.bookCoverHeight + 48
},
paddingLeft() {

View File

@@ -49,6 +49,14 @@
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2.5xl">queue_music</span>
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path
@@ -62,6 +70,14 @@
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2xl">record_voice_over</span>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="abs-icons icon-podcast text-xl"></span>
@@ -78,14 +94,6 @@
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2.5xl">queue_music</span>
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2xl">file_download</span>
@@ -178,6 +186,9 @@ export default {
isAuthorsPage() {
return this.$route.name === 'library-library-authors'
},
isNarratorsPage() {
return this.$route.name === 'library-library-narrators'
},
isPlaylistsPage() {
return this.paramId === 'playlists'
},

View File

@@ -10,7 +10,7 @@
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
</div>
</div>
</template>
@@ -67,12 +67,13 @@ export default {
// but with removing commas periods etc this is no longer plausible
const html = this.matchText
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">${this.$strings.LabelSeries}: ${html}</p>`
if (this.matchKey === 'narrators') return `<p class="truncate">${this.$strings.LabelNarrator}: ${html}</p>`
return `${html}`
}
},

View File

@@ -7,7 +7,7 @@
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
@@ -85,13 +85,13 @@ export default {
case 'addedAt':
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
case 'totalDuration':
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
case 'lastBookUpdated':
const lastUpdated = Math.max(...(this.books).map(x => x.updatedAt), 0)
const lastUpdated = Math.max(...this.books.map((x) => x.updatedAt), 0)
return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`
case 'lastBookAdded':
const lastBookAdded = Math.max(...(this.books).map(x => x.addedAt), 0)
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
const lastBookAdded = Math.max(...this.books.map((x) => x.addedAt), 0)
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
default:
return null
}
@@ -115,6 +115,14 @@ export default {
seriesBooksFinished() {
return this.seriesBookProgress.filter((p) => p.isFinished)
},
hasSeriesBookInProgress() {
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
},
seriesPercentInProgress() {
let totalFinishedAndInProgress = this.seriesBooksFinished.length
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
},
isSeriesFinished() {
return this.books.length === this.seriesBooksFinished.length
},

View File

@@ -0,0 +1,50 @@
<template>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-40">
<span class="material-icons-outlined text-[10rem]">record_voice_over</span>
</div>
<!-- Narrator name & num books overlay -->
<div class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
</div>
</div>
</nuxt-link>
</template>
<script>
export default {
props: {
narrator: {
type: Object,
default: () => {}
},
width: Number,
height: Number,
sizeMultiplier: {
type: Number,
default: 1
}
},
data() {
return {}
},
computed: {
name() {
return this.narrator?.name || ''
},
numBooks() {
return this.narrator?.books?.length || 0
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
}
},
methods: {}
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<div class="w-10 h-10 flex items-center justify-center">
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
</div>
<div class="flex-grow px-2 narratorSearchCardContent h-full">
<p class="truncate text-sm">{{ narrator }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
narrator: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style scoped>
.narratorSearchCardContent {
width: calc(100% - 40px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div>
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(narrator, index) in narrators">
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
><span :key="index" v-if="index < narrators.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="publishedYear" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
</div>
<div>
{{ publishedYear }}
</div>
</div>
<div v-if="publisher" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
</div>
<div>
{{ publisher }}
</div>
</div>
<div v-if="musicAlbum" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
</div>
<div>
{{ musicAlbum }}
</div>
</div>
<div v-if="musicAlbumArtist" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
</div>
<div>
{{ musicAlbumArtist }}
</div>
</div>
<div v-if="musicTrackPretty" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
</div>
<div>
{{ musicTrackPretty }}
</div>
</div>
<div v-if="musicDiscPretty" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
</div>
<div>
{{ musicDiscPretty }}
</div>
</div>
<div v-if="podcastType" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
</div>
<div class="capitalize">
{{ podcastType }}
</div>
</div>
<div class="flex py-0.5" v-if="genres.length">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(genre, index) in genres">
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
><span :key="index" v-if="index < genres.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div class="flex py-0.5" v-if="tags.length">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(tag, index) in tags">
<nuxt-link :key="tag" :to="`/library/${libraryId}/bookshelf?filter=tags.${$encode(tag)}`" class="hover:underline">{{ tag }}</nuxt-link
><span :key="index" v-if="index < tags.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
</div>
<div>
{{ durationPretty }}
</div>
</div>
<div class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
</div>
<div>
{{ sizePretty }}
</div>
</div>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
libraryId() {
return this.libraryItem.libraryId
},
isPodcast() {
return this.libraryItem.mediaType === 'podcast'
},
audioFile() {
// Music track
return this.media.audioFile
},
media() {
return this.libraryItem.media || {}
},
tracks() {
return this.media.tracks || []
},
podcastEpisodes() {
return this.media.episodes || []
},
mediaMetadata() {
return this.media.metadata || {}
},
publishedYear() {
return this.mediaMetadata.publishedYear
},
genres() {
return this.mediaMetadata.genres || []
},
tags() {
return this.media.tags || []
},
podcastAuthor() {
return this.mediaMetadata.author || ''
},
authors() {
return this.mediaMetadata.authors || []
},
publisher() {
return this.mediaMetadata.publisher || ''
},
musicArtists() {
return this.mediaMetadata.artists || []
},
musicAlbum() {
return this.mediaMetadata.album || ''
},
musicAlbumArtist() {
return this.mediaMetadata.albumArtist || ''
},
musicTrackPretty() {
if (!this.mediaMetadata.trackNumber) return null
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
},
musicDiscPretty() {
if (!this.mediaMetadata.discNumber) return null
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
},
narrators() {
return this.mediaMetadata.narrators || []
},
durationPretty() {
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
if (!this.tracks.length && !this.audioFile) return 'N/A'
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
return this.$elapsedPretty(this.duration)
},
duration() {
if (!this.tracks.length && !this.audioFile) return 0
return this.media.duration
},
totalPodcastDuration() {
if (!this.podcastEpisodes.length) return 0
let totalDuration = 0
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
return totalDuration
},
sizePretty() {
return this.$bytesPretty(this.media.size)
},
podcastType() {
return this.mediaMetadata.type
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -63,6 +63,15 @@
</nuxt-link>
</li>
</template>
<p v-if="narratorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelNarrators }}</p>
<template v-for="narrator in narratorResults">
<li :key="narrator.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
<cards-narrator-search-card :narrator="narrator.name" />
</nuxt-link>
</li>
</template>
</template>
</ul>
</div>
@@ -84,6 +93,7 @@ export default {
authorResults: [],
seriesResults: [],
tagResults: [],
narratorResults: [],
searchTimeout: null,
lastSearch: null
}
@@ -114,6 +124,7 @@ export default {
this.authorResults = []
this.seriesResults = []
this.tagResults = []
this.narratorResults = []
this.showMenu = false
this.isFetching = false
this.isTyping = false
@@ -142,7 +153,7 @@ export default {
}
this.isFetching = true
var searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
console.error('Search error', error)
return []
})
@@ -155,6 +166,7 @@ export default {
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []
this.tagResults = searchResults.tags || []
this.narratorResults = searchResults.narrators || []
this.isFetching = false
if (!this.showMenu) {

View File

@@ -6,7 +6,7 @@
</div>
</template>
<form @submit.prevent="submitForm">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
<div class="w-full p-8">
<div class="flex py-2">
<div class="w-1/2 px-2">
@@ -96,7 +96,14 @@
</div>
</div>
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" :label="$strings.LabelTagsAccessibleToUser" />
<div class="flex items-center">
<ui-multi-select-dropdown v-model="newUser.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
<div class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
</div>
</div>
</div>
</div>
@@ -185,6 +192,9 @@ export default {
value: t
}
})
},
tagsSelectionText() {
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
}
},
methods: {
@@ -193,8 +203,11 @@ export default {
if (this.$refs.modal) this.$refs.modal.setHide()
},
accessAllTagsToggled(val) {
if (val && this.newUser.itemTagsAccessible.length) {
this.newUser.itemTagsAccessible = []
if (val) {
if (this.newUser.itemTagsSelected?.length) {
this.newUser.itemTagsSelected = []
}
this.newUser.permissions.selectedTagsNotAccessible = false
}
},
fetchAllTags() {
@@ -226,7 +239,7 @@ export default {
this.$toast.error('Must select at least one library')
return
}
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {
this.$toast.error('Must select at least one tag')
return
}
@@ -307,12 +320,12 @@ export default {
delete: type === 'admin',
upload: type === 'admin',
accessAllLibraries: true,
accessAllTags: true
accessAllTags: true,
selectedTagsNotAccessible: false
}
},
init() {
this.fetchAllTags()
this.isNew = !this.account
if (this.account) {
this.newUser = {
@@ -322,9 +335,10 @@ export default {
isActive: this.account.isActive,
permissions: { ...this.account.permissions },
librariesAccessible: [...(this.account.librariesAccessible || [])],
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
}
} else {
this.fetchAllTags()
this.newUser = {
username: null,
password: null,
@@ -336,7 +350,8 @@ export default {
delete: false,
upload: false,
accessAllLibraries: true,
accessAllTags: true
accessAllTags: true,
selectedTagsNotAccessible: false
},
librariesAccessible: []
}

View File

@@ -49,13 +49,13 @@
</div>
<form @submit.prevent="submitSearchForm">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
<div class="w-48 px-1">
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
</div>
<div v-show="provider != 'itunes'" class="w-72 px-1">
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
@@ -128,7 +128,7 @@ export default {
},
providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
return [...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
},
searchTitleLabel() {
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN

View File

@@ -115,7 +115,7 @@
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-multi-select v-model="selectedMatch.genres" :items="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
<ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.genres.join(', ') }}</p>
</div>
</div>
@@ -300,6 +300,12 @@ export default {
},
isPodcast() {
return this.mediaType == 'podcast'
},
genres() {
const filterData = this.$store.state.libraries.filterData || {}
const currentGenres = filterData.genres || []
const selectedMatchGenres = this.selectedMatch.genres || []
return [...new Set([...currentGenres ,...selectedMatchGenres])]
}
},
methods: {

View File

@@ -10,7 +10,7 @@
<div class="w-full px-3 py-5 md:p-12">
<ui-dropdown v-model="newNotification.eventName" :label="$strings.LabelNotificationEvent" :items="eventOptions" class="mb-4" @input="eventOptionUpdated" />
<ui-multi-select v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
<ui-multi-select ref="urlsInput" v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
<ui-text-input-with-label v-model="newNotification.titleTemplate" :label="$strings.LabelNotificationTitleTemplate" class="mb-2" />
@@ -103,6 +103,8 @@ export default {
if (this.$refs.modal) this.$refs.modal.setHide()
},
submitForm() {
this.$refs.urlsInput?.forceBlur()
if (!this.newNotification.urls.length) {
this.$toast.error('Must enter an Apprise URL')
return

View File

@@ -6,7 +6,7 @@
</div>
</template>
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<div v-if="episodesCleaned.length" class="w-full py-3 mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
</form>
@@ -16,12 +16,12 @@
v-for="(episode, index) in episodesList"
:key="index"
class="relative"
:class="itemEpisodeMap[episode.enclosure.url?.split('?')[0]] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(index, episode)"
:class="itemEpisodeMap[episode.cleanUrl] ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(episode)"
>
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<span v-if="itemEpisodeMap[episode.enclosure.url?.split('?')[0]]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
<span v-if="itemEpisodeMap[episode.cleanUrl]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" />
</div>
<div class="px-8 py-2">
<div class="flex items-center font-semibold text-gray-200">
@@ -63,6 +63,7 @@ export default {
data() {
return {
processing: false,
episodesCleaned: [],
selectedEpisodes: {},
selectAll: false,
search: null,
@@ -92,7 +93,7 @@ export default {
return this.libraryItem.media.metadata.title || 'Unknown'
},
allDownloaded() {
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]])
return !this.episodesCleaned.some((episode) => !this.itemEpisodeMap[episode.cleanUrl])
},
episodesSelected() {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
@@ -113,7 +114,7 @@ export default {
return map
},
episodesList() {
return this.episodes.filter((episode) => {
return this.episodesCleaned.filter((episode) => {
if (!this.searchText) return true
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
})
@@ -131,31 +132,29 @@ export default {
}, 500)
},
toggleSelectAll(val) {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) this.selectedEpisodes[String(i)] = false
else this.$set(this.selectedEpisodes, String(i), val)
for (const episode of this.episodesCleaned) {
if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
}
},
checkSetIsSelectedAll() {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (!this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]] && !this.selectedEpisodes[String(i)]) {
for (const episode of this.episodesCleaned) {
if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) {
this.selectAll = false
return
}
}
this.selectAll = true
},
toggleSelectEpisode(index, episode) {
toggleSelectEpisode(episode) {
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
this.checkSetIsSelectedAll()
},
submit() {
var episodesToDownload = []
if (this.episodesSelected.length) {
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
episodesToDownload = this.episodesSelected.map((cleanUrl) => this.episodesCleaned.find((ep) => ep.cleanUrl == cleanUrl))
}
var payloadSize = JSON.stringify(episodesToDownload).length
@@ -185,7 +184,15 @@ export default {
})
},
init() {
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
this.episodesCleaned = this.episodes
.filter((ep) => ep.enclosure?.url)
.map((_ep) => {
return {
..._ep,
cleanUrl: _ep.enclosure.url.split('?')[0]
}
})
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
this.selectAll = false
this.selectedEpisodes = {}
}

View File

@@ -31,9 +31,10 @@
<!-- mobile -->
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
</div>
<div v-if="enclosureUrl" class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
<a :href="enclosureUrl" target="_blank" class="text-xs text-blue-400 hover:text-blue-500 hover:underline">{{ enclosureUrl }}</a>
<div v-if="enclosureUrl" class="pb-4 pt-6">
<ui-text-input-with-label :value="enclosureUrl" readonly class="text-xs">
<label class="px-1 text-xs text-gray-200 font-semibold">Episode URL from RSS feed</label>
</ui-text-input-with-label>
</div>
<div v-else class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>

View File

@@ -70,6 +70,9 @@ export default {
return this.libraryItem.libraryFiles || []
},
audioFiles() {
if (this.libraryItem.mediaType === 'podcast') {
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || []
}
return this.libraryItem.media?.audioFiles || []
},
filesWithAudioFile() {

View File

@@ -0,0 +1,100 @@
<template>
<div class="w-full">
<div class="flex items-center py-3">
<slot />
<div class="flex-grow" />
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-icons text-2xl">chevron_left</span>
</button>
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<template v-for="item in items">
<cards-narrator-card :key="item.name" :ref="`slider-item-${item.name}`" :narrator="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @hook:updated="setScrollVars" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 1
}
},
data() {
return {
isScrollable: false,
canScrollLeft: false,
canScrollRight: false,
clientWidth: 0
}
},
computed: {
cardHeight() {
return this.height
},
cardWidth() {
return this.cardHeight * 1.5
},
booksPerPage() {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
}
},
methods: {
scrolled() {
this.setScrollVars()
},
scrollRight() {
if (!this.canScrollRight) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
slider.scrollLeft = newScrollLeft
},
scrollLeft() {
if (!this.canScrollLeft) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
slider.scrollLeft = newScrollLeft
},
setScrollVars() {
const slider = this.$refs.slider
if (!slider) return
const { scrollLeft, scrollWidth, clientWidth } = slider
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
this.clientWidth = clientWidth
this.isScrollable = scrollWidth > clientWidth
this.canScrollRight = scrollPercent < 1
this.canScrollLeft = scrollLeft > 0
}
},
updated() {
this.setScrollVars()
},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@@ -569,6 +569,7 @@ export default {
changeLanguage(code) {
console.log('Changed lang', code)
this.currentLang = code
document.documentElement.lang = code
}
},
beforeMount() {
@@ -593,6 +594,11 @@ export default {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
// Set lang on HTML tag
if (this.$languageCodes?.current) {
document.documentElement.lang = this.$languageCodes.current
}
},
beforeDestroy() {
this.$eventBus.$off('change-lang', this.changeLanguage)

View File

@@ -27,11 +27,7 @@ module.exports = {
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: '' }
],
script: [
{
src: (process.env.ROUTER_BASE_PATH || '') + '/libs/sortable.js'
}
],
script: [],
link: [
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' }
]

View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.2.19",
"version": "2.2.20",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.2.19",
"version": "2.2.20",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.2.19",
"version": "2.2.20",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {

View File

@@ -17,8 +17,8 @@
<ui-text-input v-else v-model="newTagName" />
<div class="flex-grow" />
<template v-if="editingTag !== tag">
<ui-icon-btn v-if="editingTag !== tag" icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editTagClick(tag)" />
<ui-icon-btn v-if="editingTag !== tag" icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeTagClick(tag)" />
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editTagClick(tag)" />
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeTagClick(tag)" />
</template>
<template v-else>
<ui-btn color="success" small class="mx-2" @click.stop="saveTagClick">{{ $strings.ButtonSave }}</ui-btn>

View File

@@ -47,84 +47,7 @@
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
</template>
<div v-if="narrator" class="flex py-0.5 mt-4">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(narrator, index) in narrators">
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
><span :key="index" v-if="index < narrators.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="publishedYear" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
</div>
<div>
{{ publishedYear }}
</div>
</div>
<div v-if="musicAlbum" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
</div>
<div>
{{ musicAlbum }}
</div>
</div>
<div v-if="musicAlbumArtist" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
</div>
<div>
{{ musicAlbumArtist }}
</div>
</div>
<div v-if="musicTrackPretty" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
</div>
<div>
{{ musicTrackPretty }}
</div>
</div>
<div v-if="musicDiscPretty" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
</div>
<div>
{{ musicDiscPretty }}
</div>
</div>
<div class="flex py-0.5" v-if="genres.length">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(genre, index) in genres">
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
><span :key="index" v-if="index < genres.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
</div>
<div>
{{ durationPretty }}
</div>
</div>
<div class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
</div>
<div>
{{ sizePretty }}
</div>
</div>
<content-library-item-details :library-item="libraryItem" />
</div>
<div class="hidden md:block flex-grow" />
</div>
@@ -339,9 +262,6 @@ export default {
libraryId() {
return this.libraryItem.libraryId
},
folderId() {
return this.libraryItem.folderId
},
libraryItemId() {
return this.libraryItem.id
},
@@ -367,19 +287,10 @@ export default {
title() {
return this.mediaMetadata.title || 'No Title'
},
publishedYear() {
return this.mediaMetadata.publishedYear
},
narrator() {
return this.mediaMetadata.narratorName
},
bookSubtitle() {
if (this.isPodcast) return null
return this.mediaMetadata.subtitle
},
genres() {
return this.mediaMetadata.genres || []
},
podcastAuthor() {
return this.mediaMetadata.author || ''
},
@@ -389,25 +300,6 @@ export default {
musicArtists() {
return this.mediaMetadata.artists || []
},
musicAlbum() {
return this.mediaMetadata.album || ''
},
musicAlbumArtist() {
return this.mediaMetadata.albumArtist || ''
},
musicTrackPretty() {
if (!this.mediaMetadata.trackNumber) return null
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
},
musicDiscPretty() {
if (!this.mediaMetadata.discNumber) return null
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
},
narrators() {
return this.mediaMetadata.narrators || []
},
series() {
return this.mediaMetadata.series || []
},
@@ -421,26 +313,10 @@ export default {
}
})
},
durationPretty() {
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
if (!this.tracks.length && !this.audioFile) return 'N/A'
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
return this.$elapsedPretty(this.duration)
},
duration() {
if (!this.tracks.length && !this.audioFile) return 0
return this.media.duration
},
totalPodcastDuration() {
if (!this.podcastEpisodes.length) return 0
let totalDuration = 0
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
return totalDuration
},
sizePretty() {
return this.$bytesPretty(this.media.size)
},
libraryFiles() {
return this.libraryItem.libraryFiles || []
},

View File

@@ -0,0 +1,161 @@
<template>
<div class="page relative" :class="streamLibraryItem ? 'streaming' : ''">
<app-book-shelf-toolbar page="narrators" is-home />
<div id="bookshelf" class="w-full h-full px-1 py-4 md:p-8 relative overflow-y-auto">
<table class="tracksTable max-w-2xl mx-auto">
<tr>
<th class="text-left">{{ $strings.LabelName }}</th>
<th class="text-center w-24">{{ $strings.LabelBooks }}</th>
<th v-if="userCanUpdate" class="w-40"></th>
</tr>
<tr v-for="narrator in narrators" :key="narrator.id">
<td>
<p v-if="selectedNarrator?.id !== narrator.id" class="text-sm md:text-base text-gray-100">{{ narrator.name }}</p>
<form v-else @submit.prevent="saveClick">
<ui-text-input v-model="newNarratorName" />
</form>
</td>
<td class="text-center w-24">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${narrator.id}`" class="hover:underline">{{ narrator.numBooks }}</nuxt-link>
</td>
<td v-if="userCanUpdate" class="w-40">
<div class="flex justify-end items-center h-10">
<template v-if="selectedNarrator?.id !== narrator.id">
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editClick(narrator)" />
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeClick(narrator)" />
</template>
<template v-else>
<ui-btn color="success" small class="mr-2" @click.stop="saveClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn small @click.stop="cancelEditClick">{{ $strings.ButtonCancel }}</ui-btn>
</template>
</div>
</td>
</tr>
</table>
</div>
<div v-if="loading" class="absolute top-0 left-0 w-full h-[calc(100%-40px)] mt-10 flex items-center justify-center bg-black/25">
<ui-loading-indicator />
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, params, redirect, query, app }) {
const libraryId = params.library
const libraryData = await store.dispatch('libraries/fetch', libraryId)
if (!libraryData) {
return redirect('/oops?message=Library not found')
}
const library = libraryData.library
if (library.mediaType === 'podcast') {
return redirect(`/library/${libraryId}`)
}
return {
libraryId
}
},
data() {
return {
loading: true,
narrators: [],
selectedNarrator: null,
newNarratorName: null
}
},
computed: {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {
removeClick(narrator) {
const payload = {
message: this.$getString('MessageConfirmRemoveNarrator', [narrator.name]),
callback: (confirmed) => {
if (confirmed) {
this.removeNarrator(narrator.id)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
editClick(narrator) {
this.selectedNarrator = narrator
this.newNarratorName = narrator.name
},
cancelEditClick() {
this.selectedNarrator = null
this.newNarratorName = null
},
saveClick() {
if (!this.selectedNarrator) return
this.newNarratorName = this.newNarratorName?.trim() || ''
if (!this.newNarratorName || this.newNarratorName === this.selectedNarrator.name) {
this.cancelEditClick()
return
}
this.loading = true
this.$axios
.$patch(`/api/libraries/${this.currentLibraryId}/narrators/${this.selectedNarrator.id}`, { name: this.newNarratorName })
.then((data) => {
if (data.updated) {
this.$toast.success(this.$getString('MessageItemsUpdated', [data.updated]))
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
this.cancelEditClick()
this.init()
})
.catch((error) => {
console.error('Failed to updated narrator', error)
this.$toast.error('Failed to update narrator')
this.loading = false
})
},
removeNarrator(id) {
this.loading = true
this.$axios
.$delete(`/api/libraries/${this.currentLibraryId}/narrators/${id}`)
.then((data) => {
if (data.updated) {
this.$toast.success(this.$getString('MessageItemsUpdated', [data.updated]))
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
this.init()
})
.catch((error) => {
console.error('Failed to remove narrator', error)
this.$toast.error('Failed to remove narrator')
this.loading = false
})
},
async init() {
this.narrators = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/narrators`)
.then((response) => response.narrators)
.catch((error) => {
console.error('Failed to load narrators', error)
return []
})
this.loading = false
}
},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>

View File

@@ -11,27 +11,27 @@
<script>
export default {
async asyncData({ store, params, redirect, query, app }) {
var libraryId = params.library
var library = await store.dispatch('libraries/fetch', libraryId)
const libraryId = params.library
const library = await store.dispatch('libraries/fetch', libraryId)
if (!library) {
return redirect('/oops?message=Library not found')
}
var query = query.q
var results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query}`).catch((error) => {
let results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query.q}`).catch((error) => {
console.error('Failed to search library', error)
return null
})
results = {
podcasts: results && results.podcast ? results.podcast : null,
books: results && results.book ? results.book : null,
authors: results && results.authors.length ? results.authors : null,
series: results && results.series.length ? results.series : null,
tags: results && results.tags.length ? results.tags : null
podcasts: results?.podcast || [],
books: results?.book || [],
authors: results?.authors || [],
series: results?.series || [],
tags: results?.tags || [],
narrators: results?.narrators || []
}
return {
libraryId,
results,
query
query: query.q
}
},
data() {
@@ -55,16 +55,17 @@ export default {
},
methods: {
async search() {
var results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${this.query}`).catch((error) => {
const results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${this.query}`).catch((error) => {
console.error('Failed to search library', error)
return null
})
this.results = {
podcasts: results && results.podcast ? results.podcast : null,
books: results && results.book ? results.book : null,
authors: results && results.authors.length ? results.authors : null,
series: results && results.series.length ? results.series : null,
tags: results && results.tags.length ? results.tags : null
podcasts: results?.podcast || [],
books: results?.book || [],
authors: results?.authors || [],
series: results?.series || [],
tags: results?.tags || [],
narrators: results?.narrators || []
}
this.$nextTick(() => {
if (this.$refs.bookshelf) {

View File

@@ -11,6 +11,7 @@ const languageCodeMap = {
'fr': { label: 'Français', dateFnsLocale: 'fr' },
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
'it': { label: 'Italiano', dateFnsLocale: 'it' },
'nl': { label: 'Nederlands', dateFnsLocale: 'nl' },
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
'ru': { label: 'Русский', dateFnsLocale: 'ru' },
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },
@@ -74,10 +75,9 @@ async function loadi18n(code) {
for (const key in Vue.prototype.$strings) {
Vue.prototype.$strings[key] = strings[key] || translations[defaultCode][key]
}
console.log(`dateFnsLocale = ${languageCodeMap[code].dateFnsLocale}`)
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
console.log('i18n strings=', Vue.prototype.$strings)
this.$eventBus.$emit('change-lang', code)
return true
}

View File

File diff suppressed because one or more lines are too long

View File

@@ -63,6 +63,12 @@ export const state = () => ({
text: 'iTunes',
value: 'itunes'
}
],
coverOnlyProviders: [
{
text: 'AudiobookCovers.com',
value: 'audiobookcovers'
}
]
})

View File

@@ -20,7 +20,7 @@
"ButtonCreate": "Erstellen",
"ButtonCreateBackup": "Sicherung erstellen",
"ButtonDelete": "Löschen",
"ButtonDownloadQueue": "Queue",
"ButtonDownloadQueue": "Warteschlange",
"ButtonEdit": "Bearbeiten",
"ButtonEditChapters": "Kapitel bearbeiten",
"ButtonEditPodcast": "Podcast bearbeiten",
@@ -93,9 +93,9 @@
"HeaderCollection": "Sammlungen",
"HeaderCollectionItems": "Sammlungseinträge",
"HeaderCover": "Titelbild",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderCurrentDownloads": "Aktuelle Downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue",
"HeaderDownloadQueue": "Download Warteschlange",
"HeaderEpisodes": "Episoden",
"HeaderFiles": "Dateien",
"HeaderFindChapters": "Kapitel suchen",
@@ -142,8 +142,8 @@
"HeaderSettingsGeneral": "Allgemein",
"HeaderSettingsScanner": "Scanner",
"HeaderSleepTimer": "Einschlaf-Timer",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Längste Einträge (h)",
"HeaderStatsLargestItems": "Größte Medien",
"HeaderStatsLongestItems": "Längste Medien (h)",
"HeaderStatsMinutesListeningChart": "Hörminuten (letzte 7 Tage)",
"HeaderStatsRecentSessions": "Neueste Ereignisse",
"HeaderStatsTop10Authors": "Top 10 Autoren",
@@ -161,7 +161,7 @@
"LabelAccountTypeGuest": "Gast",
"LabelAccountTypeUser": "Benutzer",
"LabelActivity": "Aktivitäten",
"LabelAdded": "Added",
"LabelAdded": "Hinzugefügt",
"LabelAddedAt": "Hinzugefügt am",
"LabelAddToCollection": "Zur Sammlung hinzufügen",
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
@@ -169,7 +169,7 @@
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
"LabelAll": "Alle",
"LabelAllUsers": "Alle Benutzer",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAlreadyInYourLibrary": "In der Bibliothek vorhanden",
"LabelAppend": "Anhängen",
"LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (Vorname Nachname)",
@@ -186,7 +186,7 @@
"LabelBitrate": "Bitrate",
"LabelBooks": "Bücher",
"LabelChangePassword": "Passwort ändern",
"LabelChannels": "Channels",
"LabelChannels": "Kanäle",
"LabelChapters": "Chapters",
"LabelChaptersFound": "gefundene Kapitel",
"LabelChapterTitle": "Kapitelüberschrift",
@@ -201,10 +201,10 @@
"LabelCover": "Titelbild",
"LabelCoverImageURL": "URL des Titelbildes",
"LabelCreatedAt": "Erstellt am",
"LabelCronExpression": "Cron Ausdruck",
"LabelCronExpression": "Cron-Ausdruck",
"LabelCurrent": "Aktuell",
"LabelCurrently": "Aktuell:",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck",
"LabelDatetime": "Datum & Uhrzeit",
"LabelDescription": "Beschreibung",
"LabelDeselectAll": "Alles abwählen",
@@ -217,13 +217,13 @@
"LabelDuration": "Laufzeit",
"LabelDurationFound": "Gefundene Laufzeit:",
"LabelEdit": "Bearbeiten",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEmbeddedCover": "Eingebettetes Cover",
"LabelEnable": "Aktivieren",
"LabelEnd": "Ende",
"LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episodentitel",
"LabelEpisodeType": "Episodentyp",
"LabelExample": "Example",
"LabelExample": "Beispiel",
"LabelExplicit": "Explizit (Altersbeschränkung)",
"LabelFeedURL": "Feed URL",
"LabelFile": "Datei",
@@ -254,11 +254,12 @@
"LabelIntervalEveryDay": "Jeden Tag",
"LabelIntervalEveryHour": "Jede Stunde",
"LabelInvalidParts": "Ungültige Teile",
"LabelInvert": "Invert",
"LabelItem": "Medium",
"LabelLanguage": "Sprache",
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastBookAdded": "Zuletzt hinzugefügtes Medium",
"LabelLastBookUpdated": "Zuletzt aktualisiertes Medium",
"LabelLastSeen": "Zuletzt angesehen",
"LabelLastTime": "Letztes Mal",
"LabelLastUpdate": "Letzte Aktualisierung",
@@ -290,8 +291,8 @@
"LabelNewestAuthors": "Neuste Autoren",
"LabelNewestEpisodes": "Neueste Episoden",
"LabelNewPassword": "Neues Passwort",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNextBackupDate": "Nächstes Sicherungsdatum",
"LabelNextScheduledRun": "Nächster planmäßiger Durchlauf",
"LabelNotes": "Hinweise",
"LabelNotFinished": "nicht beendet",
"LabelNotificationAppriseURL": "Apprise URL(s)",
@@ -322,9 +323,9 @@
"LabelPlayMethod": "Abspielmethode",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPodcastType": "Podcast Typ",
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPreventIndexing": "Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird",
"LabelProgress": "Fortschritt",
"LabelProvider": "Anbieter",
"LabelPubDate": "Veröffentlichungsdatum",
@@ -332,14 +333,14 @@
"LabelPublishYear": "Jahr",
"LabelRecentlyAdded": "Kürzlich hinzugefügt",
"LabelRecentSeries": "Aktuelle Serien",
"LabelRecommended": "Recommended",
"LabelRecommended": "Empfohlen",
"LabelRegion": "Region",
"LabelReleaseDate": "Veröffentlichungsdatum",
"LabelRemoveCover": "Lösche Titelbild",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail",
"LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers",
"LabelRSSFeedOpen": "RSS Feed Offen",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedPreventIndexing": "Indizierung verhindern",
"LabelRSSFeedSlug": "RSS Feed Schlagwort",
"LabelRSSFeedURL": "RSS Feed URL",
"LabelSearchTerm": "Begriff suchen",
@@ -384,7 +385,7 @@
"LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.",
"LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern",
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.",
"LabelSettingsTimeFormat": "Time Format",
"LabelSettingsTimeFormat": "Zeitformat",
"LabelShowAll": "Alles anzeigen",
"LabelSize": "Größe",
"LabelSleepTimer": "Einschlaf-Timer",
@@ -412,8 +413,9 @@
"LabelTag": "Schlagwort",
"LabelTags": "Schlagwörter",
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTagsNotAccessibleToUser": "Für Benutzer nicht zugängliche Schlagwörter",
"LabelTasks": "Laufende Aufgaben",
"LabelTimeBase": "Basiszeit",
"LabelTimeListened": "Gehörte Zeit",
"LabelTimeListenedToday": "Heute gehörte Zeit",
"LabelTimeRemaining": "{0} verbleibend",
@@ -462,25 +464,26 @@
"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.",
"MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktivieren Sie die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.",
"MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt",
"MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für filter \"{0}: {1}\"",
"MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für Filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet",
"MessageBookshelfNoSeries": "Keine Serien vorhanden",
"MessageChapterEndIsAfter": "Ungültige Kapitelendzeit: Kapitelende > Mediumende (Kapitelende liegt nach dem Ende des Mediums)",
"MessageChapterErrorFirstNotZero": "Ungültige Kapitelstartzeit: Das erste Kapitel muss bei 0 beginnen",
"MessageChapterErrorStartGteDuration": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumlänge (Kapitelanfang liegt zeitlich nach dem Ende des Mediums -> Lösung: Kapitelanfang < Mediumlänge)",
"MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
"MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
"MessageCheckingCron": "Überprüfe cron...",
"MessageCheckingCron": "Überprüfe Cron...",
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
"MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?",
"MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveAllChapters": "Sind Sie sicher, dass Sie alle Kapitel entfernen möchten?",
"MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?",
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
"MessageConfirmRemoveNarrator": "Sind Sie sicher, dass Sie den Erzähler \"{0}\" löschen möchten?",
"MessageConfirmRemovePlaylist": "Sind Sie sicher, dass Sie die Wiedergabeliste \"{0}\" entfernen möchten?",
"MessageConfirmRenameGenre": "Sind Sie sicher, dass Sie die Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
"MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.",
@@ -517,8 +520,8 @@
"MessageNoCollections": "Keine Sammlungen",
"MessageNoCoversFound": "Keine Titelbilder gefunden",
"MessageNoDescription": "Keine Beschreibung",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoDownloadsInProgress": "Derzeit keine Downloads in Arbeit",
"MessageNoDownloadsQueued": "Keine Downloads in der Warteschlange",
"MessageNoEpisodeMatchesFound": "Keine Episodenübereinstimmungen gefunden",
"MessageNoEpisodes": "Keine Episoden",
"MessageNoFoldersAvailable": "Keine Ordner verfügbar",
@@ -535,7 +538,7 @@
"MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"",
"MessageNoSeries": "Keine Serien",
"MessageNoTags": "Keine Tags",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNoTasksRunning": "Keine laufenden Aufgaben",
"MessageNotYetImplemented": "Noch nicht implementiert",
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
@@ -549,7 +552,7 @@
"MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?",
"MessageRemoveChapter": "Kapitel löschen",
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
"MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen Remove from player queue",
"MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen",
"MessageRemoveUserWarning": "Sind Sie sicher, dass Sie den Benutzer \"{0}\" dauerhaft löschen wollen?",
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf",
"MessageResetChaptersConfirm": "Sind Sie sicher, dass Sie die Kapitel zurücksetzen und die vorgenommenen Änderungen rückgängig machen wollen?",
@@ -563,7 +566,7 @@
"MessageUploaderItemFailed": "Hochladen fehlgeschlagen",
"MessageUploaderItemSuccess": "Erfolgreich hochgeladen!",
"MessageUploading": "Hochladen...",
"MessageValidCronExpression": "Gültiger cron-ausdruck",
"MessageValidCronExpression": "Gültiger Cron-Ausdruck",
"MessageWatcherIsDisabledGlobally": "Überwachung ist in den Servereinstellungen global deaktiviert",
"MessageXLibraryIsEmpty": "{0} Bibliothek ist leer!",
"MessageYourAudiobookDurationIsLonger": "Die Dauer Ihres Mediums ist länger als die gefundene Dauer",
@@ -581,7 +584,7 @@
"PlaceholderNewFolderPath": "Neuer Ordnerpfad",
"PlaceholderNewPlaylist": "Neuer Wiedergabelistenname",
"PlaceholderSearch": "Suche...",
"PlaceholderSearchEpisode": "Search episode...",
"PlaceholderSearchEpisode": "Suche Episode...",
"ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen",
"ToastAccountUpdateSuccess": "Konto aktualisiert",
"ToastAuthorImageRemoveFailed": "Bild konnte nicht entfernt werden",
@@ -651,4 +654,4 @@
"ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
"ToastUserDeleteSuccess": "Benutzer gelöscht"
}
}

View File

@@ -254,6 +254,7 @@
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Invalid Parts",
"LabelInvert": "Invert",
"LabelItem": "Item",
"LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language",
@@ -412,6 +413,7 @@
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags Accessible to User",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Time Listened",
@@ -481,6 +483,7 @@
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",

View File

@@ -186,8 +186,8 @@
"LabelBitrate": "Bitrate",
"LabelBooks": "Libros",
"LabelChangePassword": "Cambiar Contraseña",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChannels": "Canales",
"LabelChapters": "Capitulos",
"LabelChaptersFound": "Capitulo Encontrado",
"LabelChapterTitle": "Titulo del Capitulo",
"LabelClosePlayer": "Close player",
@@ -217,7 +217,7 @@
"LabelDuration": "Duración",
"LabelDurationFound": "Duración Comprobada:",
"LabelEdit": "Editar",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEmbeddedCover": "Portada Integrada",
"LabelEnable": "Habilitar",
"LabelEnd": "Fin",
"LabelEpisode": "Episodio",
@@ -235,7 +235,7 @@
"LabelFinished": "Terminado",
"LabelFolder": "Carpeta",
"LabelFolders": "Carpetas",
"LabelFormat": "Format",
"LabelFormat": "Formato",
"LabelGenre": "Genero",
"LabelGenres": "Géneros",
"LabelHardDeleteFile": "Eliminar Definitivamente",
@@ -254,6 +254,7 @@
"LabelIntervalEveryDay": "Cada Dia",
"LabelIntervalEveryHour": "Cada Hora",
"LabelInvalidParts": "Partes Invalidas",
"LabelInvert": "Invert",
"LabelItem": "Elemento",
"LabelLanguage": "Lenguaje",
"LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor",
@@ -282,7 +283,7 @@
"LabelMissing": "Ausente",
"LabelMissingParts": "Partes Ausentes",
"LabelMore": "Mas",
"LabelMoreInfo": "More Info",
"LabelMoreInfo": "Mas Información",
"LabelName": "Nombre",
"LabelNarrator": "Narrador",
"LabelNarrators": "Narradores",
@@ -412,6 +413,7 @@
"LabelTag": "Etiqueta",
"LabelTags": "Etiquetas",
"LabelTagsAccessibleToUser": "Etiquetas Accessible para el Usuario",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tareas Corriendo",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Tiempo Escuchando",
@@ -477,10 +479,11 @@
"MessageConfirmForceReScan": "Esta seguro que desea forzar re-escanear?",
"MessageConfirmMarkSeriesFinished": "Esta seguro que desea marcar todos los libros en esta serie como terminados?",
"MessageConfirmMarkSeriesNotFinished": "Esta seguro que desea marcar todos los libros en esta serie como no terminados?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveAllChapters": "Esta seguro que desea remover todos los capitulos?",
"MessageConfirmRemoveCollection": "Esta seguro que desea remover la colección \"{0}\"?",
"MessageConfirmRemoveEpisode": "Esta seguro que desea remover el episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Esta seguro que desea remover {0} episodios?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Esta seguro que desea remover su lista de reproducción \"{0}\"?",
"MessageConfirmRenameGenre": "Esta seguro que desea renombrar el genero \"{0}\" a \"{1}\" de todos los elementos?",
"MessageConfirmRenameGenreMergeNote": "Nota: Este genero ya existe por lo que se fusionarán.",
@@ -549,7 +552,7 @@
"MessageRemoveAllItemsWarning": "ADVERTENCIA! Esta acción eliminará todos los elementos de la biblioteca de la base de datos incluyendo cualquier actualización o match. Esto no hace nada a sus archivos reales. Esta seguro que desea continuar?",
"MessageRemoveChapter": "Remover capítulos",
"MessageRemoveEpisodes": "Remover {0} episodio(s)",
"MessageRemoveFromPlayerQueue": "Remover de player queue",
"MessageRemoveFromPlayerQueue": "Romover la cola de reporduccion",
"MessageRemoveUserWarning": "Esta seguro que desea eliminar el usuario \"{0}\"?",
"MessageReportBugsAndContribute": "Reporte erres, solicite funciones y contribuye en",
"MessageResetChaptersConfirm": "Esta seguro que desea reiniciar el capitulo y deshacer los cambios que hiciste?",

View File

@@ -20,7 +20,7 @@
"ButtonCreate": "Créer",
"ButtonCreateBackup": "Créer une sauvegarde",
"ButtonDelete": "Effacer",
"ButtonDownloadQueue": "Queue de téléchargement",
"ButtonDownloadQueue": "File dattente de téléchargement",
"ButtonEdit": "Modifier",
"ButtonEditChapters": "Modifier les chapitres",
"ButtonEditPodcast": "Modifier les podcasts",
@@ -93,7 +93,7 @@
"HeaderCollection": "Collection",
"HeaderCollectionItems": "Entrées de la Collection",
"HeaderCover": "Couverture",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderCurrentDownloads": "File dattente de téléchargement",
"HeaderDetails": "Détails",
"HeaderDownloadQueue": "Queue de téléchargement",
"HeaderEpisodes": "Épisodes",
@@ -161,7 +161,7 @@
"LabelAccountTypeGuest": "Invité",
"LabelAccountTypeUser": "Utilisateur",
"LabelActivity": "Activité",
"LabelAdded": "Added",
"LabelAdded": "Ajouté",
"LabelAddedAt": "Date dajout",
"LabelAddToCollection": "Ajouter à la collection",
"LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection",
@@ -186,8 +186,8 @@
"LabelBitrate": "Bitrate",
"LabelBooks": "Livres",
"LabelChangePassword": "Modifier le mot de passe",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChannels": "Canaux",
"LabelChapters": "Chapitres",
"LabelChaptersFound": "Chapitres trouvés",
"LabelChapterTitle": "Titres du chapitre",
"LabelClosePlayer": "Fermer le lecteur",
@@ -217,7 +217,7 @@
"LabelDuration": "Durée",
"LabelDurationFound": "Durée trouvée :",
"LabelEdit": "Modifier",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEmbeddedCover": "Couverture du livre intégrée",
"LabelEnable": "Activer",
"LabelEnd": "Fin",
"LabelEpisode": "Épisode",
@@ -254,11 +254,12 @@
"LabelIntervalEveryDay": "Tous les jours",
"LabelIntervalEveryHour": "Toutes les heures",
"LabelInvalidParts": "Parties invalides",
"LabelInvert": "Invert",
"LabelItem": "Article",
"LabelLanguage": "Langue",
"LabelLanguageDefaultServer": "Langue par défaut",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastBookAdded": "Dernier livre ajouté",
"LabelLastBookUpdated": "Dernier livre mis à jour",
"LabelLastSeen": "Vu dernièrement",
"LabelLastTime": "Progression",
"LabelLastUpdate": "Dernière mise à jour",
@@ -277,12 +278,12 @@
"LabelMediaType": "Type de média",
"LabelMetadataProvider": "Fournisseur de métadonnées",
"LabelMetaTag": "Etiquette de métadonnée",
"LabelMetaTags": "Meta Tags",
"LabelMetaTags": "Etiquettes de métadonnée",
"LabelMinute": "Minute",
"LabelMissing": "Manquant",
"LabelMissingParts": "Parties manquantes",
"LabelMore": "Plus",
"LabelMoreInfo": "More Info",
"LabelMoreInfo": "Plus dinfo",
"LabelName": "Nom",
"LabelNarrator": "Narrateur",
"LabelNarrators": "Narrateurs",
@@ -290,7 +291,7 @@
"LabelNewestAuthors": "Nouveaux auteurs",
"LabelNewestEpisodes": "Derniers épisodes",
"LabelNewPassword": "Nouveau mot de passe",
"LabelNextBackupDate": "Prochaine date de sauvegarde",
"LabelNextBackupDate": "Date de la prochaine sauvegarde",
"LabelNextScheduledRun": "Prochain lancement prévu",
"LabelNotes": "Notes",
"LabelNotFinished": "Non terminé(e)",
@@ -324,7 +325,7 @@
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Type de Podcast",
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPreventIndexing": "Empêcher lindexation de votre flux par les bases de donénes iTunes et Google podcast",
"LabelProgress": "Progression",
"LabelProvider": "Fournisseur",
"LabelPubDate": "Date de publication",
@@ -339,7 +340,7 @@
"LabelRSSFeedCustomOwnerEmail": "Email propriétaire personnalisé",
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
"LabelRSSFeedOpen": "Flux RSS ouvert",
"LabelRSSFeedPreventIndexing": "Empêcher l'indexation",
"LabelRSSFeedPreventIndexing": "Empêcher lindexation",
"LabelRSSFeedSlug": "Identificateur dadresse du Flux RSS ",
"LabelRSSFeedURL": "Adresse du flux RSS",
"LabelSearchTerm": "Terme de recherche",
@@ -384,7 +385,7 @@
"LabelSettingsStoreCoversWithItemHelp": "Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiers de larticle. Seul un fichier nommé « cover » sera conservé.",
"LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles",
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les métadonnées dans le dossier de larticle avec une extension « .abs ».",
"LabelSettingsTimeFormat": "Time Format",
"LabelSettingsTimeFormat": "Format dheure",
"LabelShowAll": "Afficher Tout",
"LabelSize": "Taille",
"LabelSleepTimer": "Minuterie",
@@ -412,8 +413,9 @@
"LabelTag": "Étiquette",
"LabelTags": "Étiquettes",
"LabelTagsAccessibleToUser": "Étiquettes accessibles à lutilisateur",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tâches en cours",
"LabelTimeBase": "Base de temps",
"LabelTimeListened": "Temps découte",
"LabelTimeListenedToday": "Nombres découtes Aujourdhui",
"LabelTimeRemaining": "{0} restantes",
@@ -481,6 +483,7 @@
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer lépisode « {0} » ?",
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?",
"MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » vers « {1} » pour tous les articles ?",
"MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.",
@@ -535,7 +538,7 @@
"MessageNoSearchResultsFor": "Aucun résultat pour la recherche « {0} »",
"MessageNoSeries": "Aucune série",
"MessageNoTags": "Aucune détiquettes",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNoTasksRunning": "Aucune tâche en cours",
"MessageNotYetImplemented": "Non implémenté",
"MessageNoUpdateNecessary": "Aucune mise à jour nécessaire",
"MessageNoUpdatesWereNecessary": "Aucune mise à jour nétait nécessaire",
@@ -581,7 +584,7 @@
"PlaceholderNewFolderPath": "Nouveau chemin de dossier",
"PlaceholderNewPlaylist": "Nouveau nom de liste de lecture",
"PlaceholderSearch": "Recherche...",
"PlaceholderSearchEpisode": "Search episode...",
"PlaceholderSearchEpisode": "Recherche dépisode...",
"ToastAccountUpdateFailed": "Échec de la mise à jour du compte",
"ToastAccountUpdateSuccess": "Compte mis à jour",
"ToastAuthorImageRemoveFailed": "Échec de la suppression de limage",

View File

@@ -254,6 +254,7 @@
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Invalid Parts",
"LabelInvert": "Invert",
"LabelItem": "Item",
"LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language",
@@ -412,6 +413,7 @@
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags Accessible to User",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Time Listened",
@@ -481,6 +483,7 @@
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",

View File

@@ -254,6 +254,7 @@
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Invalid Parts",
"LabelInvert": "Invert",
"LabelItem": "Item",
"LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language",
@@ -412,6 +413,7 @@
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags Accessible to User",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Time Listened",
@@ -481,6 +483,7 @@
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",

View File

@@ -254,6 +254,7 @@
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Nevaljajuči dijelovi",
"LabelInvert": "Invert",
"LabelItem": "Stavka",
"LabelLanguage": "Jezik",
"LabelLanguageDefaultServer": "Default jezik servera",
@@ -412,6 +413,7 @@
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags dostupni korisniku",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Vremena odslušano",
@@ -481,6 +483,7 @@
"MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?",
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",

View File

@@ -254,6 +254,7 @@
"LabelIntervalEveryDay": "Ogni Giorno",
"LabelIntervalEveryHour": "Ogni ora",
"LabelInvalidParts": "Parti Invalide",
"LabelInvert": "Invert",
"LabelItem": "Oggetti",
"LabelLanguage": "Lingua",
"LabelLanguageDefaultServer": "Lingua di Default",
@@ -412,6 +413,7 @@
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Processi in esecuzione",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Tempo di Ascolto",
@@ -481,6 +483,7 @@
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?",
"MessageConfirmRenameGenreMergeNote": "Note: Questo genere esiste già quindi verra unito.",

657
client/strings/nl.json Normal file
View File

@@ -0,0 +1,657 @@
{
"ButtonAdd": "Toevoegen",
"ButtonAddChapters": "Hoofdstukken toevoegen",
"ButtonAddPodcasts": "Podcasts toevoegen",
"ButtonAddYourFirstLibrary": "Voeg je eerste bibliotheek toe",
"ButtonApply": "Pas toe",
"ButtonApplyChapters": "Hoofdstukken toepassen",
"ButtonAuthors": "Auteurs",
"ButtonBrowseForFolder": "Bladeren naar map",
"ButtonCancel": "Annuleren",
"ButtonCancelEncode": "Encoding annuleren",
"ButtonChangeRootPassword": "Root-wachtwoord wijzigen",
"ButtonCheckAndDownloadNewEpisodes": "Check & Download nieuwe afleveringen",
"ButtonChooseAFolder": "Map kiezen",
"ButtonChooseFiles": "Bestanden kiezen",
"ButtonClearFilter": "Filter verwijderen",
"ButtonCloseFeed": "Feed sluiten",
"ButtonCollections": "Collecties",
"ButtonConfigureScanner": "Configureer scanner",
"ButtonCreate": "Creëer",
"ButtonCreateBackup": "Maak back-up",
"ButtonDelete": "Verwijder",
"ButtonDownloadQueue": "Wachtrij",
"ButtonEdit": "Wijzig",
"ButtonEditChapters": "Hoofdstukken wijzigen",
"ButtonEditPodcast": "Podcast wijzigen",
"ButtonForceReScan": "Forceer nieuwe scan",
"ButtonFullPath": "Volledig pad",
"ButtonHide": "Verberg",
"ButtonHome": "Home",
"ButtonIssues": "Issues",
"ButtonLatest": "Meest recent",
"ButtonLibrary": "Bibliotheek",
"ButtonLogout": "Log uit",
"ButtonLookup": "Zoeken",
"ButtonManageTracks": "Beheer tracks",
"ButtonMapChapterTitles": "Hoofdstuktitels mappen",
"ButtonMatchAllAuthors": "Alle auteurs matchen",
"ButtonMatchBooks": "Alle boeken matchen",
"ButtonNevermind": "Laat maar",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Feed openen",
"ButtonOpenManager": "Manager openen",
"ButtonPlay": "Afspelen",
"ButtonPlaying": "Speelt",
"ButtonPlaylists": "Afspeellijsten",
"ButtonPurgeAllCache": "Volledige cache legen",
"ButtonPurgeItemsCache": "Onderdelen-cache legen",
"ButtonPurgeMediaProgress": "Mediavoortgang legen",
"ButtonQueueAddItem": "In wachtrij zetten",
"ButtonQueueRemoveItem": "Uit wachtrij verwijderen",
"ButtonQuickMatch": "Snelle match",
"ButtonRead": "Lees",
"ButtonRemove": "Verwijder",
"ButtonRemoveAll": "Alles verwijderen",
"ButtonRemoveAllLibraryItems": "Verwijder volledige bibliotheekinhoud",
"ButtonRemoveFromContinueListening": "Vewijder uit Verder luisteren",
"ButtonRemoveSeriesFromContinueSeries": "Verwijder serie uit Serie vervolgen",
"ButtonReScan": "Nieuwe scan",
"ButtonReset": "Reset",
"ButtonRestore": "Herstel",
"ButtonSave": "Opslaan",
"ButtonSaveAndClose": "Opslaan & sluiten",
"ButtonSaveTracklist": "Afspeellijst opslaan",
"ButtonScan": "Scan",
"ButtonScanLibrary": "Scan bibliotheek",
"ButtonSearch": "Zoeken",
"ButtonSelectFolderPath": "Maplocatie selecteren",
"ButtonSeries": "Series",
"ButtonSetChaptersFromTracks": "Maak hoofdstukken op basis van tracks",
"ButtonShiftTimes": "Tijden verschuiven",
"ButtonShow": "Toon",
"ButtonStartM4BEncode": "Start M4B-encoding",
"ButtonStartMetadataEmbed": "Start insluiten metadata",
"ButtonSubmit": "Indienen",
"ButtonUpload": "Upload",
"ButtonUploadBackup": "Upload back-up",
"ButtonUploadCover": "Upload cover",
"ButtonUploadOPMLFile": "Upload OPML-bestand",
"ButtonUserDelete": "Verwijder gebruiker {0}",
"ButtonUserEdit": "Wijzig gebruiker {0}",
"ButtonViewAll": "Toon alle",
"ButtonYes": "Ja",
"HeaderAccount": "Account",
"HeaderAdvanced": "Geavanceerd",
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
"HeaderAudiobookTools": "Audioboekbestandbeheer tools",
"HeaderAudioTracks": "Audio tracks",
"HeaderBackups": "Back-ups",
"HeaderChangePassword": "Wachtwoord wijzigen",
"HeaderChapters": "Hoofdstukken",
"HeaderChooseAFolder": "Map kiezen",
"HeaderCollection": "Collectie",
"HeaderCollectionItems": "Collectie-objecten",
"HeaderCover": "Cover",
"HeaderCurrentDownloads": "Huidige downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download-wachtrij",
"HeaderEpisodes": "Afleveringen",
"HeaderFiles": "Bestanden",
"HeaderFindChapters": "Zoek hoofdstukken",
"HeaderIgnoredFiles": "Genegeerde bestanden",
"HeaderItemFiles": "Onderdeel-bestanden",
"HeaderItemMetadataUtils": "Onderdeel-metadata Utils",
"HeaderLastListeningSession": "Laatste luistersessie",
"HeaderLatestEpisodes": "Laatste afleveringen",
"HeaderLibraries": "Bibliotheken",
"HeaderLibraryFiles": "Bibliotheekbestanden",
"HeaderLibraryStats": "Bibliotheekstatistieken",
"HeaderListeningSessions": "Luistersessies",
"HeaderListeningStats": "Luisterstatistieken",
"HeaderLogin": "Login",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Genres beheren",
"HeaderManageTags": "Tags beheren",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match",
"HeaderMetadataToEmbed": "In te sluiten metadata",
"HeaderNewAccount": "Nieuwe account",
"HeaderNewLibrary": "Nieuwe bibliotheek",
"HeaderNotifications": "Notificaties",
"HeaderOpenRSSFeed": "Open RSS-feed",
"HeaderOtherFiles": "Andere bestanden",
"HeaderPermissions": "Toestemmingen",
"HeaderPlayerQueue": "Afspeelwachtrij",
"HeaderPlaylist": "Afspeellijst",
"HeaderPlaylistItems": "Onderdelen in afspeellijst",
"HeaderPodcastsToAdd": "Toe te voegen podcasts",
"HeaderPreviewCover": "Preview cover",
"HeaderRemoveEpisode": "Aflevering verwijderen",
"HeaderRemoveEpisodes": "Verwijder {0} afleveringen",
"HeaderRSSFeedGeneral": "RSS-details",
"HeaderRSSFeedIsOpen": "RSS-feed is open",
"HeaderSavedMediaProgress": "Opgeslagen mediavoortgang",
"HeaderSchedule": "Schema",
"HeaderScheduleLibraryScans": "Schema automatische bibliotheekscans",
"HeaderSession": "Sessie",
"HeaderSetBackupSchedule": "Kies schema voor back-up",
"HeaderSettings": "Instellingen",
"HeaderSettingsDisplay": "Toon",
"HeaderSettingsExperimental": "Experimentele functies",
"HeaderSettingsGeneral": "Algemeen",
"HeaderSettingsScanner": "Scanner",
"HeaderSleepTimer": "Slaaptimer",
"HeaderStatsLargestItems": "Grootste items",
"HeaderStatsLongestItems": "Langste items (uren)",
"HeaderStatsMinutesListeningChart": "Minuten geluisterd (laatste 7 dagen)",
"HeaderStatsRecentSessions": "Recente sessies",
"HeaderStatsTop10Authors": "Top 10 auteurs",
"HeaderStatsTop5Genres": "Top 5 genres",
"HeaderTools": "Tools",
"HeaderUpdateAccount": "Update account",
"HeaderUpdateAuthor": "Update auteur",
"HeaderUpdateDetails": "Update details",
"HeaderUpdateLibrary": "Update bibliotheek",
"HeaderUsers": "Gebruikers",
"HeaderYourStats": "Je statistieken",
"LabelAbridged": "Verkort",
"LabelAccountType": "Accounttype",
"LabelAccountTypeAdmin": "Beheerder",
"LabelAccountTypeGuest": "Gast",
"LabelAccountTypeUser": "Gebruiker",
"LabelActivity": "Activiteit",
"LabelAdded": "Toegevoegd",
"LabelAddedAt": "Toegevoegd op",
"LabelAddToCollection": "Toevoegen aan collectie",
"LabelAddToCollectionBatch": "{0} boeken toevoegen aan collectie",
"LabelAddToPlaylist": "Toevoegen aan afspeellijst",
"LabelAddToPlaylistBatch": "{0} onderdelen toevoegen aan afspeellijst",
"LabelAll": "Alle",
"LabelAllUsers": "Alle gebruikers",
"LabelAlreadyInYourLibrary": "Reeds in je bibliotheek",
"LabelAppend": "Append",
"LabelAuthor": "Auteur",
"LabelAuthorFirstLast": "Auteur (Voornaam Achternaam)",
"LabelAuthorLastFirst": "Auteur (Achternaam, Voornaam)",
"LabelAuthors": "Auteurs",
"LabelAutoDownloadEpisodes": "Afleveringen automatisch downloaden",
"LabelBackToUser": "Terug naar gebruiker",
"LabelBackupsEnableAutomaticBackups": "Automatische back-ups inschakelen",
"LabelBackupsEnableAutomaticBackupsHelp": "Back-ups opgeslagen in /metadata/backups",
"LabelBackupsMaxBackupSize": "Maximale back-up-grootte (in GB)",
"LabelBackupsMaxBackupSizeHelp": "Als een beveiliging tegen verkeerde instelling, zullen back-up mislukken als ze de ingestelde grootte overschrijden.",
"LabelBackupsNumberToKeep": "Aantal te bewaren back-ups",
"LabelBackupsNumberToKeepHelp": "Er wordt slechts 1 back-up per keer verwijderd, dus als je reeds meer back-ups dan dit hebt moet je ze handmatig verwijderen.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Boeken",
"LabelChangePassword": "Wachtwoord wijzigen",
"LabelChannels": "Kanalen",
"LabelChapters": "Hoofdstukken",
"LabelChaptersFound": "Hoofdstukken gevonden",
"LabelChapterTitle": "Hoofdstuktitel",
"LabelClosePlayer": "Sluit speler",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Serie inklappen",
"LabelCollections": "Collecties",
"LabelComplete": "Compleet",
"LabelConfirmPassword": "Bevestig wachtwoord",
"LabelContinueListening": "Verder luisteren",
"LabelContinueSeries": "Ga verder met serie",
"LabelCover": "Cover",
"LabelCoverImageURL": "Coverafbeelding URL",
"LabelCreatedAt": "Gecreëerd op",
"LabelCronExpression": "Cron-uitdrukking",
"LabelCurrent": "Huidig",
"LabelCurrently": "Op dit moment:",
"LabelCustomCronExpression": "Custom Cron-uitdrukking:",
"LabelDatetime": "Datum-tijd",
"LabelDescription": "Beschrijving",
"LabelDeselectAll": "Deselecteer alle",
"LabelDevice": "Apparaat",
"LabelDeviceInfo": "Apparaat info",
"LabelDirectory": "Map",
"LabelDiscFromFilename": "Schijf uit bestandsnaam",
"LabelDiscFromMetadata": "Schijf uit metadata",
"LabelDownload": "Download",
"LabelDuration": "Duur",
"LabelDurationFound": "Gevonden duur:",
"LabelEdit": "Wijzig",
"LabelEmbeddedCover": "Ingesloten cover",
"LabelEnable": "Inschakelen",
"LabelEnd": "Einde",
"LabelEpisode": "Aflevering",
"LabelEpisodeTitle": "Afleveringtitel",
"LabelEpisodeType": "Afleveringtype",
"LabelExample": "Voorbeeld",
"LabelExplicit": "Expliciet",
"LabelFeedURL": "Feed URL",
"LabelFile": "Bestand",
"LabelFileBirthtime": "Aanmaaktijd bestand",
"LabelFileModified": "Bestand gewijzigd",
"LabelFilename": "Bestandsnaam",
"LabelFilterByUser": "Filter op gebruiker",
"LabelFindEpisodes": "Zoek afleveringen",
"LabelFinished": "Voltooid",
"LabelFolder": "Map",
"LabelFolders": "Mappen",
"LabelFormat": "Format",
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard-delete bestand",
"LabelHour": "Uur",
"LabelIcon": "Icoon",
"LabelIncludeInTracklist": "Includeer in tracklijst",
"LabelIncomplete": "Incompleet",
"LabelInProgress": "Bezig",
"LabelInterval": "Interval",
"LabelIntervalCustomDailyWeekly": "Aangepast dagelijks/wekelijks",
"LabelIntervalEvery12Hours": "Iedere 12 uur",
"LabelIntervalEvery15Minutes": "Iedere 15 minuten",
"LabelIntervalEvery2Hours": "Iedere 2 uur",
"LabelIntervalEvery30Minutes": "Iedere 30 minuten",
"LabelIntervalEvery6Hours": "Iedere 6 uur",
"LabelIntervalEveryDay": "Iedere dag",
"LabelIntervalEveryHour": "Ieder uur",
"LabelInvalidParts": "Ongeldige delen",
"LabelInvert": "Omdraaien",
"LabelItem": "Onderdeel",
"LabelLanguage": "Taal",
"LabelLanguageDefaultServer": "Standaard servertaal",
"LabelLastBookAdded": "Laatst toegevoegde boek",
"LabelLastBookUpdated": "Laatst geupdatete boek",
"LabelLastSeen": "Laatst gezien",
"LabelLastTime": "Laatste keer",
"LabelLastUpdate": "Laatste update",
"LabelLess": "Minder",
"LabelLibrariesAccessibleToUser": "Voor gebruiker toegankelijke bibliotheken",
"LabelLibrary": "Bibliotheek",
"LabelLibraryItem": "Library Item",
"LabelLibraryName": "Library Name",
"LabelLimit": "Limiet",
"LabelListenAgain": "Luister opnieuw",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Waarschuwing",
"LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum",
"LabelMediaPlayer": "Mediaspeler",
"LabelMediaType": "Mediaytype",
"LabelMetadataProvider": "Metadatabron",
"LabelMetaTag": "Meta-tag",
"LabelMetaTags": "Meta-tags",
"LabelMinute": "Minuut",
"LabelMissing": "Ontbrekend",
"LabelMissingParts": "Ontbrekende delen",
"LabelMore": "Meer",
"LabelMoreInfo": "Meer info",
"LabelName": "Naam",
"LabelNarrator": "Verteller",
"LabelNarrators": "Vertellers",
"LabelNew": "Nieuw",
"LabelNewestAuthors": "Nieuwste auteurs",
"LabelNewestEpisodes": "Nieuwste afleveringen",
"LabelNewPassword": "Nieuw wachtwoord",
"LabelNextBackupDate": "Volgende back-up datum",
"LabelNextScheduledRun": "Volgende geplande run",
"LabelNotes": "Notities",
"LabelNotFinished": "Niet Voltooid",
"LabelNotificationAppriseURL": "Apprise URL(s)",
"LabelNotificationAvailableVariables": "Beschikbare variabelen",
"LabelNotificationBodyTemplate": "Body-template",
"LabelNotificationEvent": "Notificatie gebeurtenis",
"LabelNotificationsMaxFailedAttempts": "Max mislukte pogingen",
"LabelNotificationsMaxFailedAttemptsHelp": "Notificaties worden uitgeschakeld als verzenden zo vaak mislukt",
"LabelNotificationsMaxQueueSize": "Max rijgrootte voor notificatie gebeurtenissen",
"LabelNotificationsMaxQueueSizeHelp": "Gebeurtenissen zijn beperkt tot 1 aftrap per seconde. Gebeurtenissen zullen genegeerd worden als de rij aan de maximale grootte zit. Dit voorkomt notificatie-spamming.",
"LabelNotificationTitleTemplate": "Titel-template",
"LabelNotStarted": "Niet Gestart",
"LabelNumberOfBooks": "Aantal Boeken",
"LabelNumberOfEpisodes": "# afleveringen",
"LabelOpenRSSFeed": "Open RSS-feed",
"LabelOverwrite": "Overschrijf",
"LabelPassword": "Wachtwoord",
"LabelPath": "Pad",
"LabelPermissionsAccessAllLibraries": "Heeft toegang tot all bibliotheken",
"LabelPermissionsAccessAllTags": "Heeft toegang tot alle tags",
"LabelPermissionsAccessExplicitContent": "Heeft toegang tot expliciete inhoud",
"LabelPermissionsDelete": "Kan verwijderen",
"LabelPermissionsDownload": "Kan downloaden",
"LabelPermissionsUpdate": "Kan updaten",
"LabelPermissionsUpload": "Kan uploaden",
"LabelPhotoPathURL": "Foto pad/URL",
"LabelPlaylists": "Afspeellijsten",
"LabelPlayMethod": "Afspeelwijze",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcasttype",
"LabelPrefixesToIgnore": "Te negeren voorzetsels (ongeacht hoofdlettergebruik)",
"LabelPreventIndexing": "Voorkom indexering van je feed door iTunes- en Google podcastmappen",
"LabelProgress": "Voortgang",
"LabelProvider": "Bron",
"LabelPubDate": "Publicatiedatum",
"LabelPublisher": "Uitgever",
"LabelPublishYear": "Jaar van uitgave",
"LabelRecentlyAdded": "Recent toegevoegd",
"LabelRecentSeries": "Recente series",
"LabelRecommended": "Aangeraden",
"LabelRegion": "Regio",
"LabelReleaseDate": "Verschijningsdatum",
"LabelRemoveCover": "Verwijder cover",
"LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar",
"LabelRSSFeedCustomOwnerName": "Aangepaste naam eigenaar",
"LabelRSSFeedOpen": "RSS-feed open",
"LabelRSSFeedPreventIndexing": "Voorkom indexering",
"LabelRSSFeedSlug": "RSS-feed slug",
"LabelRSSFeedURL": "RSS-feed URL",
"LabelSearchTerm": "Zoekterm",
"LabelSearchTitle": "Zoek titel",
"LabelSearchTitleOrASIN": "Zoek titel of ASIN",
"LabelSeason": "Seizoen",
"LabelSequence": "Sequentie",
"LabelSeries": "Serie",
"LabelSeriesName": "Naam serie",
"LabelSeriesProgress": "Voortgang serie",
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
"LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Datum format",
"LabelSettingsDisableWatcher": "Watcher uitschakelen",
"LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen",
"LabelSettingsDisableWatcherHelp": "Schakelt het automatisch toevoegen/updaten van onderdelen wanneer bestandswijzigingen gedetecteerd zijn uit. *Vereist herstart server",
"LabelSettingsEnableEReader": "E-reader inschakelen voor alle gebruikers",
"LabelSettingsEnableEReaderHelp": "E-reader is nog in ontwikkeling, maar gebruik deze instelling om het beschikbaar te maken voor al je gebruikers (of gebruik de \"Experimentele functies\"-schakelaar voor eigen gebruik)",
"LabelSettingsExperimentalFeatures": "Experimentele functies",
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
"LabelSettingsFindCovers": "Zoek covers",
"LabelSettingsFindCoversHelp": "Als je audioboek geen ingesloten cover of cover in de map heeft, zal de scanner proberen een cover te vinden.<br>Opmerking: Dit zal de scan-duur verlengen",
"LabelSettingsHomePageBookshelfView": "Homepagina gebruikt boekenplank-view",
"LabelSettingsLibraryBookshelfView": "Bibliotheek gebruikt boekenplank-view",
"LabelSettingsOverdriveMediaMarkers": "Gebruik Overdrive media markers voor hoofdstukken",
"LabelSettingsOverdriveMediaMarkersHelp": "MP3-bestanden van Overdrive hebben hoofdstuktiming ingesloten als custom ingesloten metadata. Door dit in te schakelen worden deze tags voor hoofdstuktiming automatisch gebruikt.",
"LabelSettingsParseSubtitles": "Parseer subtitel",
"LabelSettingsParseSubtitlesHelp": "Haal subtitels uit mapnaam van audioboek.<br>Subtitel moet gescheiden zijn met \" - \"<br>b.v. \"Boektitel - Een Subtitel Hier\" heeft als subtitel \"Een Subtitel Hier\"",
"LabelSettingsPreferAudioMetadata": "Prefereer audio-metadata",
"LabelSettingsPreferAudioMetadataHelp": "Audiobestand ID3 metatags zullen worden gebruikt voor boekdetails in plaats van mapnamen",
"LabelSettingsPreferMatchedMetadata": "Prefereer gematchte metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Gematchte data zal onderdeeldetails overschrijven bij gebruik van Quick Match. Standaard vult Quick Match uitsluitend ontbrekende details aan.",
"LabelSettingsPreferOPFMetadata": "Prefereer OPF-metadata",
"LabelSettingsPreferOPFMetadataHelp": "OPF-bestand metadata zal worden gebruik in plaats van mapnamen",
"LabelSettingsSkipMatchingBooksWithASIN": "Sla matchen van boeken over die al over een ASIN beschikken",
"LabelSettingsSkipMatchingBooksWithISBN": "Sla matchen van boeken over die al over een ISBN beschikken",
"LabelSettingsSortingIgnorePrefixes": "Negeer voorvoegsels bij sorteren",
"LabelSettingsSortingIgnorePrefixesHelp": "b.v. voor voorvoegsel \"The\" wordt titel \"The Title\" dan gesorteerd als \"Title, The\"",
"LabelSettingsSquareBookCovers": "Gebruik vierkante boekcovers",
"LabelSettingsSquareBookCoversHelp": "Prefereer gebruik van vierkante covers boven standaard 1.6:1 boekcovers",
"LabelSettingsStoreCoversWithItem": "Bewaar covers bij onderdeel",
"LabelSettingsStoreCoversWithItemHelp": "Standaard worden covers bewaard in /metadata/items, door deze instelling in te schakelen zullen covers in de map van je bibliotheekonderdeel bewaard worden. Slechts een bestand genaamd \"cover\" zal worden bewaard",
"LabelSettingsStoreMetadataWithItem": "Bewaar metadata bij onderdeel",
"LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden. Gebruikt .abs-extensie",
"LabelSettingsTimeFormat": "Tijdformat",
"LabelShowAll": "Toon alle",
"LabelSize": "Grootte",
"LabelSleepTimer": "Slaaptimer",
"LabelStart": "Start",
"LabelStarted": "Gestart",
"LabelStartedAt": "Gestart op",
"LabelStartTime": "Starttijd",
"LabelStatsAudioTracks": "Audiotracks",
"LabelStatsAuthors": "Auteurs",
"LabelStatsBestDay": "Beste dag",
"LabelStatsDailyAverage": "Dagelijks gemiddelde",
"LabelStatsDays": "Dagen",
"LabelStatsDaysListened": "Dagen geluisterd",
"LabelStatsHours": "Uren",
"LabelStatsInARow": "op een rij",
"LabelStatsItemsFinished": "Onderdelen voltooid",
"LabelStatsItemsInLibrary": "Onderdeel in bibliotheek",
"LabelStatsMinutes": "minuten",
"LabelStatsMinutesListening": "Minuten luisterend",
"LabelStatsOverallDays": "Overall dagen",
"LabelStatsOverallHours": "Overall uren",
"LabelStatsWeekListening": "Week luisterend",
"LabelSubtitle": "Subtitel",
"LabelSupportedFileTypes": "Ondersteunde bestandstypes",
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags toegankelijk voor de gebruiker",
"LabelTagsNotAccessibleToUser": "Tags niet toegankelijk voor de gebruiker",
"LabelTasks": "Lopende taken",
"LabelTimeBase": "Tijdsbasis",
"LabelTimeListened": "Tijd geluisterd",
"LabelTimeListenedToday": "Tijd geluisterd vandaag",
"LabelTimeRemaining": "{0} te gaan",
"LabelTimeToShift": "Tijd op te schuiven in seconden",
"LabelTitle": "Titel",
"LabelToolsEmbedMetadata": "Metadata insluiten",
"LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief coverafbeelding en hoofdstukken.",
"LabelToolsMakeM4b": "Maak M4B-audioboekbestand",
"LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, coverafbeelding en hoofdstukken.",
"LabelToolsSplitM4b": "Splits M4B in MP3's",
"LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplits per hoofdstuk met ingesloten metadata, coverafbeelding en hoofdstukken.",
"LabelTotalDuration": "Totale duur",
"LabelTotalTimeListened": "Totale tijd geluisterd",
"LabelTrackFromFilename": "Track vanuit bestandsnaam",
"LabelTrackFromMetadata": "Track vanuit metadata",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Type",
"LabelUnabridged": "Onverkort",
"LabelUnknown": "Onbekend",
"LabelUpdateCover": "Update cover",
"LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden",
"LabelUpdatedAt": "Geüpdatet op",
"LabelUpdateDetails": "Update details",
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
"LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen",
"LabelUploaderDropFiles": "Bestanden neerzetten",
"LabelUseChapterTrack": "Gebruik hoofdstuktrack",
"LabelUseFullTrack": "Gebruik volledige track",
"LabelUser": "Gebruiker",
"LabelUsername": "Gebruikersnaam",
"LabelValue": "Waarde",
"LabelVersion": "Versie",
"LabelViewBookmarks": "Bekijk boekwijzers",
"LabelViewChapters": "Bekijk hoofdstukken",
"LabelViewQueue": "Bekijk afspeelwachtrij",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Weekdagen om te draaien",
"LabelYourAudiobookDuration": "Jouw audioboekduur",
"LabelYourBookmarks": "Jouw boekwijzers",
"LabelYourPlaylists": "Jouw afspeellijsten",
"LabelYourProgress": "Jouw voortgang",
"MessageAddToPlayerQueue": "Toevoegen aan wachtrij",
"MessageAppriseDescription": "Om deze functie te gebruiken heb je een draaiende instantie van <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nodig of een api die dezelfde requests afhandelt. <br />De Apprise API Url moet het volledige URL-pad zijn om de notificatie te verzenden, b.v., als je API-instantie draait op <code>http://192.168.1.1:8337</code> dan zou je <code>http://192.168.1.1:8337/notify</code> gebruiken.",
"MessageBackupsDescription": "Back-ups omvatten gebruikers, gebruikers' voortgang, bibliotheekonderdeeldetails, serverinstellingen en afbeeldingen bewaard in <code>/metadata/items</code> & <code>/metadata/authors</code>. Back-ups <strong>bevatten niet</strong> de bestanden bewaard in je bibliotheekmappen.",
"MessageBatchQuickMatchDescription": "Quick Match zal proberen ontbrekende covers en metadata voor de geselecteerde onderdelen te matchten. Schakel de opties hieronder in om Quick Match toe te staan bestaande covers en/of metadata te overschrijven.",
"MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt",
"MessageBookshelfNoResultsForFilter": "Geen resultaten voo filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend",
"MessageBookshelfNoSeries": "Je hebt geen series",
"MessageChapterEndIsAfter": "Hoofdstukeinde is na het einde van je audioboek",
"MessageChapterErrorFirstNotZero": "Eerste hoofdstuk moet starten op 0",
"MessageChapterErrorStartGteDuration": "Ongeldig: starttijd moet kleiner zijn dan duur van audioboek",
"MessageChapterErrorStartLtPrev": "Ongeldig: starttijd moet be groter zijn dan of equal aan starttijd van vorig hoofdstuk",
"MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek",
"MessageCheckingCron": "Cron aan het checken...",
"MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?",
"MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?",
"MessageConfirmDeleteSession": "Weet je zeker dat je deze sessie wil verwijderen?",
"MessageConfirmForceReScan": "Weet je zeker dat je geforceerd opnieuw wil scannen?",
"MessageConfirmMarkSeriesFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als voltooid?",
"MessageConfirmMarkSeriesNotFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als niet voltooid?",
"MessageConfirmRemoveAllChapters": "Weet je zeker dat je alle hoofdstukken wil verwijderen?",
"MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
"MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?",
"MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?",
"MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{0}\" wil verwijderen?",
"MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?",
"MessageConfirmRenameGenreMergeNote": "Opmerking: Dit genre bestaat al, dus zullen ze worden samengevoegd.",
"MessageConfirmRenameGenreWarning": "Waarschuwing! Een gelijknamig genre met ander hooflettergebruik bestaat al: \"{0}\".",
"MessageConfirmRenameTag": "Weet je zeker dat je tag \"{0}\" wil hernoemen naar\"{1}\" voor alle onderdelen?",
"MessageConfirmRenameTagMergeNote": "Opmerking: Deze tag bestaat al, dus zullen ze worden samengevoegd.",
"MessageConfirmRenameTagWarning": "Waarschuwing! Een gelijknamige tag met ander hooflettergebruik bestaat al: \"{0}\".",
"MessageDownloadingEpisode": "Aflevering aan het dowloaden",
"MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde",
"MessageEmbedFinished": "Insluiting voltooid!",
"MessageEpisodesQueuedForDownload": "{0} aflevering(en) in de rij om te downloaden",
"MessageFeedURLWillBe": "Feed URL zal {0} zijn",
"MessageFetching": "Aan het ophalen...",
"MessageForceReScanDescription": "zall alle bestanden opnieuw scannen als een verse scan. Audiobestanden ID3-tags, OPF-bestanden en textbestanden zullen als nieuw worden gescand.",
"MessageImportantNotice": "Belangrijke opmerking!",
"MessageInsertChapterBelow": "Hoofdstuk hieronder invoegen",
"MessageItemsSelected": "{0} onderdelen geselecteerd",
"MessageItemsUpdated": "{0} onderdelen geüpdatet",
"MessageJoinUsOn": "Doe mee op",
"MessageListeningSessionsInTheLastYear": "{0} luistersessies in het laatste jaar",
"MessageLoading": "Aan het laden...",
"MessageLoadingFolders": "Mappen aan het laden...",
"MessageM4BFailed": "M4B mislukt!",
"MessageM4BFinished": "M4B voltooid!",
"MessageMapChapterTitles": "Map hoofdstuktitels naar je bsetaande audioboekhoofdstukken zonder aanpassing van tijden",
"MessageMarkAsFinished": "Markeer als Voltooid",
"MessageMarkAsNotFinished": "Markeer als Niet Voltooid",
"MessageMatchBooksDescription": "zal proberen boeken in de bibliotheek te matchen met een boek uit de geselecteerde bron en lege details en coverafbeelding te vullen. Overschrijft details niet.",
"MessageNoAudioTracks": "Geen audio tracks",
"MessageNoAuthors": "Geen auteurs",
"MessageNoBackups": "Geen back-ups",
"MessageNoBookmarks": "Geen boekwijzers",
"MessageNoChapters": "Geen hoofdstukken",
"MessageNoCollections": "Geen collecties",
"MessageNoCoversFound": "Geen covers gevonden",
"MessageNoDescription": "Geen beschrijving",
"MessageNoDownloadsInProgress": "Geen downloads bezig op dit moment",
"MessageNoDownloadsQueued": "Geen downloads in de wachtrij",
"MessageNoEpisodeMatchesFound": "Geen afleveringsmatches gevonden",
"MessageNoEpisodes": "Geen afleveringen",
"MessageNoFoldersAvailable": "Geen mappen beschikbaar",
"MessageNoGenres": "Geen genres",
"MessageNoIssues": "Geen issues",
"MessageNoItems": "Geen onderdelen",
"MessageNoItemsFound": "Geen onderdelen gevonden",
"MessageNoListeningSessions": "Geen luistersessies",
"MessageNoLogs": "Geen logs",
"MessageNoMediaProgress": "Geen mediavoortgang",
"MessageNoNotifications": "Geen notificaties",
"MessageNoPodcastsFound": "Geen podcasts gevonden",
"MessageNoResults": "Geen resultaten",
"MessageNoSearchResultsFor": "Geen zoekresultatn voor \"{0}\"",
"MessageNoSeries": "Geen series",
"MessageNoTags": "Geen tags",
"MessageNoTasksRunning": "Geen lopende taken",
"MessageNotYetImplemented": "Nog niet geimplementeerd",
"MessageNoUpdateNecessary": "Geen update noodzakelijk",
"MessageNoUpdatesWereNecessary": "Geen updates waren noodzakelijk",
"MessageNoUserPlaylists": "Je hebt geen afspeellijsten",
"MessageOr": "of",
"MessagePauseChapter": "Pauzeer afspelen hoofdstuk",
"MessagePlayChapter": "Luister naar begin van hoofdstuk",
"MessagePlaylistCreateFromCollection": "Afspeellijst aanmaken vanuit collectie",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast heeft geen RSS-feed URL om te gebruiken voor matching",
"MessageQuickMatchDescription": "Vul lege onderdeeldetails & cover met eerste matchresultaat van '{0}'. Overschrijft geen details tenzij 'Prefereer gematchte metadata' serverinstelling is ingeschakeld.",
"MessageRemoveAllItemsWarning": "WAARSCHUWING! Deze actie zal alle onderdelen in de bibliotheek verwijderen uit de database, inclusief enige updates of matches die je hebt gemaakt. Dit doet niets met je onderliggende bestanden. Weet je het zeker?",
"MessageRemoveChapter": "Verwijder hoofdstuk",
"MessageRemoveEpisodes": "Verwijder {0} aflevering(en)",
"MessageRemoveFromPlayerQueue": "Verwijder uit afspeelwachtrij",
"MessageRemoveUserWarning": "Weet je zeker dat je gebruiker \"{0}\" permanent wil verwijderen?",
"MessageReportBugsAndContribute": "Rapporteer bugs, vraag functionaliteiten aan en draag bij op",
"MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?",
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
"MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle clients die van je server gebruik maken zullen automatisch worden ververst.",
"MessageSearchResultsFor": "Zoekresultaten voor",
"MessageServerCouldNotBeReached": "Server niet bereikbaar",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
"MessageThinking": "Aan het denken...",
"MessageUploaderItemFailed": "Uploaden mislukt",
"MessageUploaderItemSuccess": "Uploaden gelukt!",
"MessageUploading": "Aan het uploaden...",
"MessageValidCronExpression": "Geldige cron-uitdrukking",
"MessageWatcherIsDisabledGlobally": "Watcher is globaal uitgeschakeld in serverinstellingen",
"MessageXLibraryIsEmpty": "{0} bibliotheek is leeg!",
"MessageYourAudiobookDurationIsLonger": "Duur van jouw audioboek is langer dan de gevonden duur",
"MessageYourAudiobookDurationIsShorter": "Duur van jouw audioboek is korter dan de gevonden duur",
"NoteChangeRootPassword": "Root-gebruiker is de enige gebruiker die een leeg wachtwoord kan hebben",
"NoteChapterEditorTimes": "Opmerking: Starttijd van het eerste hoofdstuk moet op 0:00 blijven en de starttijd van het laatste hoofdstuk mag niet de duur van het audioboek overschrijden.",
"NoteFolderPicker": "Opmerking: Reeds gemapte mappen worden niet getoond",
"NoteFolderPickerDebian": "Opmerking: Mappenkiezer voor de debian installatie is niet volledig geimplementeerd. Je moet het pad naar je map zelf invoeren.",
"NoteRSSFeedPodcastAppsHttps": "Waarschuwing: De meeste podcast-apps zullen eisen dat de RSS-feed URL HTTPS gebruikt",
"NoteRSSFeedPodcastAppsPubDate": "Waarschuwing: 1 of meer van je afleveringen hebben geen Pub Datum. Sommige podcast-apps vereisen dit.",
"NoteUploaderFoldersWithMediaFiles": "Mappen met mediabestanden zullen worden behandeld als aparte bibliotheekonderdelen.",
"NoteUploaderOnlyAudioFiles": "Bij uploaden van uitsluitend audiobestanden wordt ieder audiobestand als apart audiobook worden behandeld.",
"NoteUploaderUnsupportedFiles": "Niet-ondersteunde bestanden worden genegeerd. Bij het kiezen of neerzetten van een map worden andere bestanden die niet in de map staan genegeerd.",
"PlaceholderNewCollection": "Nieuwe naam collectie",
"PlaceholderNewFolderPath": "Nieuwe locatie map",
"PlaceholderNewPlaylist": "Nieuwe naam afspeellijst",
"PlaceholderSearch": "Zoeken..",
"PlaceholderSearchEpisode": "Aflevering zoeken..",
"ToastAccountUpdateFailed": "Updaten account mislukt",
"ToastAccountUpdateSuccess": "Account geüpdatet",
"ToastAuthorImageRemoveFailed": "Afbeelding verwijderen mislukt",
"ToastAuthorImageRemoveSuccess": "Afbeelding auteur verwijderd",
"ToastAuthorUpdateFailed": "Updaten auteur mislukt",
"ToastAuthorUpdateMerged": "Auteur samengevoegd",
"ToastAuthorUpdateSuccess": "Auteur geüpdatet",
"ToastAuthorUpdateSuccessNoImageFound": "Auteur geüpdatet (geen afbeelding gevonden)",
"ToastBackupCreateFailed": "Back-up maken mislukt",
"ToastBackupCreateSuccess": "Back-up gemaakt",
"ToastBackupDeleteFailed": "Verwijderen back-up mislukt",
"ToastBackupDeleteSuccess": "Back-up verwijderd",
"ToastBackupRestoreFailed": "Herstellen back-up mislukt",
"ToastBackupUploadFailed": "Uploaden back-up mislukt",
"ToastBackupUploadSuccess": "Back-up geüpload",
"ToastBatchUpdateFailed": "Bulk-update mislukt",
"ToastBatchUpdateSuccess": "Bulk-update gelukt",
"ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt",
"ToastBookmarkCreateSuccess": "boekwijzer toegevoegd",
"ToastBookmarkRemoveFailed": "Verwijderen boekwijzer mislukt",
"ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd",
"ToastBookmarkUpdateFailed": "Updaten boekwijzer mislukt",
"ToastBookmarkUpdateSuccess": "Boekwijzer geüpdatet",
"ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten",
"ToastChaptersMustHaveTitles": "Hoofdstukken moeten titels hebben",
"ToastCollectionItemsRemoveFailed": "Verwijderen onderdeel (of onderdelen) uit collectie mislukt",
"ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie",
"ToastCollectionRemoveFailed": "Verwijderen collectie mislukt",
"ToastCollectionRemoveSuccess": "Collectie verwijderd",
"ToastCollectionUpdateFailed": "Updaten collectie mislukt",
"ToastCollectionUpdateSuccess": "Collectie geüpdatet",
"ToastItemCoverUpdateFailed": "Updaten cover onderdeel mislukt",
"ToastItemCoverUpdateSuccess": "Cover onderdeel geüpdatet",
"ToastItemDetailsUpdateFailed": "Updaten details onderdeel mislukt",
"ToastItemDetailsUpdateSuccess": "Details onderdeel geüpdatet",
"ToastItemDetailsUpdateUnneeded": "Geen updates nodig voor details onderdeel",
"ToastItemMarkedAsFinishedFailed": "Markeren als Voltooid mislukt",
"ToastItemMarkedAsFinishedSuccess": "Onderdeel gemarkeerd als Voltooid",
"ToastItemMarkedAsNotFinishedFailed": "Markeren als Niet Voltooid mislukt",
"ToastItemMarkedAsNotFinishedSuccess": "Onderdeel gemarkeerd als Niet Voltooid",
"ToastLibraryCreateFailed": "Bibliotheek aanmaken mislukt",
"ToastLibraryCreateSuccess": "Bibliotheek \"{0}\" aangemaakt",
"ToastLibraryDeleteFailed": "Bibliotheek verwijderen mislukt",
"ToastLibraryDeleteSuccess": "Bibliotheek verwijderd",
"ToastLibraryScanFailedToStart": "Starten scan mislukt",
"ToastLibraryScanStarted": "Scannen bibliotheek gestart",
"ToastLibraryUpdateFailed": "Updaten bibliotheek mislukt",
"ToastLibraryUpdateSuccess": "Bibliotheek \"{0}\" geüpdatet",
"ToastPlaylistCreateFailed": "Aanmaken afspeellijst mislukt",
"ToastPlaylistCreateSuccess": "Afspeellijst aangemaakt",
"ToastPlaylistRemoveFailed": "Verwijderen afspeellijst mislukt",
"ToastPlaylistRemoveSuccess": "Afspeellijst verwijderd",
"ToastPlaylistUpdateFailed": "Afspeellijst updaten mislukt",
"ToastPlaylistUpdateSuccess": "Afspeellijst geüpdatet",
"ToastPodcastCreateFailed": "Podcast aanmaken mislukt",
"ToastPodcastCreateSuccess": "Podcast aangemaakt",
"ToastRemoveItemFromCollectionFailed": "Onderdeel verwijderen uit collectie mislukt",
"ToastRemoveItemFromCollectionSuccess": "Onderdeel verwijderd uit collectie",
"ToastRSSFeedCloseFailed": "Sluiten RSS-feed mislukt",
"ToastRSSFeedCloseSuccess": "RSS-feed gesloten",
"ToastSeriesUpdateFailed": "Serie update mislukt",
"ToastSeriesUpdateSuccess": "Serie update gelukt",
"ToastSessionDeleteFailed": "Verwijderen sessie mislukt",
"ToastSessionDeleteSuccess": "Sessie verwijderd",
"ToastSocketConnected": "Socket verbonden",
"ToastSocketDisconnected": "Socket niet verbonden",
"ToastSocketFailedToConnect": "Verbinding Socket mislukt",
"ToastUserDeleteFailed": "Verwijderen gebruiker mislukt",
"ToastUserDeleteSuccess": "Gebruiker verwijderd"
}

View File

@@ -254,6 +254,7 @@
"LabelIntervalEveryDay": "Każdego dnia",
"LabelIntervalEveryHour": "Każdej godziny",
"LabelInvalidParts": "Nieprawidłowe części",
"LabelInvert": "Invert",
"LabelItem": "Pozycja",
"LabelLanguage": "Język",
"LabelLanguageDefaultServer": "Domyślny język serwera",
@@ -412,6 +413,7 @@
"LabelTag": "Tag",
"LabelTags": "Tagi",
"LabelTagsAccessibleToUser": "Tagi dostępne dla użytkownika",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Czas odtwarzania",
@@ -481,6 +483,7 @@
"MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?",
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",

View File

@@ -254,6 +254,7 @@
"LabelIntervalEveryDay": "Каждый день",
"LabelIntervalEveryHour": "Каждый час",
"LabelInvalidParts": "Неверные части",
"LabelInvert": "Invert",
"LabelItem": "Элемент",
"LabelLanguage": "Язык",
"LabelLanguageDefaultServer": "Язык сервера по умолчанию",
@@ -412,6 +413,7 @@
"LabelTag": "Тег",
"LabelTags": "Теги",
"LabelTagsAccessibleToUser": "Теги доступные для пользователя",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Запущенные задачи",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Время прослушивания",
@@ -481,6 +483,7 @@
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?",
"MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?",
"MessageConfirmRenameGenreMergeNote": "Примечание: Этот жанр уже существует, поэтому они будут объединены.",

View File

@@ -155,13 +155,13 @@
"HeaderUpdateLibrary": "更新媒体库",
"HeaderUsers": "用户",
"HeaderYourStats": "你的统计数据",
"LabelAbridged": "Abridged",
"LabelAbridged": "概要",
"LabelAccountType": "帐户类型",
"LabelAccountTypeAdmin": "管理员",
"LabelAccountTypeGuest": "来宾",
"LabelAccountTypeUser": "用户",
"LabelActivity": "活动",
"LabelAdded": "Added",
"LabelAdded": "添加",
"LabelAddedAt": "添加于",
"LabelAddToCollection": "添加到收藏",
"LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏",
@@ -183,15 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "为了防止错误配置, 如果备份超过配置的大小, 备份将失败.",
"LabelBackupsNumberToKeep": "要保留的备份个数",
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
"LabelBitrate": "Bitrate",
"LabelBitrate": "比特率",
"LabelBooks": "图书",
"LabelChangePassword": "修改密码",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChannels": "声道",
"LabelChapters": "章节",
"LabelChaptersFound": "找到的章节",
"LabelChapterTitle": "章节标题",
"LabelClosePlayer": "关闭播放器",
"LabelCodec": "Codec",
"LabelCodec": "编解码",
"LabelCollapseSeries": "折叠系列",
"LabelCollections": "收藏",
"LabelComplete": "已完成",
@@ -217,7 +217,7 @@
"LabelDuration": "持续时间",
"LabelDurationFound": "找到持续时间:",
"LabelEdit": "编辑",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEmbeddedCover": "嵌入封面",
"LabelEnable": "启用",
"LabelEnd": "结束",
"LabelEpisode": "剧集",
@@ -235,7 +235,7 @@
"LabelFinished": "已听完",
"LabelFolder": "文件夹",
"LabelFolders": "文件夹",
"LabelFormat": "Format",
"LabelFormat": "编码格式",
"LabelGenre": "流派",
"LabelGenres": "流派",
"LabelHardDeleteFile": "完全删除文件",
@@ -254,11 +254,12 @@
"LabelIntervalEveryDay": "每天",
"LabelIntervalEveryHour": "每小时",
"LabelInvalidParts": "无效部件",
"LabelInvert": "倒转",
"LabelItem": "项目",
"LabelLanguage": "语言",
"LabelLanguageDefaultServer": "默认服务器语言",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastBookAdded": "最后添加的书",
"LabelLastBookUpdated": "最后更新的书",
"LabelLastSeen": "上次查看时间",
"LabelLastTime": "最近一次",
"LabelLastUpdate": "最近更新",
@@ -277,12 +278,12 @@
"LabelMediaType": "媒体类型",
"LabelMetadataProvider": "元数据提供者",
"LabelMetaTag": "元数据标签",
"LabelMetaTags": "Meta Tags",
"LabelMetaTags": "元标签",
"LabelMinute": "分钟",
"LabelMissing": "丢失",
"LabelMissingParts": "丢失的部分",
"LabelMore": "更多",
"LabelMoreInfo": "More Info",
"LabelMoreInfo": "更多..",
"LabelName": "名称",
"LabelNarrator": "演播者",
"LabelNarrators": "演播者",
@@ -412,8 +413,9 @@
"LabelTag": "标签",
"LabelTags": "标签",
"LabelTagsAccessibleToUser": "用户可访问的标签",
"LabelTagsNotAccessibleToUser": "用户无法访问标签",
"LabelTasks": "正在运行的任务",
"LabelTimeBase": "Time Base",
"LabelTimeBase": "时间基准",
"LabelTimeListened": "收听时间",
"LabelTimeListenedToday": "今日收听的时间",
"LabelTimeRemaining": "剩余 {0}",
@@ -433,7 +435,7 @@
"LabelTracksMultiTrack": "多轨",
"LabelTracksSingleTrack": "单轨",
"LabelType": "类型",
"LabelUnabridged": "Unabridged",
"LabelUnabridged": "未删节",
"LabelUnknown": "未知",
"LabelUpdateCover": "更新封面",
"LabelUpdateCoverHelp": "找到匹配项时允许覆盖所选书籍存在的封面",
@@ -477,10 +479,11 @@
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
"MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?",
"MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?",
"MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?",
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?",
"MessageConfirmRenameGenre": "你确定要将所有项目流派 \"{0}\" 重命名到 \"{1}\"?",
"MessageConfirmRenameGenreMergeNote": "注意: 该流派已经存在, 因此它们将被合并.",
@@ -581,7 +584,7 @@
"PlaceholderNewFolderPath": "输入文件夹路径",
"PlaceholderNewPlaylist": "输入播放列表名称",
"PlaceholderSearch": "查找..",
"PlaceholderSearchEpisode": "Search episode..",
"PlaceholderSearchEpisode": "搜索剧集..",
"ToastAccountUpdateFailed": "账户更新失败",
"ToastAccountUpdateSuccess": "帐户已更新",
"ToastAuthorImageRemoveFailed": "作者图像删除失败",

View File

@@ -48,21 +48,9 @@
<Mode>rw</Mode>
</Volume>
</Data>
<Environment>
<Variable>
<Value>99</Value>
<Name>AUDIOBOOKSHELF_UID</Name>
<Mode/>
</Variable>
<Variable>
<Value>100</Value>
<Name>AUDIOBOOKSHELF_GID</Name>
<Mode/>
</Variable>
</Environment>
<Labels/>
<Config Name="Audiobooks" Target="/audiobooks" Default="" Mode="rw" Description="Container Path: /audiobooks" Type="Path" Display="always" Required="true" Mask="false" />
<Config Name="Config" Target="/config" Default="/mnt/user/appdata/audiobookshelf/config/" Mode="rw" Description="Container Path: /config" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/config/</Config>
<Config Name="Metadata" Target="/metadata" Default="/mnt/user/appdata/audiobookshelf/metadata/" Mode="rw" Description="Container Path: /metadata" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/metadata/</Config>
<Config Name="Web UI Port" Target="80" Default="13378" Mode="tcp" Description="Container Port: 80" Type="Port" Display="always" Required="false" Mask="false">13378</Config>
</Container>
</Container>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.2.19",
"version": "2.2.20",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.2.19",
"version": "2.2.20",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.2.19",
"version": "2.2.20",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {

View File

@@ -147,7 +147,7 @@ For this to work you must enable at least the following mods using `a2enmod`:
### SWAG Reverse Proxy
[See this solution](https://forums.unraid.net/topic/112698-support-audiobookshelf/?do=findComment&comment=1049637)
[See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample)
### Synology Reverse Proxy

View File

@@ -126,12 +126,12 @@ class Auth {
async login(req, res) {
const ipAddress = requestIp.getClientIp(req)
var username = (req.body.username || '').toLowerCase()
var password = req.body.password || ''
const username = (req.body.username || '').toLowerCase()
const password = req.body.password || ''
var user = this.users.find(u => u.username.toLowerCase() === username)
const user = this.users.find(u => u.username.toLowerCase() === username)
if (!user || !user.isActive) {
if (!user?.isActive) {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
if (req.rateLimit.remaining <= 2) {
Logger.error(`[Auth] Failed login attempt for username ${username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
@@ -145,13 +145,15 @@ class Auth {
if (password) {
return res.status(401).send('Invalid root password (hint: there is none)')
} else {
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
return res.json(this.getUserLoginResponsePayload(user))
}
}
// Check password match
var compare = await bcrypt.compare(password, user.pash)
const compare = await bcrypt.compare(password, user.pash)
if (compare) {
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
res.json(this.getUserLoginResponsePayload(user))
} else {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)

View File

@@ -596,6 +596,7 @@ class LibraryController {
const itemMatches = []
const authorMatches = {}
const narratorMatches = {}
const seriesMatches = {}
const tagMatches = {}
@@ -608,7 +609,7 @@ class LibraryController {
matchText: queryResult.matchText
})
}
if (queryResult.series && queryResult.series.length) {
if (queryResult.series?.length) {
queryResult.series.forEach((se) => {
if (!seriesMatches[se.id]) {
const _series = this.db.series.find(_se => _se.id === se.id)
@@ -618,7 +619,7 @@ class LibraryController {
}
})
}
if (queryResult.authors && queryResult.authors.length) {
if (queryResult.authors?.length) {
queryResult.authors.forEach((au) => {
if (!authorMatches[au.id]) {
const _author = this.db.authors.find(_au => _au.id === au.id)
@@ -631,7 +632,7 @@ class LibraryController {
}
})
}
if (queryResult.tags && queryResult.tags.length) {
if (queryResult.tags?.length) {
queryResult.tags.forEach((tag) => {
if (!tagMatches[tag]) {
tagMatches[tag] = { name: tag, books: [li.toJSON()] }
@@ -640,13 +641,23 @@ class LibraryController {
}
})
}
if (queryResult.narrators?.length) {
queryResult.narrators.forEach((narrator) => {
if (!narratorMatches[narrator]) {
narratorMatches[narrator] = { name: narrator, books: [li.toJSON()] }
} else {
narratorMatches[narrator].books.push(li.toJSON())
}
})
}
})
const itemKey = req.library.mediaType
const results = {
[itemKey]: itemMatches.slice(0, maxResults),
tags: Object.values(tagMatches).slice(0, maxResults),
authors: Object.values(authorMatches).slice(0, maxResults),
series: Object.values(seriesMatches).slice(0, maxResults)
series: Object.values(seriesMatches).slice(0, maxResults),
narrators: Object.values(narratorMatches).slice(0, maxResults)
}
res.json(results)
}
@@ -673,13 +684,12 @@ class LibraryController {
}
async getAuthors(req, res) {
var libraryItems = req.libraryItems
var authors = {}
libraryItems.forEach((li) => {
const authors = {}
req.libraryItems.forEach((li) => {
if (li.media.metadata.authors && li.media.metadata.authors.length) {
li.media.metadata.authors.forEach((au) => {
if (!authors[au.id]) {
var _author = this.db.authors.find(_au => _au.id === au.id)
const _author = this.db.authors.find(_au => _au.id === au.id)
if (_author) {
authors[au.id] = _author.toJSON()
authors[au.id].numBooks = 1
@@ -696,6 +706,83 @@ class LibraryController {
})
}
async getNarrators(req, res) {
const narrators = {}
req.libraryItems.forEach((li) => {
if (li.media.metadata.narrators && li.media.metadata.narrators.length) {
li.media.metadata.narrators.forEach((n) => {
if (!narrators[n]) {
narrators[n] = {
id: encodeURIComponent(Buffer.from(n).toString('base64')),
name: n,
numBooks: 1
}
} else {
narrators[n].numBooks++
}
})
}
})
res.json({
narrators: naturalSort(Object.values(narrators)).asc(n => n.name)
})
}
async updateNarrator(req, res) {
if (!req.user.canUpdate) {
Logger.error(`[LibraryController] Unauthorized user "${req.user.username}" attempted to update narrator`)
return res.sendStatus(403)
}
const narratorName = libraryHelpers.decode(req.params.narratorId)
const updatedName = req.body.name
if (!updatedName) {
return res.status(400).send('Invalid request payload. Name not specified.')
}
const itemsUpdated = []
for (const libraryItem of req.libraryItems) {
if (libraryItem.media.metadata.updateNarrator(narratorName, updatedName)) {
itemsUpdated.push(libraryItem)
}
}
if (itemsUpdated.length) {
await this.db.updateLibraryItems(itemsUpdated)
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
}
res.json({
updated: itemsUpdated.length
})
}
async removeNarrator(req, res) {
if (!req.user.canUpdate) {
Logger.error(`[LibraryController] Unauthorized user "${req.user.username}" attempted to remove narrator`)
return res.sendStatus(403)
}
const narratorName = libraryHelpers.decode(req.params.narratorId)
const itemsUpdated = []
for (const libraryItem of req.libraryItems) {
if (libraryItem.media.metadata.removeNarrator(narratorName)) {
itemsUpdated.push(libraryItem)
}
}
if (itemsUpdated.length) {
await this.db.updateLibraryItems(itemsUpdated)
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
}
res.json({
updated: itemsUpdated.length
})
}
async matchAll(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user)
@@ -765,7 +852,7 @@ class LibraryController {
return res.sendStatus(404)
}
var library = this.db.libraries.find(lib => lib.id === req.params.id)
const library = this.db.libraries.find(lib => lib.id === req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}

View File

@@ -4,6 +4,7 @@ const Audible = require('../providers/Audible')
const iTunes = require('../providers/iTunes')
const Audnexus = require('../providers/Audnexus')
const FantLab = require('../providers/FantLab')
const AudiobookCovers = require('../providers/AudiobookCovers')
const Logger = require('../Logger')
const { levenshteinDistance } = require('../utils/index')
@@ -15,6 +16,7 @@ class BookFinder {
this.iTunesApi = new iTunes()
this.audnexus = new Audnexus()
this.fantLab = new FantLab()
this.audiobookCovers = new AudiobookCovers()
this.verbose = false
}
@@ -159,6 +161,12 @@ class BookFinder {
return books
}
async getAudiobookCoversResults(search) {
const covers = await this.audiobookCovers.search(search)
if (this.verbose) Logger.debug(`AudiobookCovers Search Results: ${covers.length || 0}`)
return covers || []
}
async getiTunesAudiobooksResults(title, author) {
return this.iTunesApi.searchAudiobooks(title)
}
@@ -187,6 +195,8 @@ class BookFinder {
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'fantlab') {
books = await this.getFantLabResults(title, author)
} else if (provider === 'audiobookcovers') {
books = await this.getAudiobookCoversResults(title)
}
else {
books = await this.getGoogleBooksResults(title, author)
@@ -202,11 +212,13 @@ class BookFinder {
return this.search(provider, cleanedTitle, cleanedAuthor, isbn, asin, options)
}
if (["google", "audible", "itunes", 'fantlab'].includes(provider)) return books
if (provider === 'openlibrary') {
books.sort((a, b) => {
return a.totalDistance - b.totalDistance
})
}
return books.sort((a, b) => {
return a.totalDistance - b.totalDistance
})
return books
}
async findCovers(provider, title, author, options = {}) {

View File

@@ -322,6 +322,7 @@ class Book {
tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)),
series: this.metadata.searchSeries(query),
authors: this.metadata.searchAuthors(query),
narrators: this.metadata.searchNarrators(query),
matchKey: null,
matchText: null
}
@@ -336,10 +337,12 @@ class Book {
} else if (payload.series.length) {
payload.matchKey = 'series'
payload.matchText = this.metadata.seriesName
}
else if (payload.tags.length) {
} else if (payload.tags.length) {
payload.matchKey = 'tags'
payload.matchText = this.tags.join(', ')
} else if (payload.narrators.length) {
payload.matchKey = 'narrators'
payload.matchText = this.metadata.narratorName
}
}
return payload

View File

@@ -221,6 +221,32 @@ class BookMetadata {
})
}
/**
* Update narrator name if narrator is in book
* @param {String} oldNarratorName - Narrator name to get updated
* @param {String} newNarratorName - Updated narrator name
* @return {Boolean} True if narrator was updated
*/
updateNarrator(oldNarratorName, newNarratorName) {
if (!this.hasNarrator(oldNarratorName)) return false
this.narrators = this.narrators.filter(n => n !== oldNarratorName)
if (!this.hasNarrator(newNarratorName)) {
this.narrators.push(newNarratorName)
}
return true
}
/**
* Remove narrator name if narrator is in book
* @param {String} narratorName - Narrator name to remove
* @return {Boolean} True if narrator was updated
*/
removeNarrator(narratorName) {
if (!this.hasNarrator(narratorName)) return false
this.narrators = this.narrators.filter(n => n !== narratorName)
return true
}
setData(scanMediaData = {}) {
this.title = scanMediaData.title || null
this.subtitle = scanMediaData.subtitle || null
@@ -381,6 +407,9 @@ class BookMetadata {
searchAuthors(query) {
return this.authors.filter(au => cleanStringForSearch(au.name).includes(query))
}
searchNarrators(query) {
return this.narrators.filter(n => cleanStringForSearch(n).includes(query))
}
searchQuery(query) { // Returns key if match is found
const keysToCheck = ['title', 'asin', 'isbn']
for (const key of keysToCheck) {

View File

@@ -20,7 +20,7 @@ class User {
this.permissions = {}
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
this.itemTagsAccessible = [] // Empty if ALL item tags accessible
this.itemTagsSelected = [] // Empty if ALL item tags accessible
if (user) {
this.construct(user)
@@ -86,7 +86,7 @@ class User {
createdAt: this.createdAt,
permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible],
itemTagsAccessible: [...this.itemTagsAccessible]
itemTagsSelected: [...this.itemTagsSelected]
}
}
@@ -105,7 +105,7 @@ class User {
createdAt: this.createdAt,
permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible],
itemTagsAccessible: [...this.itemTagsAccessible]
itemTagsSelected: [...this.itemTagsSelected]
}
if (minimal) {
delete json.mediaProgress
@@ -169,9 +169,14 @@ class User {
if (this.permissions.accessAllTags === undefined) this.permissions.accessAllTags = true
// Explicit content restriction permission added v2.0.18
if (this.permissions.accessExplicitContent === undefined) this.permissions.accessExplicitContent = true
// itemTagsAccessible was renamed to itemTagsSelected in version v2.2.20
if (user.itemTagsAccessible?.length) {
this.permissions.selectedTagsNotAccessible = false
user.itemTagsSelected = user.itemTagsAccessible
}
this.librariesAccessible = [...(user.librariesAccessible || [])]
this.itemTagsAccessible = [...(user.itemTagsAccessible || [])]
this.itemTagsSelected = [...(user.itemTagsSelected || [])]
}
update(payload) {
@@ -228,19 +233,21 @@ class User {
// Update accessible tags
if (this.permissions.accessAllTags) {
// Access all tags
if (this.itemTagsAccessible.length) {
this.itemTagsAccessible = []
if (this.itemTagsSelected.length) {
this.itemTagsSelected = []
this.permissions.selectedTagsNotAccessible = false
hasUpdates = true
}
} else if (payload.itemTagsAccessible !== undefined) {
if (payload.itemTagsAccessible.length) {
if (payload.itemTagsAccessible.join(',') !== this.itemTagsAccessible.join(',')) {
} else if (payload.itemTagsSelected !== undefined) {
if (payload.itemTagsSelected.length) {
if (payload.itemTagsSelected.join(',') !== this.itemTagsSelected.join(',')) {
hasUpdates = true
this.itemTagsAccessible = [...payload.itemTagsAccessible]
this.itemTagsSelected = [...payload.itemTagsSelected]
}
} else if (this.itemTagsAccessible.length > 0) {
} else if (this.itemTagsSelected.length > 0) {
hasUpdates = true
this.itemTagsAccessible = []
this.itemTagsSelected = []
this.permissions.selectedTagsNotAccessible = false
}
}
return hasUpdates
@@ -343,8 +350,12 @@ class User {
checkCanAccessLibraryItemWithTags(tags) {
if (this.permissions.accessAllTags) return true
if (!tags || !tags.length) return false
return this.itemTagsAccessible.some(tag => tags.includes(tag))
if (this.permissions.selectedTagsNotAccessible) {
if (!tags?.length) return true
return tags.every(tag => !this.itemTagsSelected.includes(tag))
}
if (!tags?.length) return false
return this.itemTagsSelected.some(tag => tags.includes(tag))
}
checkCanAccessLibraryItem(libraryItem) {

View File

@@ -0,0 +1,23 @@
const axios = require('axios')
const Logger = require('../Logger')
class AudiobookCovers {
constructor() { }
async search(search) {
const url = `https://api.audiobookcovers.com/cover/bytext/`
const params = new URLSearchParams([['q', search]])
const items = await axios.get(url, { params }).then((res) => {
if (!res || !res.data) return []
return res.data
}).catch(error => {
Logger.error('[AudiobookCovers] Cover search error', error)
return []
})
return items.map(item => ({ cover: item.filename }))
}
}
module.exports = AudiobookCovers

View File

@@ -84,6 +84,9 @@ class ApiRouter {
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this))
this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this))
this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this))
this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))
this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this))

View File

@@ -25,6 +25,11 @@ module.exports = function areEquivalent(value1, value2, stack = []) {
return true;
}
// Truthy check to handle value1=null, value2=Object
if ((value1 && !value2) || (!value1 && value2)) {
return false
}
const type1 = typeof value1;
// Ensure types match

View File

@@ -92,7 +92,6 @@ module.exports.setDefault = (path, silent = false) => {
const gid = global.Gid
return new Promise((resolve) => {
if (isNaN(uid) || isNaN(gid)) {
if (!silent) Logger.debug('Not modifying permissions since no uid/gid is specified')
return resolve()
}
if (!silent) Logger.debug(`Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)

View File

@@ -174,20 +174,24 @@ async function recurseFiles(path, relPathToReplace = null) {
}
module.exports.recurseFiles = recurseFiles
module.exports.downloadFile = async (url, filepath) => {
Logger.debug(`[fileUtils] Downloading file to ${filepath}`)
module.exports.downloadFile = (url, filepath) => {
return new Promise(async (resolve, reject) => {
Logger.debug(`[fileUtils] Downloading file to ${filepath}`)
axios({
url,
method: 'GET',
responseType: 'stream',
timeout: 30000
}).then((response) => {
const writer = fs.createWriteStream(filepath)
response.data.pipe(writer)
const writer = fs.createWriteStream(filepath)
const response = await axios({
url,
method: 'GET',
responseType: 'stream',
timeout: 30000
})
response.data.pipe(writer)
return new Promise((resolve, reject) => {
writer.on('finish', resolve)
writer.on('error', reject)
writer.on('finish', resolve)
writer.on('error', reject)
}).catch((err) => {
Logger.error(`[fileUtils] Failed to download file "${filepath}"`, err)
reject(err)
})
})
}

View File

@@ -85,6 +85,8 @@ function extractEpisodeData(item) {
}
}
episode.enclosure.url = episode.enclosure.url.trim()
// Full description with html
if (item['content:encoded']) {
const rawDescription = (extractFirstArrayItem(item, 'content:encoded') || '').trim()

View File

@@ -170,7 +170,7 @@ function parseTags(format, verbose) {
file_tag_titlesort: tryGrabTags(format, 'title-sort', 'tsot'),
file_tag_subtitle: tryGrabTags(format, 'subtitle', 'tit3', 'tt3'),
file_tag_track: tryGrabTags(format, 'track', 'trck', 'trk'),
file_tag_disc: tryGrabTags(format, 'discnumber', 'disc', 'disk', 'tpos'),
file_tag_disc: tryGrabTags(format, 'discnumber', 'disc', 'disk', 'tpos', 'tpa'),
file_tag_album: tryGrabTags(format, 'album', 'talb', 'tal'),
file_tag_albumsort: tryGrabTags(format, 'album-sort', 'tsoa'),
file_tag_artist: tryGrabTags(format, 'artist', 'tpe1', 'tp1'),
@@ -183,7 +183,7 @@ function parseTags(format, verbose) {
file_tag_description: tryGrabTags(format, 'description', 'desc'),
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'),
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin'),
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'),
file_tag_isbn: tryGrabTags(format, 'isbn'), // custom
file_tag_language: tryGrabTags(format, 'language', 'lang'),
file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom