Compare commits

...

97 Commits

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

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

Let's Encrypt was not able to validate certificates either automatically or manually as ABS does not respond to ACME challenges directly. Editing the virtual environment configuration to ignore proxy requests to .well-known and serving that directly from Apache allowed LE to validate the challenge instead.
2023-06-03 11:46:42 -06:00
advplyr
a2fdc3e876 Update:Increase max height of libraries dropdown 2023-06-01 17:09:04 -05:00
advplyr
f92b66a469 Merge pull request #1810 from glacasa/master
Update french translations
2023-06-01 16:29:48 -05:00
Guillaume Lacasa
c3d256c42b Update french translations 2023-06-01 09:38:00 +02:00
advplyr
fdc792cb82 Version bump v2.2.22 2023-05-30 16:59:04 -05:00
advplyr
a16fb31e6e Update:Library filter max height #1673 2023-05-30 16:55:52 -05:00
advplyr
4d8a1b5b6d Add:Ebook library filter, and update e-book to ebook 2023-05-30 16:37:24 -05:00
advplyr
c382f07b05 Fix:Close player resetting progress #1807 2023-05-30 16:08:30 -05:00
advplyr
9f6a7d065c Merge pull request #1808 from Lionfox2/patch-1
Update de.json
2023-05-30 04:14:33 -05:00
Lionfox2
11aa75ecbe Update de.json
I corrected a spelling mistake (starseite --> startseite)
2023-05-30 00:56:27 +02:00
advplyr
05ce9c6eda Add:Email smtp config & send ebooks to devices #1474 2023-05-29 17:38:38 -05:00
advplyr
15aaf2863c Add:OPML Export #1260 2023-05-28 15:10:34 -05:00
advplyr
019063e6f4 Update:New API routes for library files and downloads 2023-05-28 12:34:22 -05:00
advplyr
ea79948122 Fix:Podcast episode downloads where RSS feed uses the same title #1802 2023-05-28 11:24:51 -05:00
advplyr
7a0f27e3cc Fix:Epub3 background color #1804 2023-05-28 10:55:37 -05:00
advplyr
4f75a89633 Update:New EBook API endpoint 2023-05-28 10:47:28 -05:00
advplyr
b3f19ef628 Fix:Static file route check authorization 2023-05-28 09:34:03 -05:00
advplyr
f16e312319 Fix:Series api check user has access to library 2023-05-28 08:51:34 -05:00
advplyr
056da0ef70 Fix:Static ebook route 2023-05-28 08:39:41 -05:00
advplyr
ca5f781531 Version bump v2.2.21 2023-05-27 17:54:20 -05:00
advplyr
53c96b2540 Update:Handle multiple sessions open, sync when paused, show alert of multiple sessions open when both are playing #1660 2023-05-27 17:21:43 -05:00
advplyr
9712bdf5f0 Update:Check if directory already exists before upload #1497 2023-05-27 16:00:34 -05:00
advplyr
0678c26627 Fix:Catch exception when podcast episode download request fails #1759 2023-05-27 15:24:08 -05:00
advplyr
b52e240025 Add:Batch re-scan #1754 2023-05-27 14:51:03 -05:00
advplyr
2fa73f7a8d Update:Audio player visible when ereader is open #1800 and adding zoom to PDF reader 2023-05-27 11:58:01 -05:00
advplyr
2cc23b6d6b Update:Auto update home page shelves when new episode is added #716 2023-05-27 09:13:44 -05:00
advplyr
9a617226b3 Update:Continue Reading shelf Remove from Continue Reading menu item 2023-05-27 09:00:54 -05:00
advplyr
fbfc015d92 Fix:Hide Chapters tab for ebook only items #1795 2023-05-27 08:31:47 -05:00
advplyr
3e4c94e2b4 Update:Continue Reading and Read Again home page shelves for ebook only items #1782 2023-05-27 08:20:09 -05:00
advplyr
1da471e136 Fix:Embed metadata tool embed ASIN, SERIES and SERIESPART #1794 2023-05-26 17:57:56 -05:00
advplyr
4dba95c000 Update:Show series on collection items #1449 2023-05-24 17:37:51 -05:00
advplyr
36477a832c Add:Saving progress for PDF ebooks #1791 2023-05-24 16:41:16 -05:00
advplyr
b4aa8f0c9a Merge pull request #1786 from 92Kev/master
Update Dutch translation
2023-05-23 17:27:15 -05:00
advplyr
6a974d5ef0 Update:Epub TOC shows subitems #1787 2023-05-23 15:38:49 -05:00
92Kev
304eda9f8c 'Audio tracks' to 'Audiotracks'. 2023-05-21 18:30:55 +02:00
92Kev
581f2e3d15 Fixes some errors and changes some translations to more intuitive terms. 2023-05-21 18:16:00 +02:00
advplyr
be2d317325 Fix initialize currentTime and ebookProgress for MediaProgress 2023-05-20 15:37:44 -05:00
advplyr
9f6bfeb839 Fix:Removing media progress that was started local 2023-05-20 15:19:09 -05:00
advplyr
f4f5f79af7 Update:Add chapters to playback session so they can be used for podcast episodes in mobile apps 2023-05-20 13:30:07 -05:00
advplyr
92bb2fb23d Fix:Levenshtein distance crash when passed non-strings 2023-05-18 17:07:58 -05:00
advplyr
3c406c12b4 Updates to metadata file format changing, use chapters from metadata file 2023-05-16 18:58:01 -05:00
advplyr
81d4ac3ed2 Add:metadata.json format #1775 #916 2023-05-15 18:23:31 -05:00
advplyr
32bdae31a8 Add:All providers option for searching covers #1774 2023-05-14 13:43:20 -05:00
advplyr
84c16c4a39 Fix:Audible india provider 2023-05-14 13:42:29 -05:00
advplyr
b8b3d05f5e Fix:Authors page includes library id in url #1767 2023-05-13 16:47:38 -05:00
advplyr
bac09de23d Fix:getNarrators API endpoint check narrators are strings #1770 2023-05-12 18:22:09 -05:00
advplyr
b0bf9604bb Update:First sync after 20 seconds listening #1546 2023-05-11 15:31:47 -04:00
advplyr
688531f0a7 Update:Podcast episodes fallback to description when subtitle is null #1752 2023-05-10 20:18:29 -04:00
advplyr
dfc7877f69 Fix:Persist book cover provider separate from match provider #1764 2023-05-09 19:26:37 -04:00
advplyr
e00116a0e3 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-05-08 16:21:02 -04:00
advplyr
2ab287e2a9 Fix:Podcast cron filter out failed library item 2023-05-08 16:20:09 -04:00
advplyr
1e0da09b2f Merge pull request #1760 from Alistair1231/seek-instead-of-skip
seek instead of skip with action handlers
2023-05-07 15:16:04 -05:00
Alistair1231
0e7a5649cc seek instead of skip with action handlers 2023-05-07 19:23:07 +00:00
advplyr
30009e45da Update:Book subtitle searchable #1755 2023-05-06 09:43:13 -05:00
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
140 changed files with 4717 additions and 1145 deletions

View File

@@ -112,7 +112,7 @@ input[type=number] {
background-color: #373838;
}
.tracksTable tr:hover {
.tracksTable tr:hover:not(:has(th)) {
background-color: #474747;
}
@@ -232,6 +232,20 @@ Bookshelf Label
-webkit-box-orient: vertical;
}
.episode-subtitle-long {
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
line-height: 16px;
/* fallback */
max-height: 72px;
/* fallback */
-webkit-line-clamp: 6;
/* number of lines to show */
-webkit-box-orient: vertical;
}
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
.app-bar-and-toolbar .Vue-Toastification__container.top-right {

View File

@@ -7,7 +7,7 @@
</nuxt-link>
<nuxt-link to="/">
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
</nuxt-link>
<ui-libraries-dropdown class="mr-2" />
@@ -15,8 +15,6 @@
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
<div class="flex-grow" />
<widgets-notification-widget class="hidden md:block" />
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
</ui-tooltip>
@@ -24,6 +22,8 @@
<google-cast-launcher></google-cast-launcher>
</div>
<widgets-notification-widget class="hidden md:block" />
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
@@ -149,9 +149,6 @@ export default {
processingBatch() {
return this.$store.state.processingBatch
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
isChromecastEnabled() {
return this.$store.getters['getServerSetting']('chromecastEnabled')
},
@@ -178,6 +175,11 @@ export default {
})
}
options.push({
text: 'Re-Scan',
action: 'rescan'
})
return options
}
},
@@ -206,13 +208,39 @@ export default {
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
contextMenuAction(action) {
contextMenuAction({ action }) {
if (action === 'quick-embed') {
this.requestBatchQuickEmbed()
} else if (action === 'quick-match') {
this.batchAutoMatchClick()
} else if (action === 'rescan') {
this.batchRescan()
}
},
async batchRescan() {
const payload = {
message: `Are you sure you want to re-scan ${this.selectedMediaItems.length} items?`,
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/items/batch/scan`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
console.log('Batch Re-Scan started')
this.cancelSelectionMode()
})
.catch((error) => {
console.error('Batch Re-Scan failed', error)
const errorMsg = error.response.data || 'Failed to batch re-scan'
this.$toast.error(errorMsg)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
async playSelectedItems() {
this.$store.commit('setProcessingBatch', true)

View File

@@ -16,7 +16,7 @@
<!-- Alternate plain view -->
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
<template v-for="(shelf, index) in shelves">
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-item-slider>
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
@@ -36,7 +36,7 @@
<!-- Regular bookshelf view -->
<div v-else class="w-full">
<template v-for="(shelf, index) in shelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening'" @selectEntity="(payload) => selectEntity(payload, index)" />
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" @selectEntity="(payload) => selectEntity(payload, index)" />
</template>
</div>
</div>
@@ -65,9 +65,6 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
@@ -286,7 +283,8 @@ export default {
}
if (user.mediaProgress.length) {
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
this.removeItemsFromContinueListening(mediaProgressToHide)
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')
}
},
libraryItemAdded(libraryItem) {
@@ -336,8 +334,9 @@ export default {
},
libraryItemsAdded(libraryItems) {
console.log('libraryItems added', libraryItems)
// TODO: Check if audiobook would be on this shelf
if (!this.search) {
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId)
if (!this.search && isThisLibrary) {
this.fetchCategories()
}
},
@@ -346,6 +345,14 @@ export default {
this.libraryItemUpdated(li)
})
},
episodeAdded(episodeWithLibraryItem) {
console.log('Podcast episode added', episodeWithLibraryItem)
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
if (!this.search && isThisLibrary) {
this.fetchCategories()
}
},
removeAllSeriesFromContinueSeries(seriesIds) {
this.shelves.forEach((shelf) => {
if (shelf.type == 'book' && shelf.id == 'continue-series') {
@@ -357,8 +364,8 @@ export default {
}
})
},
removeItemsFromContinueListening(mediaProgressItems) {
const continueListeningShelf = this.shelves.find((s) => s.id === 'continue-listening')
removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {
const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)
if (continueListeningShelf) {
if (continueListeningShelf.type === 'book') {
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
@@ -373,17 +380,6 @@ export default {
})
}
}
// this.shelves.forEach((shelf) => {
// if (shelf.id == 'continue-listening') {
// if (shelf.type == 'book') {
// // Filter out books from continue listening shelf
// shelf.entities = shelf.entities.filter((ent) => {
// if (mediaProgressItems.some(mp => mp.libraryItemId === ent.id)) return false
// return true
// })
// }
// }
// })
},
authorUpdated(author) {
this.shelves.forEach((shelf) => {
@@ -417,6 +413,7 @@ export default {
this.$root.socket.on('item_removed', this.libraryItemRemoved)
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
this.$root.socket.on('items_added', this.libraryItemsAdded)
this.$root.socket.on('episode_added', this.episodeAdded)
} else {
console.error('Error socket not initialized')
}
@@ -431,6 +428,7 @@ export default {
this.$root.socket.off('item_removed', this.libraryItemRemoved)
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
this.$root.socket.off('items_added', this.libraryItemsAdded)
this.$root.socket.off('episode_added', this.episodeAdded)
} else {
console.error('Error socket not initialized')
}

View File

@@ -81,6 +81,8 @@
<!-- issues page remove all button -->
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
</template>
<!-- search page -->
<template v-else-if="page === 'search'">
@@ -186,6 +188,9 @@ export default {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
@@ -276,10 +281,30 @@ export default {
},
isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
},
contextMenuItems() {
const items = []
if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
items.push({
text: 'Export OPML',
action: 'export-opml'
})
}
return items
}
},
methods: {
seriesContextMenuAction(action) {
contextMenuAction({ action }) {
if (action === 'export-opml') {
this.exportOPML()
}
},
exportOPML() {
this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
},
seriesContextMenuAction({ action }) {
if (action === 'open-rss-feed') {
this.showOpenSeriesRSSFeed()
} else if (action === 're-add-to-continue-listening') {

View File

@@ -90,6 +90,11 @@ export default {
title: this.$strings.HeaderNotifications,
path: '/config/notifications'
},
{
id: 'config-email',
title: this.$strings.HeaderEmail,
path: '/config/email'
},
{
id: 'config-item-metadata-utils',
title: this.$strings.HeaderItemMetadataUtils,

View File

@@ -78,9 +78,6 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},

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

@@ -15,7 +15,7 @@
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</div>
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
@@ -268,6 +268,10 @@ export default {
seek(time) {
this.playerHandler.seek(time)
},
playbackTimeUpdate(time) {
// When updating progress from another session
this.playerHandler.seek(time, false)
},
setCurrentTime(time) {
this.currentTime = time
if (this.$refs.audioPlayer) {
@@ -366,9 +370,8 @@ export default {
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack)
const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
} else {
console.warn('Media session not available')
}
@@ -478,12 +481,14 @@ export default {
mounted() {
this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('playback-seek', this.seek)
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem)
},
beforeDestroy() {
this.$eventBus.$off('cast-session-active', this.castSessionActive)
this.$eventBus.$off('playback-seek', this.seek)
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
}

View File

@@ -1,5 +1,5 @@
<template>
<nuxt-link :to="`/author/${author.id}`">
<nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`">
<div @mouseover="mouseover" @mouseleave="mouseleave">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<!-- Image or placeholder -->

View File

@@ -5,7 +5,7 @@
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
<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" />
@@ -61,7 +61,6 @@ export default {
},
matchHtml() {
if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return ''
// This used to highlight the part of the search found
// but with removing commas periods etc this is no longer plausible
@@ -69,6 +68,7 @@ export default {
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 === 'subtitle') return `<p class="truncate">${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>`

View File

@@ -1,8 +1,10 @@
<template>
<div class="flex items-center h-full px-1 overflow-hidden">
<div class="h-5 w-5 min-w-5 text-lg mr-1.5 flex items-center justify-center">
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span>
<div class="flex items-center px-1 overflow-hidden">
<div class="w-8 flex items-center justify-center">
<!-- <div class="text-lg"> -->
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span>
<widgets-loading-spinner v-else />
<!-- </div> -->
</div>
<div class="flex-grow px-2 taskRunningCardContent">
<p class="truncate text-sm">{{ title }}</p>
@@ -36,10 +38,13 @@ export default {
return this.task.details || 'Unknown'
},
isFinished() {
return this.task.isFinished || false
return !!this.task.isFinished
},
isFailed() {
return this.task.isFailed || false
return !!this.task.isFailed
},
isSuccess() {
return this.isFinished && !this.isFailed
},
failedMessage() {
return this.task.error || ''
@@ -48,6 +53,11 @@ export default {
return this.task.action || ''
},
actionIcon() {
if (this.isFailed) {
return 'error'
} else if (this.isSuccess) {
return 'done'
}
switch (this.action) {
case 'download-podcast-episode':
return 'cloud_download'
@@ -68,16 +78,15 @@ export default {
return ''
}
},
methods: {
},
methods: {},
mounted() {}
}
</script>
<style>
.taskRunningCardContent {
width: calc(100% - 80px);
height: 75px;
width: calc(100% - 84px);
height: 60px;
display: flex;
flex-direction: column;
justify-content: center;

View File

@@ -114,6 +114,7 @@ export default {
var files = this.item.itemFiles.concat(this.item.otherFiles)
return {
index: this.item.index,
directory: this.directory,
...this.itemData,
files
}

View File

@@ -76,6 +76,10 @@
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div>
<div v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }">
<span class="text-white/80" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ ebookFormat }}</span>
</div>
</div>
<!-- Processing/loading spinner overlay -->
@@ -170,12 +174,6 @@ export default {
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
enableEReader() {
return this.store.getters['getServerSetting']('enableEReader')
},
_libraryItem() {
return this.libraryItem || {}
},
@@ -221,7 +219,7 @@ export default {
libraryId() {
return this._libraryItem.libraryId
},
hasEbook() {
ebookFormat() {
return this.media.ebookFormat
},
numTracks() {
@@ -252,14 +250,14 @@ export default {
},
booksInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
return this.collapsedSeries?.numBooks || 0
},
seriesSequenceList() {
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
return this.collapsedSeries?.seriesSequenceList || null
},
libraryItemIdsInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
return this.collapsedSeries?.libraryItemIds || []
},
hasCover() {
return !!this.media.coverPath
@@ -325,6 +323,9 @@ export default {
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
isEBookOnly() {
return !this.numTracks && this.ebookFormat
},
useEBookProgress() {
if (!this.userProgress || this.userProgress.progress) return false
return this.userProgress.ebookProgress > 0
@@ -360,13 +361,13 @@ export default {
return this.store.getters['getIsStreamingFromDifferentLibrary']
},
showReadButton() {
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
return !this.isSelectionMode && this.ebookFormat
},
isMissing() {
return this._libraryItem.isMissing
@@ -482,6 +483,18 @@ export default {
text: this.$strings.LabelAddToPlaylist
})
}
if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {
items.push({
text: this.$strings.LabelSendEbookToDevice,
subitems: this.store.state.libraries.ereaderDevices.map((d) => {
return {
text: d.name,
func: 'sendToDevice',
data: d.name
}
})
})
}
}
if (this.userCanUpdate) {
items.push({
@@ -508,7 +521,7 @@ export default {
if (this.continueListeningShelf) {
items.push({
func: 'removeFromContinueListening',
text: this.$strings.ButtonRemoveFromContinueListening
text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
})
}
if (!this.isPodcast) {
@@ -712,6 +725,37 @@ export default {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
},
sendToDevice(deviceName) {
// More menu func
const payload = {
// message: `Are you sure you want to send ${this.ebookFormat} ebook "${this.title}" to device "${deviceName}"?`,
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFormat, this.title, deviceName]),
callback: (confirmed) => {
if (confirmed) {
const payload = {
libraryItemId: this.libraryItemId,
deviceName
}
this.processing = true
const axios = this.$axios || this.$nuxt.$axios
axios
.$post(`/api/emails/send-ebook-to-device`, payload)
.then(() => {
this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))
})
.catch((error) => {
console.error('Failed to send ebook to device', error)
this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.store.commit('globals/setConfirmPrompt', payload)
},
removeSeriesFromContinueListening() {
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
@@ -825,8 +869,8 @@ export default {
items: this.moreMenuItems
},
created() {
this.$on('action', (func) => {
if (_this[func]) _this[func]()
this.$on('action', (action) => {
if (action.func && _this[action.func]) _this[action.func](action.data)
})
this.$on('close', () => {
_this.isMoreMenuOpen = false
@@ -838,7 +882,7 @@ export default {
var wrapperBox = this.$refs.moreIcon.getBoundingClientRect()
var el = instance.$el
var elHeight = this.moreMenuItems.length * 28 + 2
var elHeight = this.moreMenuItems.length * 28 + 10
var elWidth = 130
var bottomOfIcon = wrapperBox.top + wrapperBox.height
@@ -865,12 +909,13 @@ export default {
this.createMoreMenu()
},
async clickReadEBook() {
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
const axios = this.$axios || this.$nuxt.$axios
var libraryItem = await axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed to get lirbary item', this.libraryItemId)
return null
})
if (!libraryItem) return
this.store.commit('showEReader', libraryItem)
this.store.commit('showEReader', { libraryItem, keepProgress: true })
},
selectBtnClick(evt) {
if (this.processingBatch) return

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

@@ -1,8 +1,8 @@
<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-20">
<span class="material-icons-outlined text-8xl">record_voice_over</span>
<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 -->

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

@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs">{{ selectedText }}</span>
</span>
@@ -14,12 +14,17 @@
</div>
</button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-sm ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li>
</template>

View File

@@ -197,8 +197,8 @@ export default {
}
</script>
<style>
<style scoped>
.globalSearchMenu {
max-height: 80vh;
max-height: calc(100vh - 75px);
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span>
@@ -9,31 +9,35 @@
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-icons" style="font-size: 1.1rem">close</span>
</div>
</button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
</div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_right</span>
</div>
<!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li>
</template>
</ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_left</span>
</div>
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate">Back</span>
<span class="font-normal block truncate">Back</span>
</div>
</li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
@@ -41,16 +45,15 @@
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
</div>
</li>
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('no-series'))">
<div class="flex items-center">
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">{{ $strings.MessageNoSeries }}</span>
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div>
<!-- selected checkmark icon -->
<div v-if="`${sublist}.${item.value}` === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li>
</template>
</ul>
@@ -72,9 +75,8 @@ export default {
},
watch: {
showMenu(newVal) {
if (!newVal) {
if (this.sublist && !this.selectedItemSublist) this.sublist = null
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
if (newVal) {
this.sublist = this.selectedItemSublist
}
}
},
@@ -185,6 +187,11 @@ export default {
value: 'tracks',
sublist: true
},
{
text: this.$strings.LabelEbooks,
value: 'ebooks',
sublist: true
},
{
text: this.$strings.LabelAbridged,
value: 'abridged',
@@ -255,20 +262,20 @@ export default {
return this.bookItems
},
selectedItemSublist() {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
return this.selected?.includes('.') ? this.selected.split('.')[0] : null
},
selectedText() {
if (!this.selected) return ''
var parts = this.selected.split('.')
var filterName = this.selectItems.find((i) => i.value === parts[0])
var filterValue = null
const parts = this.selected.split('.')
const filterName = this.selectItems.find((i) => i.value === parts[0])
let filterValue = null
if (parts.length > 1) {
var decoded = this.$decode(parts[1])
const decoded = this.$decode(parts[1])
if (decoded.startsWith('aut_')) {
var author = this.authors.find((au) => au.id == decoded)
const author = this.authors.find((au) => au.id == decoded)
if (author) filterValue = author.name
} else if (decoded.startsWith('ser_')) {
var series = this.series.find((se) => se.id == decoded)
const series = this.series.find((se) => se.id == decoded)
if (series) filterValue = series.name
} else {
filterValue = decoded
@@ -334,6 +341,18 @@ export default {
}
]
},
ebooks() {
return [
{
id: 'ebook',
name: this.$strings.LabelHasEbook
},
{
id: 'supplementary',
name: this.$strings.LabelHasSupplementaryEbook
}
]
},
missing() {
return [
{
@@ -391,7 +410,7 @@ export default {
]
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {
const sublistItems = (this[this.sublist] || []).map((item) => {
if (typeof item === 'string') {
return {
text: item,
@@ -404,6 +423,13 @@ export default {
}
}
})
if (this.sublist === 'series') {
sublistItems.unshift({
text: this.$strings.MessageNoSeries,
value: this.$encode('no-series')
})
}
return sublistItems
},
filterData() {
return this.$store.state.libraries.filterData || {}
@@ -428,7 +454,7 @@ export default {
return
}
var val = option.value
const val = option.value
if (this.selected === val) {
this.showMenu = false
return
@@ -439,4 +465,10 @@ export default {
}
}
}
</script>
</script>
<style scoped>
.libraryFilterMenu {
max-height: calc(100vh - 125px);
}
</style>

View File

@@ -7,11 +7,11 @@
</span>
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>

View File

@@ -1,17 +1,17 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>

View File

@@ -15,7 +15,7 @@
</template>
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
<ui-btn color="success" type="submit" padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
<ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
</form>
</div>
<div v-else class="w-full p-4">

View File

@@ -0,0 +1,171 @@
<template>
<modals-modal ref="modal" v-model="show" name="ereader-device-edit" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
<div class="w-full px-3 py-5 md:p-12">
<div class="flex items-center -mx-1 mb-2">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" />
</div>
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" />
</div>
</div>
<div class="flex items-center pt-4">
<div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</div>
</div>
</form>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
existingDevices: {
type: Array,
default: () => []
},
ereaderDevice: {
type: Object,
default: () => null
}
},
data() {
return {
processing: false,
newDevice: {
name: '',
email: ''
}
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this.ereaderDevice ? 'Create Device' : 'Update Device'
}
},
methods: {
submitForm() {
this.$refs.ereaderNameInput.blur()
this.$refs.ereaderEmailInput.blur()
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
this.$toast.error('Name and email required')
return
}
this.newDevice.name = this.newDevice.name.trim()
this.newDevice.email = this.newDevice.email.trim()
if (!this.ereaderDevice) {
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
this.$toast.error('EReader device with that name already exists')
return
}
this.submitCreate()
} else {
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
this.$toast.error('EReader device with that name already exists')
return
}
this.submitUpdate()
}
},
submitUpdate() {
this.processing = true
const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)
const payload = {
ereaderDevices: [
...existingDevicesWithoutThisOne,
{
...this.newDevice
}
]
}
this.$axios
.$post(`/api/emails/ereader-devices`, payload)
.then((data) => {
this.$emit('update', data.ereaderDevices)
this.$toast.success('Device updated')
this.show = false
})
.catch((error) => {
console.error('Failed to update device', error)
this.$toast.error('Failed to update device')
})
.finally(() => {
this.processing = false
})
},
submitCreate() {
this.processing = true
const payload = {
ereaderDevices: [
...this.existingDevices,
{
...this.newDevice
}
]
}
this.$axios
.$post('/api/emails/ereader-devices', payload)
.then((data) => {
this.$emit('update', data.ereaderDevices || [])
this.$toast.success('Device added')
this.show = false
})
.catch((error) => {
console.error('Failed to add device', error)
this.$toast.error('Failed to add device')
})
.finally(() => {
this.processing = false
})
},
init() {
if (this.ereaderDevice) {
this.newDevice.name = this.ereaderDevice.name
this.newDevice.email = this.ereaderDevice.email
} else {
this.newDevice.name = ''
this.newDevice.email = ''
}
}
},
mounted() {}
}
</script>

View File

@@ -74,6 +74,9 @@ export default {
this.$store.commit('setEditModalTab', val)
}
},
height() {
return Math.min(this.availableHeight, 650)
},
tabs() {
return [
{
@@ -124,9 +127,6 @@ export default {
}
]
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
@@ -136,14 +136,26 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem || {}
},
selectedLibraryItemId() {
return this.selectedLibraryItem.id
},
media() {
return this.libraryItem?.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => {
if (tab.experimental && !this.showExperimentalFeatures) return false
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
if (tab.admin && !this.userIsAdminOrUp) return false
if (tab.id === 'tools' && this.isMissing) return false
if (tab.id === 'chapters' && this.isEBookOnly) return false
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
@@ -151,9 +163,6 @@ export default {
return false
})
},
height() {
return Math.min(this.availableHeight, 650)
},
tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
@@ -161,20 +170,11 @@ export default {
isMissing() {
return this.selectedLibraryItem.isMissing
},
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem || {}
},
selectedLibraryItemId() {
return this.selectedLibraryItem.id
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
isEBookOnly() {
return this.media.ebookFile && !this.media.tracks?.length
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
return this.libraryItem?.mediaType || null
},
title() {
return this.mediaMetadata.title || 'No Title'

View File

@@ -17,10 +17,10 @@
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
<div class="flex items-center">
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
<ui-file-input ref="fileInput" @change="fileUploadSelected"
><span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span
><span class="material-icons text-2xl inline-block md:!hidden">upload</span></ui-file-input
>
<ui-file-input ref="fileInput" @change="fileUploadSelected">
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
<span class="material-icons text-2xl inline-block md:!hidden">upload</span>
</ui-file-input>
</div>
<form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
@@ -36,10 +36,10 @@
</div>
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
<template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
<template v-for="localCoverFile in localCovers">
<div :key="localCoverFile.ino" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="localCoverFile.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(localCoverFile)">
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
<covers-preview-cover :src="`${cover.localPath}?token=${userToken}`" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-preview-cover :src="localCoverFile.localPath" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</div>
</template>
@@ -128,7 +128,7 @@ export default {
},
providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return [...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
},
searchTitleLabel() {
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
@@ -169,8 +169,8 @@ export default {
return this.libraryFiles
.filter((f) => f.fileType === 'image')
.map((file) => {
var _file = { ...file }
_file.localPath = `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
const _file = { ...file }
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
return _file
})
}
@@ -223,7 +223,7 @@ export default {
this.searchTitle = this.mediaMetadata.title || ''
this.searchAuthor = this.mediaMetadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
},
removeCover() {
if (!this.media.coverPath) {
@@ -288,13 +288,13 @@ export default {
},
getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor || ''}`
if (this.isPodcast) searchQuery += '&podcast=1'
return searchQuery
},
persistProvider() {
try {
localStorage.setItem('book-provider', this.provider)
localStorage.setItem('book-cover-provider', this.provider)
} catch (error) {
console.error('PersistProvider', error)
}

View File

@@ -20,7 +20,7 @@
</div>
<!-- Split to mp3 -->
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<!-- <div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
@@ -31,7 +31,7 @@
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
</div>
</div>
</div>
</div> -->
<!-- Embed Metadata -->
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
@@ -79,9 +79,6 @@ export default {
return {}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryItemId() {
return this.libraryItem?.id || null
},

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
<div class="flex items-center py-2">
<div class="flex items-center py-3">
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
<p class="pl-4 text-base">
@@ -17,13 +17,22 @@
</div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
</div>
<div v-if="mediaType == 'book'" class="py-3">
<div v-if="isBookLibrary" class="flex items-center py-3">
<ui-toggle-switch v-model="audiobooksOnly" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
<p class="pl-4 text-base">
{{ $strings.LabelSettingsAudiobooksOnly }}
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div v-if="isBookLibrary" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
</div>
</div>
<div v-if="mediaType == 'book'" class="py-3">
<div v-if="isBookLibrary" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
@@ -47,7 +56,8 @@ export default {
useSquareBookCovers: false,
disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false
skipMatchingMediaWithIsbn: false,
audiobooksOnly: false
}
},
computed: {
@@ -60,6 +70,9 @@ export default {
mediaType() {
return this.library.mediaType
},
isBookLibrary() {
return this.mediaType === 'book'
},
providers() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
@@ -72,7 +85,8 @@ export default {
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
disableWatcher: !!this.disableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
audiobooksOnly: !!this.audiobooksOnly
}
}
},
@@ -84,6 +98,7 @@ export default {
this.disableWatcher = !!this.librarySettings.disableWatcher
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
}
},
mounted() {

View File

@@ -1,11 +1,11 @@
<template>
<div class="w-full h-full">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400" :style="{ width: pageMenuWidth + 'px' }">
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index + 1)">
<p class="text-sm truncate">{{ file }}</p>
</div>
</div>
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-10 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong
@@ -14,39 +14,38 @@
</div>
</div>
<div v-if="comicMetadata" class="absolute top-0 right-52 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-32' : 'left-20'">
<span class="material-icons text-xl">download</span>
</a>
<div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
<span class="material-icons text-xl">more</span>
</div>
<div class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 156px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
<div v-if="numPages" class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu">
<span class="material-icons text-xl">menu</span>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
<div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page }} / {{ numPages }}</p>
</div>
<div class="overflow-hidden m-auto comicwrapper relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="overflow-hidden w-full h-full relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div>
</div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div>
</div>
<div class="h-full flex justify-center">
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
<img v-if="mainImg" :src="mainImg" class="object-contain h-full m-auto" />
</div>
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
<ui-loading-indicator />
</div>
</div>
<!-- <div v-show="loading" class="w-screen h-screen absolute top-0 left-0 bg-black bg-opacity-20 flex items-center justify-center">
<ui-loading-indicator />
</div> -->
</div>
</template>
@@ -61,7 +60,13 @@ Archive.init({
export default {
props: {
url: String
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
},
data() {
return {
@@ -71,6 +76,7 @@ export default {
mainImg: null,
page: 0,
numPages: 0,
pageMenuWidth: 256,
showPageMenu: false,
showInfoMenu: false,
loadTimeout: null,
@@ -87,6 +93,18 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
},
comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
},
@@ -95,9 +113,59 @@ export default {
},
canGoPrev() {
return this.page > 0
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
return Number(this.userMediaProgress.ebookLocation)
},
cleanedPageNames() {
return (
this.pages?.map((p) => {
if (p.length > 50) {
let firstHalf = p.slice(0, 22)
let lastHalf = p.slice(p.length - 23)
return `${firstHalf} ... ${lastHalf}`
}
return p
}) || []
)
}
},
methods: {
clickShowPageMenu() {
this.showInfoMenu = false
this.showPageMenu = !this.showPageMenu
},
clickShowInfoMenu() {
this.showPageMenu = false
this.showInfoMenu = !this.showInfoMenu
},
updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
if (this.savedPage === this.page) {
return
}
const payload = {
ebookLocation: this.page,
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('ComicReader.updateProgress failed:', error)
})
},
clickOutside() {
if (this.showPageMenu) this.showPageMenu = false
if (this.showInfoMenu) this.showInfoMenu = false
@@ -110,12 +178,15 @@ export default {
if (!this.canGoPrev) return
this.setPage(this.page - 1)
},
setPage(index) {
if (index < 0 || index > this.numPages - 1) {
setPage(page) {
if (page <= 0 || page > this.numPages) {
return
}
var filename = this.pages[index]
this.page = index
this.showPageMenu = false
this.showInfoMenu = false
const filename = this.pages[page - 1]
this.page = page
this.updateProgress()
return this.extractFile(filename)
},
setLoadTimeout() {
@@ -145,10 +216,11 @@ export default {
},
async extract() {
this.loading = true
console.log('Extracting', this.url)
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
})
const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject()
@@ -164,9 +236,28 @@ export default {
this.numPages = this.pages.length
// Calculate page menu size
const largestFilename = this.cleanedPageNames
.map((p) => p)
.sort((a, b) => a.length - b.length)
.pop()
const pEl = document.createElement('p')
pEl.innerText = largestFilename
pEl.style.fontSize = '0.875rem'
pEl.style.opacity = 0
pEl.style.position = 'absolute'
document.body.appendChild(pEl)
const textWidth = pEl.getBoundingClientRect()?.width
if (textWidth) {
this.pageMenuWidth = textWidth + (16 + 5 + 2 + 5)
}
pEl.remove()
if (this.pages.length) {
this.loading = false
await this.setPage(0)
const startPage = this.savedPage > 0 && this.savedPage <= this.numPages ? this.savedPage : 1
await this.setPage(startPage)
this.loadedFirstPage = true
} else {
this.$toast.error('Unable to extract pages')
@@ -249,15 +340,6 @@ export default {
<style scoped>
.pagemenu {
max-height: calc(100vh - 60px);
}
.comicimg {
height: calc(100vh - 40px);
margin: auto;
}
.comicwrapper {
width: 100vw;
height: calc(100vh - 40px);
margin-top: 20px;
max-height: calc(100% - 48px);
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="h-full w-full">
<div id="epub-reader" class="h-full w-full">
<div class="h-full flex items-center justify-center">
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center overflow-x-hidden justify-center">
<span v-if="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
@@ -24,22 +24,33 @@ import ePub from 'epubjs'
*/
export default {
props: {
url: String,
libraryItem: {
type: Object,
default: () => {}
}
},
playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
},
data() {
return {
windowWidth: 0,
windowHeight: 0,
/** @type {ePub.Book} */
book: null,
/** @type {ePub.Rendition} */
rendition: null
}
},
watch: {
playerOpen() {
this.resize()
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
/** @returns {string} */
libraryItemId() {
return this.libraryItem?.id
@@ -58,12 +69,29 @@ export default {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedEbookLocation() {
if (!this.keepProgress) return null
if (!this.userMediaProgress?.ebookLocation) return null
// Validate ebookLocation is an epubcfi
if (!String(this.userMediaProgress.ebookLocation).startsWith('epubcfi')) return null
return this.userMediaProgress.ebookLocation
},
localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}`
},
readerWidth() {
if (this.windowWidth < 640) return this.windowWidth
return this.windowWidth - 200
},
readerHeight() {
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight
return this.windowHeight - 164
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
}
},
methods: {
@@ -90,6 +118,7 @@ export default {
* @param {string} payload.ebookProgress - eBook Progress Percentage
*/
updateProgress(payload) {
if (!this.keepProgress) return
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
@@ -181,7 +210,7 @@ export default {
},
/** @param {string} location - CFI of the new location */
relocated(location) {
if (this.userMediaProgress?.ebookLocation === location.start.cfi) {
if (this.savedEbookLocation === location.start.cfi) {
return
}
@@ -201,22 +230,26 @@ export default {
const reader = this
/** @type {ePub.Book} */
reader.book = new ePub(reader.url, {
reader.book = new ePub(reader.ebookUrl, {
width: this.readerWidth,
height: window.innerHeight - 50
height: this.readerHeight - 50,
openAs: 'epub',
requestHeaders: {
Authorization: `Bearer ${this.userToken}`
}
})
/** @type {ePub.Rendition} */
reader.rendition = reader.book.renderTo('viewer', {
width: this.readerWidth,
height: window.innerHeight * 0.8
height: this.readerHeight * 0.8
})
// load saved progress
reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
reader.rendition.display(this.savedEbookLocation || reader.book.locations.start)
// load style
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
reader.rendition.themes.default({ '*': { color: '#fff!important', 'background-color': 'rgb(35 35 35)!important' }, a: { color: '#fff!important' } })
reader.book.ready.then(() => {
// set up event listeners
@@ -253,17 +286,19 @@ export default {
},
resize() {
this.windowWidth = window.innerWidth
this.rendition?.resize(this.readerWidth, window.innerHeight * 0.8)
this.windowHeight = window.innerHeight
this.rendition?.resize(this.readerWidth, this.readerHeight * 0.8)
}
},
mounted() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
window.addEventListener('resize', this.resize)
this.initEpub()
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
this.book?.destroy()
},
mounted() {
this.windowWidth = window.innerWidth
window.addEventListener('resize', this.resize)
this.initEpub()
}
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full h-full">
<div class="h-full max-h-full w-full">
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-16 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<iframe title="html-viewer" width="100%"> Loading </iframe>
</div>
</div>
@@ -15,12 +15,30 @@ import defaultCss from '@/assets/ebooks/basic.js'
export default {
props: {
url: String
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
fileId: String
},
data() {
return {}
},
computed: {},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
}
},
methods: {
addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0]
@@ -78,8 +96,11 @@ export default {
},
async initMobi() {
// Fetch mobi file as blob
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
})
var reader = new FileReader()
reader.onload = async (event) => {

View File

@@ -11,15 +11,19 @@
</div>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center">
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 flex items-center text-center">
<p class="font-mono">{{ page }} / {{ numPages }}</p>
</div>
<div class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 flex items-center text-center">
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
</div>
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
<div class="flex items-center justify-center">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="overflow-auto">
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="progressEvt" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
</div>
</div>
</div>
@@ -30,17 +34,26 @@
</template>
<script>
import pdf from 'vue-pdf'
import pdf from '@teckel/vue-pdf'
export default {
components: {
pdf
},
props: {
url: String
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
},
data() {
return {
windowWidth: 0,
windowHeight: 0,
scale: 1,
rotate: 0,
loadedRatio: 0,
page: 1,
@@ -48,35 +61,121 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
fitToPageWidth() {
return this.pdfHeight * 0.6
},
pdfWidth() {
return this.pdfHeight * 0.6667
return this.fitToPageWidth * this.scale
},
pdfHeight() {
return window.innerHeight - 120
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight - 120
return this.windowHeight - 284
},
maxScale() {
return Math.floor((this.windowWidth * 10) / this.fitToPageWidth) / 10
},
canGoNext() {
return this.page < this.numPages
},
canGoPrev() {
return this.page > 1
},
canScaleUp() {
return this.scale < this.maxScale
},
canScaleDown() {
return this.scale > 1
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
return Number(this.userMediaProgress.ebookLocation)
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
},
pdfDocInitParams() {
return {
url: this.ebookUrl,
httpHeaders: {
Authorization: `Bearer ${this.userToken}`
}
}
}
},
methods: {
zoomIn() {
this.scale += 0.1
},
zoomOut() {
this.scale -= 0.1
},
updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
const payload = {
ebookLocation: this.page,
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
},
loadedEvt() {
if (this.savedPage > 0 && this.savedPage <= this.numPages) {
this.page = this.savedPage
}
},
progressEvt(progress) {
this.loadedRatio = progress
},
numPagesLoaded(e) {
this.numPages = e
},
prev() {
if (this.page <= 1) return
this.page--
this.updateProgress()
},
next() {
if (this.page >= this.numPages) return
this.page++
this.updateProgress()
},
error(err) {
console.error(err)
},
resize() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
}
},
mounted() {}
mounted() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
window.addEventListener('resize', this.resize)
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
<div v-if="show" id="reader" class="absolute top-0 left-0 w-full z-60 bg-primary text-white" :class="{ 'reader-player-open': !!streamLibraryItem }">
<div class="absolute top-4 left-4 z-20">
<span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
</div>
@@ -17,17 +17,22 @@
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
</div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" />
<!-- TOC side nav -->
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
<div v-if="hasToC" class="w-72 h-full max-h-full absolute top-0 left-0 bg-bg shadow-xl transition-transform z-30" :class="tocOpen ? 'translate-x-0' : '-translate-x-72'" @click.stop.prevent="toggleToC">
<div class="p-4 h-full overflow-hidden">
<div v-if="hasToC" class="w-96 h-full max-h-full absolute top-0 left-0 bg-bg shadow-xl transition-transform z-30" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC">
<div class="p-4 h-full">
<p class="text-lg font-semibold mb-2">Table of Contents</p>
<div class="tocContent">
<ul>
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
<a :href="chapter.href" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
<a :href="chapter.href" class="text-white/70 hover:text-white" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
<ul v-if="chapter.subitems.length">
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
<a :href="subchapter.href" class="text-white/70 hover:text-white" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a>
</li>
</ul>
</li>
</ul>
</div>
@@ -67,6 +72,9 @@ export default {
else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
hasToC() {
return this.isEpub
},
@@ -95,10 +103,18 @@ export default {
return this.selectedLibraryItem.folderId
},
ebookFile() {
// ebook file id is passed when reading a supplementary ebook
if (this.ebookFileId) {
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
}
return this.media.ebookFile
},
ebookFormat() {
if (!this.ebookFile) return null
// Use file extension for supplementary ebook
if (!this.ebookFile.ebookFormat) {
return this.ebookFile.metadata.ext.toLowerCase().slice(1)
}
return this.ebookFile.ebookFormat
},
ebookType() {
@@ -120,23 +136,14 @@ export default {
isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
},
ebookUrl() {
if (!this.ebookFile) return null
let filepath = ''
if (this.selectedLibraryItem.isFile) {
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
} else {
const itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
const relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
}
return `/ebook/${this.libraryId}/${this.folderId}/${filepath}`
},
userToken() {
return this.$store.getters['user/getToken']
},
keepProgress() {
return this.$store.state.ereaderKeepProgress
},
ebookFileId() {
return this.$store.state.ereaderFileId
}
},
methods: {
@@ -146,7 +153,6 @@ export default {
},
openSettings() {},
hotkey(action) {
console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
@@ -187,12 +193,19 @@ export default {
</script>
<style>
/* @import url(@/assets/calibre/basic.css); */
.ebook-viewer {
height: calc(100% - 96px);
}
.tocContent {
height: calc(100% - 36px);
overflow-y: auto;
}
#reader {
height: 100%;
}
#reader.reader-player-open {
height: calc(100% - 164px);
}
@media (max-height: 400px) {
#reader.reader-player-open {
height: 100%;
}
}
</style>

View File

@@ -17,7 +17,7 @@
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="110" @action="contextMenuAction" />
</td>
</tr>
</template>
@@ -73,11 +73,11 @@ export default {
return items
},
downloadUrl() {
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.track.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}/download?token=${this.userToken}`
}
},
methods: {
contextMenuAction(action) {
contextMenuAction({ action }) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
@@ -88,7 +88,7 @@ export default {
},
deleteLibraryFile() {
const payload = {
message: 'This will delete the file from your file system. Are you sure?',
message: this.$strings.MessageConfirmDeleteFile,
callback: (confirmed) => {
if (confirmed) {
this.$axios
@@ -107,15 +107,7 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
a.download = this.track.metadata.filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
this.$downloadFile(this.downloadUrl, this.track.metadata.filename)
}
},
mounted() {}

View File

@@ -0,0 +1,87 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-2 md:pr-4">{{ $strings.HeaderEbookFiles }}</p>
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ ebookFiles.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
</div>
<transition name="slide">
<div class="w-full" v-show="showFiles">
<table class="text-sm tracksTable">
<tr>
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
<th class="text-left px-4 w-24">
{{ $strings.LabelRead }} <ui-tooltip :text="$strings.LabelReadEbookWithoutProgress" direction="top" class="inline-block"><span class="material-icons-outlined text-sm align-middle">info</span></ui-tooltip>
</th>
<th v-if="userCanDelete || userCanDownload || userIsAdmin" class="text-center w-16"></th>
</tr>
<template v-for="file in ebookFiles">
<tables-ebook-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" @read="readEbook" />
</template>
</table>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
showFiles: false,
showFullPath: false
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
ebookFiles() {
return (this.libraryItem.libraryFiles || []).filter((lf) => lf.fileType === 'ebook')
},
ebookFileIno() {
return this.libraryItem.media.ebookFile?.ino
},
audioFiles() {
if (this.libraryItem.mediaType === 'podcast') {
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || []
}
return this.libraryItem.media?.audioFiles || []
}
},
methods: {
readEbook(fileIno) {
this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
},
clickBar() {
this.showFiles = !this.showFiles
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,139 @@
<template>
<tr>
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }} <ui-tooltip :text="$strings.LabelPrimaryEbook" class="inline-block"><span v-if="isPrimary" class="material-icons-outlined text-success align-text-bottom">check_circle</span></ui-tooltip>
</td>
<td>
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<ui-icon-btn icon="auto_stories" outlined borderless icon-font-size="1.125rem" :size="8" @click="readEbook" />
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="130" :processing="processing" @action="contextMenuAction" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
file: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
downloadUrl() {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`
},
isPrimary() {
return !this.file.isSupplementary
},
libraryIsAudiobooksOnly() {
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
},
contextMenuItems() {
const items = []
if (this.userCanUpdate && !this.libraryIsAudiobooksOnly) {
items.push({
text: this.isPrimary ? this.$strings.LabelSetEbookAsSupplementary : this.$strings.LabelSetEbookAsPrimary,
action: 'updateStatus'
})
}
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
return items
}
},
methods: {
readEbook() {
this.$emit('read', this.file.ino)
},
contextMenuAction({ action }) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
this.downloadLibraryFile()
} else if (action === 'updateStatus') {
this.updateEbookStatus()
}
},
updateEbookStatus() {
this.processing = true
this.$axios
.$patch(`/api/items/${this.libraryItemId}/ebook/${this.file.ino}/status`)
.then(() => {
this.$toast.success('Ebook updated')
})
.catch((error) => {
console.error('Failed to update ebook', error)
this.$toast.error('Failed to update ebook')
})
.finally(() => {
this.processing = false
})
},
deleteLibraryFile() {
const payload = {
message: this.$strings.MessageConfirmDeleteFile,
callback: (confirmed) => {
if (confirmed) {
this.processing = true
this.$axios
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
.then(() => {
this.$toast.success('File deleted')
})
.catch((error) => {
console.error('Failed to delete file', error)
this.$toast.error('Failed to delete file')
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
}
},
mounted() {}
}
</script>

View File

@@ -38,7 +38,6 @@ export default {
type: Object,
default: () => {}
},
isMissing: Boolean,
expanded: Boolean, // start expanded
inModal: Boolean
},
@@ -70,6 +69,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

@@ -12,7 +12,7 @@
</div>
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="110" @action="contextMenuAction" />
</td>
</tr>
</template>
@@ -45,7 +45,7 @@ export default {
return this.$store.getters['user/getIsAdminOrUp']
},
downloadUrl() {
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.file.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`
},
contextMenuItems() {
const items = []
@@ -72,7 +72,7 @@ export default {
}
},
methods: {
contextMenuAction(action) {
contextMenuAction({ action }) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
@@ -83,7 +83,7 @@ export default {
},
deleteLibraryFile() {
const payload = {
message: 'This will delete the file from your file system. Are you sure?',
message: this.$strings.MessageConfirmDeleteFile,
callback: (confirmed) => {
if (confirmed) {
this.$axios
@@ -102,15 +102,7 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
a.download = this.file.metadata.filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
}
},
mounted() {}

View File

@@ -70,7 +70,10 @@ export default {
methods: {
editItem(playlistItem) {
if (playlistItem.episode) {
this.$store.commit('globals/setSelectedEpisode', playlist.episode)
const episodeIds = this.items.map((pi) => pi.episodeId)
this.$store.commit('setEpisodeTableEpisodeIds', episodeIds)
this.$store.commit('setSelectedLibraryItem', playlistItem.libraryItem)
this.$store.commit('globals/setSelectedEpisode', playlistItem.episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
} else {
const itemIds = this.items.map((i) => i.libraryItemId)

View File

@@ -1,16 +1,18 @@
<template>
<div class="w-full px-1 md:px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
<div v-if="book" class="flex h-16 md:h-20">
<div v-if="book" class="flex h-18 md:h-[5.5rem]">
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
<div class="flex h-full items-center justify-center">
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
</div>
</div>
<div class="h-full relative flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
<span class="material-icons text-2xl">play_arrow</span>
<div class="h-full flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
<div class="relative" :style="{ height: coverHeight + 'px', minHeight: coverHeight + 'px', maxHeight: coverHeight + 'px' }">
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="absolute top-0 left-0 flex items-center justify-center bg-black bg-opacity-50 h-full w-full z-10" v-show="isHovering && showPlayBtn">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
<span class="material-icons text-2xl">play_arrow</span>
</div>
</div>
</div>
</div>
@@ -19,9 +21,12 @@
<div class="truncate max-w-48 md:max-w-md">
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline text-sm md:text-base">{{ bookTitle }}</nuxt-link>
</div>
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${book.libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300"> {{ _series.text }}</nuxt-link>
</div>
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
<template v-for="(author, index) in bookAuthors">
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
<nuxt-link :key="author.id" :to="`/author/${author.id}?library=${book.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">,&nbsp;</span>
</template>
</div>
@@ -96,6 +101,19 @@ export default {
bookDuration() {
return this.$elapsedPretty(this.media.duration)
},
series() {
return this.mediaMetadata.series || []
},
seriesList() {
return this.series.map((se) => {
let text = se.name
if (se.sequence) text += ` #${se.sequence}`
return {
...se,
text
}
})
},
isMissing() {
return this.book.isMissing
},
@@ -117,6 +135,9 @@ export default {
coverSize() {
return this.$store.state.globals.isMobile ? 30 : 50
},
coverHeight() {
return this.coverSize * 1.6
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
return this.coverSize

View File

@@ -94,7 +94,7 @@ export default {
}
},
methods: {
contextMenuAction(action) {
contextMenuAction({ action }) {
this.showMobileMenu = false
if (action === 'edit') {
this.editClick()

View File

@@ -21,7 +21,7 @@
</div>
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
<template v-for="(author, index) in bookAuthors">
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
<nuxt-link :key="author.id" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">,&nbsp;</span>
</template>
<nuxt-link v-if="episode" :to="`/item/${libraryItem.id}`" class="truncate hover:underline">{{ mediaMetadata.title }}</nuxt-link>

View File

@@ -7,8 +7,7 @@
<widgets-podcast-type-indicator :type="episode.episodeType" />
</div>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle"></p>
<div class="flex justify-between pt-2 max-w-xl">
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
@@ -22,10 +21,6 @@
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</button>
<!-- <button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="isQueued ? 'text-success' : ''" @click.stop="queueBtnClick">
<span class="material-icons-outlined">{{ isQueued ? 'playlist_add_check' : 'queue' }}</span>
</button> -->
<ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="isQueued ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="isQueued ? 'text-success' : ''" direction="top">
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick" />
</ui-tooltip>
@@ -89,7 +84,7 @@ export default {
return this.episode.title || ''
},
subtitle() {
return this.episode.subtitle || ''
return this.episode.subtitle || this.description
},
description() {
return this.episode.description || ''

View File

@@ -185,7 +185,7 @@ export default {
this.searchText = this.search.toLowerCase().trim()
}, 500)
},
contextMenuAction(action) {
contextMenuAction({ action }) {
if (action === 'quick-match-episodes') {
if (this.quickMatchingEpisodes) return

View File

@@ -1,16 +1,37 @@
<template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu">
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons" :class="iconClass">more_vert</span>
</button>
<div v-else class="h-full w-full flex items-center justify-center">
<widgets-loading-spinner />
</div>
</slot>
<transition name="menu">
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm" :style="{ width: menuWidth }">
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
<template v-for="(item, index) in items">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p>{{ item.text }}</p>
<template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p>
</div>
<div
v-if="mouseoverItemIndex === index"
:key="`subitems-${index}`"
@mouseover="mouseoverSubItemMenu(index)"
@mouseleave="mouseleaveSubItemMenu(index)"
class="absolute bg-bg border rounded-b-md border-black-200 shadow-lg z-50 -ml-px py-1"
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
>
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)">
<p>{{ subitem.text }}</p>
</div>
</div>
</template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p class="text-left">{{ item.text }}</p>
</div>
</template>
</div>
@@ -31,9 +52,10 @@ export default {
default: ''
},
menuWidth: {
type: String,
default: '192px'
}
type: Number,
default: 192
},
processing: Boolean
},
data() {
return {
@@ -42,22 +64,54 @@ export default {
events: ['mousedown'],
isActive: true
},
showMenu: false
submenuWidth: 144,
showMenu: false,
mouseoverItemIndex: null,
isOverSubItemMenu: false,
openSubMenuLeft: false
}
},
computed: {
submenuLeftPos() {
return this.openSubMenuLeft ? -(this.submenuWidth - 1) : this.menuWidth - 0.5
}
},
computed: {},
methods: {
mouseoverSubItemMenu(index) {
this.isOverSubItemMenu = true
},
mouseleaveSubItemMenu(index) {
setTimeout(() => {
if (this.isOverSubItemMenu && this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
}, 1)
},
mouseoverItem(index) {
this.isOverSubItemMenu = false
this.mouseoverItemIndex = index
},
mouseleaveItem(index) {
setTimeout(() => {
if (this.isOverSubItemMenu) return
if (this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
}, 1)
},
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
this.$nextTick(() => {
const boundingRect = this.$refs.menuWrapper?.getBoundingClientRect()
if (boundingRect) {
this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5
}
})
},
clickedOutside() {
this.showMenu = false
},
clickAction(action) {
clickAction(action, data) {
if (this.disabled) return
this.showMenu = false
this.$emit('action', action)
this.$emit('action', { action, data })
}
},
mounted() {}

View File

@@ -1,6 +1,14 @@
<template>
<div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name" @click.stop.prevent="clickShowMenu">
<button
type="button"
:disabled="disabled"
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
aria-haspopup="listbox"
:aria-expanded="showMenu"
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
@click.stop.prevent="clickShowMenu"
>
<div class="flex items-center justify-center sm:justify-start">
<ui-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
@@ -8,7 +16,7 @@
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
<div class="flex items-center px-2">
@@ -93,4 +101,10 @@ export default {
},
mounted() {}
}
</script>
</script>
<style scoped>
.librariesDropdownMenu {
max-height: calc(100vh - 75px);
}
</style>

View File

@@ -1,61 +0,0 @@
<template>
<div class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="showMenu = !showMenu">
<span class="flex items-center">
<span class="block truncate">{{ label }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-2xl text-gray-100" aria-label="User Account" role="button">person</span>
</span>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
<template v-for="item in items">
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
</li>
</nuxt-link>
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
</li>
</template>
</ul>
</transition>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
default: 'Menu'
},
items: {
type: Array,
default: () => []
}
},
data() {
return {
showMenu: false
}
},
methods: {
clickOutside() {
this.showMenu = false
},
clickedOption(itemValue) {
this.$emit('action', itemValue)
this.showMenu = false
}
},
mounted() {}
}
</script>

View File

@@ -213,7 +213,9 @@ export default {
// Reload HTML content
this.$refs.trix.editor.loadHTML(newContent)
// Move cursor to end of new content updated
this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
if (this.autofocus) {
this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
}
},
getContentEndPosition() {
return this.$refs.trix.editor.getDocument().toString().length - 1

View File

@@ -1,7 +1,17 @@
<template>
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
<div ref="wrapper" class="absolute bg-bg rounded-md py-1 border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" :style="{ width: menuWidth + 'px' }" style="top: 0; left: 0">
<template v-for="(item, index) in items">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.func)">
<template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p>
</div>
<div v-if="mouseoverItemIndex === index" :key="`subitems-${index}`" @mouseover="mouseoverSubItemMenu(index)" @mouseleave="mouseleaveSubItemMenu(index)" class="absolute bg-bg rounded-b-md border border-black-200 py-1 shadow-lg z-50" :class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'" :style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }">
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.func, subitem.data)">
<p>{{ subitem.text }}</p>
</div>
</div>
</template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop="clickAction(item.func)">
<p>{{ item.text }}</p>
</div>
</template>
@@ -22,13 +32,43 @@ export default {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
}
},
submenuWidth: 144,
menuWidth: 144,
mouseoverItemIndex: null,
isOverSubItemMenu: false,
openSubMenuLeft: false
}
},
computed: {
submenuLeftPos() {
return this.openSubMenuLeft ? -this.submenuWidth : this.menuWidth - 1.5
}
},
computed: {},
methods: {
clickAction(func) {
this.$emit('action', func)
mouseoverSubItemMenu(index) {
this.isOverSubItemMenu = true
},
mouseleaveSubItemMenu(index) {
setTimeout(() => {
if (this.isOverSubItemMenu && this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
}, 1)
},
mouseoverItem(index) {
this.isOverSubItemMenu = false
this.mouseoverItemIndex = index
},
mouseleaveItem(index) {
setTimeout(() => {
if (this.isOverSubItemMenu) return
if (this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
}, 1)
},
clickAction(func, data) {
this.$emit('action', {
func,
data
})
this.close()
},
clickedOutside(e) {
@@ -44,7 +84,14 @@ export default {
this.$el.parentNode.removeChild(this.$el)
}
},
mounted() {},
mounted() {
this.$nextTick(() => {
const boundingRect = this.$refs.wrapper?.getBoundingClientRect()
if (boundingRect) {
this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5
}
})
},
beforeDestroy() {}
}
</script>

View File

@@ -1,19 +1,21 @@
<template>
<div v-if="tasksRunning" class="w-4 h-4 mx-3 relative" v-click-outside="clickOutsideObj">
<div v-if="tasksToShow.length" class="w-4 h-4 mx-3 relative" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full cursor-pointer" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<div class="flex h-full items-center justify-center">
<ui-tooltip text="Tasks running" direction="bottom" class="flex items-center">
<ui-tooltip v-if="tasksRunning" :text="$strings.LabelTasks" direction="bottom" class="flex items-center">
<widgets-loading-spinner />
</ui-tooltip>
<ui-tooltip v-else text="Activities" direction="bottom" class="flex items-center">
<span class="material-icons text-1.5xl" aria-label="Activities" role="button">notifications</span>
</ui-tooltip>
</div>
</button>
<transition name="menu">
<div class="sm:w-80 w-full relative">
<div v-show="showMenu" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalTaskRunningMenu">
<div v-show="showMenu" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalTaskRunningMenu">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-if="tasksRunningOrFailed.length">
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelTasks }}</p>
<template v-for="task in tasksRunningOrFailed">
<template v-if="tasksToShow.length">
<template v-for="task in tasksToShow">
<nuxt-link :key="task.id" v-if="actionLink(task)" :to="actionLink(task)">
<li class="text-gray-50 select-none relative hover:bg-black-400 py-1 cursor-pointer">
<cards-item-task-running-card :task="task" />
@@ -54,9 +56,10 @@ export default {
tasksRunning() {
return this.tasks.some((t) => !t.isFinished)
},
tasksRunningOrFailed() {
// return just the tasks that are running or failed in the last 1 minute
return this.tasks.filter((t) => !t.isFinished || (t.isFailed && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
tasksToShow() {
// return just the tasks that are running or failed (or show success) in the last 1 minute
const tasks = this.tasks.filter((t) => !t.isFinished || ((t.isFailed || t.showSuccess) && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
return tasks.sort((a, b) => b.startedAt - a.startedAt)
}
},
methods: {
@@ -75,6 +78,8 @@ export default {
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
case 'embed-metadata':
return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`
case 'scan-item':
return `/item/${task.data.libraryItemId}`
default:
return ''
}

View File

@@ -35,7 +35,9 @@ export default {
isSocketConnected: false,
isFirstSocketConnection: true,
socketConnectionToastId: null,
currentLang: null
currentLang: null,
multiSessionOtherSessionId: null, // Used for multiple sessions open warning toast
multiSessionCurrentSessionId: null // Used for multiple sessions open warning toast
}
},
watch: {
@@ -300,14 +302,27 @@ export default {
this.$store.commit('users/updateUserOnline', user)
},
userSessionClosed(sessionId) {
// If this session or other session is closed then dismiss multiple sessions warning toast
if (sessionId === this.multiSessionOtherSessionId || this.multiSessionCurrentSessionId === sessionId) {
this.multiSessionOtherSessionId = null
this.multiSessionCurrentSessionId = null
this.$toast.dismiss('multiple-sessions')
}
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
},
userMediaProgressUpdate(payload) {
this.$store.commit('user/updateMediaProgress', payload)
if (payload.data) {
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId)) {
// TODO: Update currently open session if being played from another device
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId) && this.$store.state.playbackSessionId !== payload.sessionId) {
this.multiSessionOtherSessionId = payload.sessionId
this.multiSessionCurrentSessionId = this.$store.state.playbackSessionId
console.log(`Media progress was updated from another session (${this.multiSessionOtherSessionId}) for currently open media. Device description=${payload.deviceDescription}. Current session id=${this.multiSessionCurrentSessionId}`)
if (this.$store.state.streamIsPlaying) {
this.$toast.update('multiple-sessions', { content: `Another session is open for this item on device ${payload.deviceDescription}`, options: { timeout: 20000, type: 'warning', pauseOnFocusLoss: false } }, true)
} else {
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
}
}
}
},
@@ -365,6 +380,11 @@ export default {
adminMessageEvt(message) {
this.$toast.info(message)
},
ereaderDevicesUpdated(data) {
if (!data?.ereaderDevices) return
this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices)
},
initializeSocket() {
this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@@ -437,6 +457,9 @@ export default {
this.socket.on('task_finished', this.taskFinished)
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
// EReader Device Listeners
this.socket.on('ereader-devices-updated', this.ereaderDevicesUpdated)
this.socket.on('backup_applied', this.backupApplied)
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
@@ -468,9 +491,9 @@ export default {
}
},
checkActiveElementIsInput() {
var activeElement = document.activeElement
var inputs = ['input', 'select', 'button', 'textarea']
return activeElement && inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1
const activeElement = document.activeElement
const inputs = ['input', 'select', 'button', 'textarea', 'trix-editor']
return activeElement && inputs.some((i) => i === activeElement.tagName.toLowerCase())
},
getHotkeyName(e) {
var keyCode = e.keyCode || e.which
@@ -537,12 +560,6 @@ export default {
.catch((err) => console.error(err))
},
initLocalStorage() {
// If experimental features set in local storage
var experimentalFeaturesSaved = localStorage.getItem('experimental')
if (experimentalFeaturesSaved === '1') {
this.$store.commit('setExperimentalFeatures', true)
}
// Queue auto play
var playerQueueAutoPlay = localStorage.getItem('playerQueueAutoPlay')
this.$store.commit('setPlayerQueueAutoPlay', playerQueueAutoPlay !== '0')

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' }
]
@@ -75,9 +71,8 @@ module.exports = {
],
proxy: {
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' + process.env : '/' },
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/api/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
},
io: {

146
client/package-lock.json generated
View File

@@ -1,16 +1,17 @@
{
"name": "audiobookshelf-client",
"version": "2.2.19",
"version": "2.2.23",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.2.19",
"version": "2.2.23",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
"@nuxtjs/proxy": "^2.1.0",
"@teckel/vue-pdf": "^4.3.5",
"core-js": "^3.16.0",
"cron-parser": "^4.7.1",
"date-fns": "^2.25.0",
@@ -21,7 +22,6 @@
"nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1",
"v-click-outside": "^3.1.2",
"vue-pdf": "^4.2.0",
"vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3"
},
@@ -2983,6 +2983,43 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
},
"node_modules/@teckel/vue-pdf": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@teckel/vue-pdf/-/vue-pdf-4.3.5.tgz",
"integrity": "sha512-g2DAbZMPbPc7NPFImOsU/e7rt7wfdmBkmFa2kPsB4x+k+Bs8yC5Icmq/VnTSEq/Y8bNvEY7i6+JoicGnlfQL7Q==",
"dependencies": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "^2.5.207 <2.8.0",
"raw-loader": "^4.0.1",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
}
},
"node_modules/@teckel/vue-pdf/node_modules/json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dependencies": {
"minimist": "^1.2.0"
},
"bin": {
"json5": "lib/cli.js"
}
},
"node_modules/@teckel/vue-pdf/node_modules/loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^1.0.1"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/@types/anymatch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
@@ -15921,43 +15958,6 @@
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
},
"node_modules/vue-pdf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
"dependencies": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "2.6.347",
"raw-loader": "^4.0.2",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
}
},
"node_modules/vue-pdf/node_modules/json5": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dependencies": {
"minimist": "^1.2.0"
},
"bin": {
"json5": "lib/cli.js"
}
},
"node_modules/vue-pdf/node_modules/loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^1.0.1"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/vue-resize-sensor": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
@@ -19591,6 +19591,39 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
},
"@teckel/vue-pdf": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@teckel/vue-pdf/-/vue-pdf-4.3.5.tgz",
"integrity": "sha512-g2DAbZMPbPc7NPFImOsU/e7rt7wfdmBkmFa2kPsB4x+k+Bs8yC5Icmq/VnTSEq/Y8bNvEY7i6+JoicGnlfQL7Q==",
"requires": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "^2.5.207 <2.8.0",
"raw-loader": "^4.0.1",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
},
"dependencies": {
"json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"requires": {
"minimist": "^1.2.0"
}
},
"loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^1.0.1"
}
}
}
},
"@types/anymatch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
@@ -29618,39 +29651,6 @@
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
},
"vue-pdf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
"requires": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "2.6.347",
"raw-loader": "^4.0.2",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
},
"dependencies": {
"json5": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"requires": {
"minimist": "^1.2.0"
}
},
"loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^1.0.1"
}
}
}
},
"vue-resize-sensor": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.2.19",
"version": "2.2.23",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {
@@ -15,6 +15,7 @@
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
"@nuxtjs/proxy": "^2.1.0",
"@teckel/vue-pdf": "^4.3.5",
"core-js": "^3.16.0",
"cron-parser": "^4.7.1",
"date-fns": "^2.25.0",
@@ -25,7 +26,6 @@
"nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1",
"v-click-outside": "^3.1.2",
"vue-pdf": "^4.2.0",
"vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3"
},

View File

@@ -43,8 +43,8 @@
<script>
export default {
async asyncData({ store, app, params, redirect }) {
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
async asyncData({ store, app, params, redirect, query }) {
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${query.library || store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
console.error('Failed to get author', error)
return null
})
@@ -53,6 +53,10 @@ export default {
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
}
if (query.library) {
store.commit('libraries/setCurrentLibrary', query.library)
}
return {
author
}

View File

@@ -145,7 +145,7 @@ export default {
feed: this.rssFeed
})
},
contextMenuAction(action) {
contextMenuAction({ action }) {
if (action === 'delete') {
this.removeClick()
} else if (action === 'create-playlist') {

View File

@@ -4,7 +4,7 @@
<div class="configContent" :class="`page-${currentPage}`">
<div v-show="isMobilePortrait" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2 cursor-pointer" @click.stop.prevent="toggleShowMore">
<span class="material-icons text-2xl cursor-pointer">arrow_forward</span>
<p class="pl-3 capitalize">{{ $strings.HeaderSettings }}</p>
<p class="pl-3 capitalize">{{ currentPage }}</p>
</div>
<nuxt-child />
</div>
@@ -55,6 +55,7 @@ export default {
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
else if (pageName === 'users') return this.$strings.HeaderUsers
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
else if (pageName === 'email') return this.$strings.HeaderEmail
}
return this.$strings.HeaderSettings
}
@@ -79,14 +80,6 @@ export default {
width: 900px;
max-width: calc(100% - 176px);
}
.configContent.page-library-stats {
width: 1200px;
}
@media (max-width: 1550px) {
.configContent.page-library-stats {
margin-left: 176px;
}
}
@media (max-width: 1240px) {
.configContent {
margin-left: 176px;
@@ -98,8 +91,5 @@ export default {
width: 100%;
max-width: 100%;
}
.configContent.page-library-stats {
margin-left: 0px;
}
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<div>
<app-settings-content :header-text="$strings.HeaderEmailSettings" :description="''">
<form @submit.prevent="submitForm">
<div class="flex items-center -mx-1 mb-2">
<div class="w-full md:w-3/4 px-1">
<ui-text-input-with-label ref="hostInput" v-model="newSettings.host" :disabled="savingSettings" :label="$strings.LabelHost" />
</div>
<div class="w-full md:w-1/4 px-1">
<ui-text-input-with-label ref="portInput" v-model="newSettings.port" type="number" :disabled="savingSettings" :label="$strings.LabelPort" />
</div>
</div>
<div class="flex items-center mb-2 py-3">
<ui-toggle-switch labeledBy="email-settings-secure" v-model="newSettings.secure" :disabled="savingSettings" />
<ui-tooltip :text="$strings.LabelEmailSettingsSecureHelp">
<div class="pl-4 flex items-center">
<span id="email-settings-secure">{{ $strings.LabelEmailSettingsSecure }}</span>
<span class="material-icons text-lg pl-1">info_outlined</span>
</div>
</ui-tooltip>
</div>
<div class="flex items-center -mx-1 mb-2">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="userInput" v-model="newSettings.user" :disabled="savingSettings" :label="$strings.LabelUsername" />
</div>
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="passInput" v-model="newSettings.pass" type="password" :disabled="savingSettings" :label="$strings.LabelPassword" />
</div>
</div>
<div class="flex items-center -mx-1 mb-2">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="fromInput" v-model="newSettings.fromAddress" :disabled="savingSettings" :label="$strings.LabelEmailSettingsFromAddress" />
</div>
</div>
<div class="flex items-center justify-between pt-4">
<ui-btn :loading="sendingTest" :disabled="savingSettings || !newSettings.host" type="button" @click="sendTestClick">{{ $strings.ButtonTest }}</ui-btn>
<ui-btn :loading="savingSettings" :disabled="!hasUpdates" type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</form>
<div v-show="loading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-25 flex items-center justify-center">
<ui-loading-indicator />
</div>
</app-settings-content>
<app-settings-content :header-text="$strings.HeaderEReaderDevices" showAddButton :description="''" @clicked="addNewDeviceClick">
<table v-if="existingEReaderDevices.length" class="tracksTable my-4">
<tr>
<th class="text-left">{{ $strings.LabelName }}</th>
<th class="text-left">{{ $strings.LabelEmail }}</th>
<th class="w-40"></th>
</tr>
<tr v-for="device in existingEReaderDevices" :key="device.name">
<td>
<p class="text-sm md:text-base text-gray-100">{{ device.name }}</p>
</td>
<td class="text-left">
<p class="text-sm md:text-base text-gray-100">{{ device.email }}</p>
</td>
<td class="w-40">
<div class="flex justify-end items-center h-10">
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name" class="mx-1" @click="editDeviceClick(device)" />
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name" @click="deleteDeviceClick(device)" />
</div>
</td>
</tr>
</table>
<div v-else class="text-center py-4">
<p class="text-lg text-gray-100">No Devices</p>
</div>
</app-settings-content>
<modals-emails-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="existingEReaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
</div>
</template>
<script>
export default {
data() {
return {
loading: false,
savingSettings: false,
sendingTest: false,
deletingDeviceName: null,
settings: null,
newSettings: {
host: null,
port: 465,
secure: true,
user: null,
pass: null,
fromAddress: null
},
newEReaderDevice: {
name: '',
email: ''
},
selectedEReaderDevice: null,
showEReaderDeviceModal: false
}
},
computed: {
hasUpdates() {
if (!this.settings) return true
for (const key in this.newSettings) {
if (key === 'ereaderDevices') continue
if (this.newSettings[key] !== this.settings[key]) return true
}
return false
},
existingEReaderDevices() {
return this.settings?.ereaderDevices || []
}
},
methods: {
editDeviceClick(device) {
this.selectedEReaderDevice = device
this.showEReaderDeviceModal = true
},
deleteDeviceClick(device) {
const payload = {
message: `Are you sure you want to delete e-reader device "${device.name}"?`,
callback: (confirmed) => {
if (confirmed) {
this.deleteDevice(device)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteDevice(device) {
const payload = {
ereaderDevices: this.existingEReaderDevices.filter((d) => d.name !== device.name)
}
this.deletingDeviceName = device.name
this.$axios
.$patch(`/emails/ereader-devices`, payload)
.then((data) => {
this.ereaderDevicesUpdated(data.ereaderDevices)
this.$toast.success('Device deleted')
})
.catch((error) => {
console.error('Failed to delete device', error)
this.$toast.error('Failed to delete device')
})
.finally(() => {
this.deletingDeviceName = null
})
},
ereaderDevicesUpdated(ereaderDevices) {
this.settings.ereaderDevices = ereaderDevices
this.newSettings.ereaderDevices = ereaderDevices.map((d) => ({ ...d }))
},
addNewDeviceClick() {
this.selectedEReaderDevice = null
this.showEReaderDeviceModal = true
},
sendTestClick() {
this.sendingTest = true
this.$axios
.$post('/api/emails/test')
.then(() => {
this.$toast.success('Test Email Sent')
})
.catch((error) => {
console.error('Failed to send test email', error)
const errorMsg = error.response.data || 'Failed to send test email'
this.$toast.error(errorMsg)
})
.finally(() => {
this.sendingTest = false
})
},
validateForm() {
for (const ref of [this.$refs.hostInput, this.$refs.portInput, this.$refs.userInput, this.$refs.passInput, this.$refs.fromInput]) {
if (ref?.blur) ref.blur()
}
if (this.newSettings.port) {
this.newSettings.port = Number(this.newSettings.port)
}
return true
},
submitForm() {
if (!this.validateForm()) return
const updatePayload = {
host: this.newSettings.host,
port: this.newSettings.port,
secure: this.newSettings.secure,
user: this.newSettings.user,
pass: this.newSettings.pass,
fromAddress: this.newSettings.fromAddress
}
this.savingSettings = true
this.$axios
.$patch('/api/emails/settings', updatePayload)
.then((data) => {
this.settings = data.settings
this.newSettings = {
...data.settings
}
this.$toast.success('Email settings updated')
})
.catch((error) => {
console.error('Failed to update email settings', error)
this.$toast.error('Failed to update email settings')
})
.finally(() => {
this.savingSettings = false
})
},
init() {
this.loading = true
this.$axios
.$get(`/api/emails/settings`)
.then((data) => {
this.settings = data.settings
this.newSettings = {
...this.settings
}
})
.catch((error) => {
console.error('Failed to get email settings', error)
this.$toast.error('Failed to load email settings')
})
.finally(() => {
this.loading = false
})
}
},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>

View File

@@ -39,11 +39,15 @@
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
</div>
<div class="flex items-center py-2">
<div class="flex items-center py-2 mb-2">
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="w-44 mb-2">
<ui-dropdown v-model="newServerSettings.metadataFileFormat" small :items="metadataFileFormats" label="Metadata File Format" @input="updateMetadataFileFormat" :disabled="updatingServerSettings" />
</div>
<div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsDisplay }}</h2>
</div>
@@ -162,7 +166,8 @@
</ui-tooltip>
</div>
<div class="pt-4">
<!-- old experimental features -->
<!-- <div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsExperimental }}</h2>
</div>
@@ -176,26 +181,6 @@
</a>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-enable-e-reader" v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
<ui-tooltip :text="$strings.LabelSettingsEnableEReaderHelp">
<p class="pl-4">
<span id="settings-enable-e-reader">{{ $strings.LabelSettingsEnableEReader }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<!-- <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerUseTone" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseTone', val)" />
<ui-tooltip text="Tone library for metadata">
<p class="pl-4">
Use Tone library for metadata
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div> -->
</div>
</div>
@@ -272,7 +257,17 @@ export default {
useBookshelfView: false,
isPurgingCache: false,
newServerSettings: {},
showConfirmPurgeCache: false
showConfirmPurgeCache: false,
metadataFileFormats: [
{
text: '.json',
value: 'json'
},
{
text: '.abs',
value: 'abs'
}
]
}
},
watch: {
@@ -289,14 +284,6 @@ export default {
providers() {
return this.$store.state.scanners.providers
},
showExperimentalFeatures: {
get() {
return this.$store.state.showExperimentalFeatures
},
set(val) {
this.$store.commit('setExperimentalFeatures', val)
}
},
dateFormats() {
return this.$store.state.globals.dateFormats
},
@@ -341,6 +328,10 @@ export default {
updateServerLanguage(val) {
this.updateSettingsKey('language', val)
},
updateMetadataFileFormat(val) {
if (this.serverSettings.metadataFileFormat === val) return
this.updateSettingsKey('metadataFileFormat', val)
},
updateSettingsKey(key, val) {
this.updateServerSettings({
[key]: val
@@ -350,8 +341,7 @@ export default {
this.updatingServerSettings = true
this.$store
.dispatch('updateServerSettings', payload)
.then((success) => {
console.log('Updated Server Settings', success)
.then(() => {
this.updatingServerSettings = false
this.$toast.success('Server settings updated')

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

@@ -42,100 +42,16 @@
<nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
<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>
<!-- Alerts -->
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
<span class="material-icons text-2xl">warning_amber</span>
<p v-if="userIsAdminOrUp" class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader can be enabled in config.</p>
<p v-else class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader must be enabled by a server admin.</p>
</div>
<!-- Podcast episode downloads queue -->
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div class="flex items-center">
@@ -199,7 +115,7 @@
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" menu-width="148px" @action="contextMenuAction">
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
<template #default="{ showMenu, clickShowMenu, disabled }">
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons">more_horiz</span>
@@ -224,7 +140,9 @@
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item="libraryItem" class="mt-6" />
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" class="mt-6" />
<tables-library-files-table v-if="libraryFiles.length" :library-item="libraryItem" class="mt-6" />
</div>
</div>
</div>
@@ -277,12 +195,6 @@ export default {
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
enableEReader() {
return this.$store.getters['getServerSetting']('enableEReader')
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
@@ -334,14 +246,11 @@ export default {
return this.tracks.length
},
showReadButton() {
return this.ebookFile && (this.showExperimentalFeatures || this.enableEReader)
return this.ebookFile
},
libraryId() {
return this.libraryItem.libraryId
},
folderId() {
return this.libraryItem.folderId
},
libraryItemId() {
return this.libraryItem.id
},
@@ -367,19 +276,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 +289,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,29 +302,16 @@ 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 || []
},
ebookFiles() {
return this.libraryFiles.filter((lf) => lf.fileType === 'ebook')
},
ebookFile() {
return this.media.ebookFile
},
@@ -454,9 +322,6 @@ export default {
// Music track
return this.media.audioFile
},
showExperimentalReadAlert() {
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
},
description() {
return this.mediaMetadata.description || ''
},
@@ -555,6 +420,19 @@ export default {
})
}
if (this.ebookFile && this.$store.state.libraries.ereaderDevices?.length) {
items.push({
text: this.$strings.LabelSendEbookToDevice,
subitems: this.$store.state.libraries.ereaderDevices.map((d) => {
return {
text: d.name,
action: 'sendToDevice',
data: d.name
}
})
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
@@ -630,7 +508,7 @@ export default {
this.$store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'cover' })
},
openEbook() {
this.$store.commit('showEReader', this.libraryItem)
this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: true })
},
toggleFinished(confirmed = false) {
if (!this.userIsFinished && this.progressPercent > 0 && !confirmed) {
@@ -726,10 +604,11 @@ export default {
}
},
clearProgressClick() {
if (!this.userMediaProgress) return
if (confirm(`Are you sure you want to reset your progress?`)) {
this.resettingProgress = true
this.$axios
.$delete(`/api/me/progress/${this.libraryItemId}`)
.$delete(`/api/me/progress/${this.userMediaProgress.id}`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
@@ -800,14 +679,7 @@ export default {
}
},
downloadLibraryItem() {
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
this.$downloadFile(this.downloadUrl)
},
deleteLibraryItem() {
const payload = {
@@ -834,7 +706,35 @@ export default {
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
contextMenuAction(action) {
sendToDevice(deviceName) {
const payload = {
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFile.ebookFormat, this.title, deviceName]),
callback: (confirmed) => {
if (confirmed) {
const payload = {
libraryItemId: this.libraryItemId,
deviceName
}
this.processing = true
this.$axios
.$post(`/api/emails/send-ebook-to-device`, payload)
.then(() => {
this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))
})
.catch((error) => {
console.error('Failed to send ebook to device', error)
this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
contextMenuAction({ action, data }) {
if (action === 'collections') {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowCollectionsModal', true)
@@ -849,6 +749,8 @@ export default {
this.downloadLibraryItem()
} else if (action === 'delete') {
this.deleteLibraryItem()
} else if (action === 'sendToDevice') {
this.sendToDevice(data)
}
}
},

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

@@ -45,7 +45,7 @@
<widgets-podcast-type-indicator :type="episode.episodeType" />
</div>
<p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p>
<p class="text-sm text-gray-200 mb-4 episode-subtitle-long" v-html="episode.subtitle || episode.description" />
<div class="flex items-center">
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">

View File

@@ -107,7 +107,7 @@ export default {
const payload = {
newRoot: { ...this.newRoot }
}
var success = await this.$axios
const success = await this.$axios
.$post('/init', payload)
.then(() => true)
.catch((error) => {
@@ -124,9 +124,10 @@ export default {
location.reload()
},
setUser({ user, userDefaultLibraryId, serverSettings, Source, feeds }) {
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) {
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source)
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
this.$setServerLanguageCode(serverSettings.language)
if (serverSettings.chromecastEnabled) {

View File

@@ -78,6 +78,7 @@
</template>
<script>
import Path from 'path'
import uploadHelpers from '@/mixins/uploadHelpers'
export default {
@@ -243,7 +244,7 @@ export default {
ref.setUploadStatus(status)
}
},
uploadItem(item) {
async uploadItem(item) {
var form = new FormData()
form.set('title', item.title)
if (!this.selectedLibraryIsPodcast) {
@@ -294,18 +295,41 @@ export default {
return
}
var items = this.validateItems()
const items = this.validateItems()
if (!items) {
this.$toast.error('Some invalid items')
return
}
this.processing = true
var itemsUploaded = 0
var itemsFailed = 0
for (let i = 0; i < items.length; i++) {
var item = items[i]
const itemsToUpload = []
// Check if path already exists before starting upload
// uploading fails if path already exists
for (const item of items) {
const filepath = Path.join(this.selectedFolder.fullPath, item.directory)
const exists = await this.$axios
.$post(`/api/filesystem/pathexists`, { filepath })
.then((data) => {
if (data.exists) {
this.$toast.error(`Filepath "${filepath}" already exists on server`)
}
return data.exists
})
.catch((error) => {
console.error('Failed to check if filepath exists', error)
return false
})
if (!exists) {
itemsToUpload.push(item)
}
}
let itemsUploaded = 0
let itemsFailed = 0
for (const item of itemsToUpload) {
this.updateItemCardStatus(item.index, 'uploading')
var result = await this.uploadItem(item)
const result = await this.uploadItem(item)
if (result) itemsUploaded++
else itemsFailed++
this.updateItemCardStatus(item.index, result ? 'success' : 'failed')

View File

@@ -24,7 +24,6 @@ export default class PlayerHandler {
this.failedProgressSyncs = 0
this.lastSyncTime = 0
this.lastSyncedAt = 0
this.listeningTimeSinceSync = 0
this.playInterval = null
@@ -53,6 +52,11 @@ export default class PlayerHandler {
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
}
setSessionId(sessionId) {
this.currentSessionId = sessionId
this.ctx.$store.commit('setPlaybackSessionId', sessionId)
}
load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
this.libraryItem = libraryItem
this.isVideo = libraryItem.mediaType === 'video'
@@ -183,7 +187,7 @@ export default class PlayerHandler {
}
async prepare(forceTranscode = false) {
this.currentSessionId = null // Reset session
this.setSessionId(null) // Reset session
const payload = {
deviceInfo: {
@@ -210,6 +214,8 @@ export default class PlayerHandler {
this.playWhenReady = false
this.initialPlaybackRate = playbackRate
this.startTimeOverride = undefined
this.lastSyncTime = 0
this.listeningTimeSinceSync = 0
this.prepareSession(session)
}
@@ -217,7 +223,7 @@ export default class PlayerHandler {
prepareSession(session) {
this.failedProgressSyncs = 0
this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime
this.currentSessionId = session.id
this.setSessionId(session.id)
this.displayTitle = session.displayTitle
this.displayAuthor = session.displayAuthor
@@ -262,7 +268,7 @@ export default class PlayerHandler {
this.player = null
this.playerState = 'IDLE'
this.libraryItem = null
this.currentSessionId = null
this.setSessionId(null)
this.startTime = 0
this.stopPlayInterval()
}
@@ -287,7 +293,8 @@ export default class PlayerHandler {
const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
lastTick = Date.now()
this.listeningTimeSinceSync += exactTimeElapsed
if (this.listeningTimeSinceSync >= 5) {
const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 5 : 20
if (this.listeningTimeSinceSync >= TimeToWaitBeforeSync) {
this.sendProgressSync(currentTime)
}
}, 1000)
@@ -297,13 +304,17 @@ export default class PlayerHandler {
let syncData = null
if (this.player) {
const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
syncData = {
timeListened: listeningTimeToAdd,
duration: this.getDuration(),
currentTime: this.getCurrentTime()
// When opening player and quickly closing dont save progress
if (listeningTimeToAdd > 20) {
syncData = {
timeListened: listeningTimeToAdd,
duration: this.getDuration(),
currentTime: this.getCurrentTime()
}
}
}
this.listeningTimeSinceSync = 0
this.lastSyncTime = 0
return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 1000 }).catch((error) => {
console.error('Failed to close session', error)
})
@@ -322,6 +333,7 @@ export default class PlayerHandler {
duration: this.getDuration(),
currentTime
}
this.listeningTimeSinceSync = 0
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 3000 }).then(() => {
this.failedProgressSyncs = 0
@@ -383,13 +395,13 @@ export default class PlayerHandler {
this.player.setPlaybackRate(playbackRate)
}
seek(time) {
seek(time, shouldSync = true) {
if (!this.player) return
this.player.seek(time, this.playerPlaying)
this.ctx.setCurrentTime(time)
// Update progress if paused
if (!this.playerPlaying) {
if (!this.playerPlaying && shouldSync) {
this.sendProgressSync(time)
}
}

View File

@@ -7,15 +7,10 @@ export default function ({ $axios, store, $config }) {
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
return
}
var bearerToken = store.state.user.user ? store.state.user.user.token : null
const bearerToken = store.state.user.user?.token || null
if (bearerToken) {
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
}
if (process.env.NODE_ENV === 'development') {
config.url = `/dev${config.url}`
console.log('Making request to ' + config.url)
}
})
$axios.onError(error => {

View File

@@ -1,10 +1,10 @@
const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'],
text: ['txt'],
metadata: ['opf', 'abs']
metadata: ['opf', 'abs', 'xml', 'json']
}
const DownloadStatus = {

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

@@ -145,6 +145,25 @@ Vue.prototype.$getNextScheduledDate = (expression) => {
return interval.next().toDate()
}
Vue.prototype.$downloadFile = (url, filename = null, openInNewTab = false) => {
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
if (filename) {
a.download = filename
}
if (openInNewTab) {
a.target = '_blank'
}
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
}
export function supplant(str, subs) {
// source: http://crockford.com/javascript/remedial.html
return str.replace(/{([^{}]*)}/g,

View File

File diff suppressed because one or more lines are too long

View File

@@ -6,6 +6,7 @@ export const state = () => ({
Source: null,
versionData: null,
serverSettings: null,
playbackSessionId: null,
streamLibraryItem: null,
streamEpisodeId: null,
streamIsPlaying: false,
@@ -16,11 +17,12 @@ export const state = () => ({
editPodcastModalTab: 'details',
showEditModal: false,
showEReader: false,
ereaderKeepProgress: false,
ereaderFileId: null,
selectedLibraryItem: null,
developerMode: false,
processingBatch: false,
previousPath: '/',
showExperimentalFeatures: false,
bookshelfBookIds: [],
episodeTableEpisodeIds: [],
openModal: null,
@@ -35,7 +37,7 @@ export const getters = {
return state.serverSettings[key]
},
getLibraryItemIdStreaming: state => {
return state.streamLibraryItem ? state.streamLibraryItem.id : null
return state.streamLibraryItem?.id || null
},
getIsStreamingFromDifferentLibrary: (state, getters, rootState) => {
if (!state.streamLibraryItem) return false
@@ -150,6 +152,9 @@ export const mutations = {
if (!settings) return
state.serverSettings = settings
},
setPlaybackSessionId(state, playbackSessionId) {
state.playbackSessionId = playbackSessionId
},
setMediaPlaying(state, payload) {
if (!payload) {
state.streamLibraryItem = null
@@ -206,8 +211,10 @@ export const mutations = {
setEditPodcastModalTab(state, tab) {
state.editPodcastModalTab = tab
},
showEReader(state, libraryItem) {
showEReader(state, { libraryItem, keepProgress, fileId }) {
state.selectedLibraryItem = libraryItem
state.ereaderKeepProgress = keepProgress
state.ereaderFileId = fileId
state.showEReader = true
},
@@ -223,10 +230,6 @@ export const mutations = {
setProcessingBatch(state, val) {
state.processingBatch = val
},
setExperimentalFeatures(state, val) {
state.showExperimentalFeatures = val
localStorage.setItem('experimental', val ? 1 : 0)
},
setOpenModal(state, val) {
state.openModal = val
},

View File

@@ -11,7 +11,8 @@ export const state = () => ({
filterData: null,
numUserPlaylists: 0,
collections: [],
userPlaylists: []
userPlaylists: [],
ereaderDevices: []
})
export const getters = {
@@ -56,6 +57,9 @@ export const getters = {
if (!getters.getCurrentLibrarySettings || isNaN(getters.getCurrentLibrarySettings.coverAspectRatio)) return 1
return getters.getCurrentLibrarySettings.coverAspectRatio === Constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1
},
getLibraryIsAudiobooksOnly: (state, getters) => {
return !!getters.getCurrentLibrarySettings?.audiobooksOnly
},
getCollection: state => id => {
return state.collections.find(c => c.id === id)
},
@@ -339,5 +343,8 @@ export const mutations = {
removeUserPlaylist(state, playlist) {
state.userPlaylists = state.userPlaylists.filter(p => p.id !== playlist.id)
state.numUserPlaylists = state.userPlaylists.length
},
setEReaderDevices(state, ereaderDevices) {
state.ereaderDevices = ereaderDevices
}
}

View File

@@ -19,7 +19,7 @@ export const getters = {
getIsRoot: (state) => state.user && state.user.type === 'root',
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
getToken: (state) => {
return state.user ? state.user.token : null
return state.user?.token || null
},
getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => {
if (!state.user.mediaProgress) return null
@@ -80,7 +80,7 @@ export const actions = {
if (state.settings.orderBy == 'media.metadata.publishedYear') {
settingsUpdate.orderBy = 'media.metadata.title'
}
const invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues']
const invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues', 'ebooks', 'abridged']
const filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
if (invalidFilters.includes(filterByFirstPart)) {
settingsUpdate.filterBy = 'all'

View File

@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Alles löschen",
"ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge",
"ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste",
"ButtonReScan": "Neu scannen",
"ButtonReset": "Zurücksetzen",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "M4B-Kodierung starten",
"ButtonStartMetadataEmbed": "Metadateneinbettung starten",
"ButtonSubmit": "Ok",
"ButtonTest": "Test",
"ButtonUpload": "Hochladen",
"ButtonUploadBackup": "Sicherung hochladen",
"ButtonUploadCover": "Titelbild hochladen",
@@ -96,7 +98,11 @@
"HeaderCurrentDownloads": "Aktuelle Downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Warteschlange",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episoden",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Dateien",
"HeaderFindChapters": "Kapitel suchen",
"HeaderIgnoredFiles": "Ignorierte Dateien",
@@ -197,6 +203,7 @@
"LabelComplete": "Vollständig",
"LabelConfirmPassword": "Passwort bestätigen",
"LabelContinueListening": "Weiterhören",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Serien fortsetzen",
"LabelCover": "Titelbild",
"LabelCoverImageURL": "URL des Titelbildes",
@@ -216,7 +223,13 @@
"LabelDownload": "Herunterladen",
"LabelDuration": "Laufzeit",
"LabelDurationFound": "Gefundene Laufzeit:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Bearbeiten",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"LabelEmbeddedCover": "Eingebettetes Cover",
"LabelEnable": "Aktivieren",
"LabelEnd": "Ende",
@@ -239,6 +252,9 @@
"LabelGenre": "Kategorie",
"LabelGenres": "Kategorien",
"LabelHardDeleteFile": "Datei dauerhaft löschen",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host",
"LabelHour": "Stunde",
"LabelIcon": "Symbol",
"LabelIncludeInTracklist": "In die Titelliste aufnehmen",
@@ -323,14 +339,19 @@
"LabelPlayMethod": "Abspielmethode",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPodcastType": "Podcast Typ",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
"LabelPreventIndexing": "Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Fortschritt",
"LabelProvider": "Anbieter",
"LabelPubDate": "Veröffentlichungsdatum",
"LabelPublisher": "Herausgeber",
"LabelPublishYear": "Jahr",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Kürzlich hinzugefügt",
"LabelRecentSeries": "Aktuelle Serien",
"LabelRecommended": "Empfohlen",
@@ -347,23 +368,26 @@
"LabelSearchTitle": "Titel",
"LabelSearchTitleOrASIN": "Titel oder ASIN",
"LabelSeason": "Staffel",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Reihenfolge",
"LabelSeries": "Serien",
"LabelSeriesName": "Serienname",
"LabelSeriesProgress": "Serienfortschritt",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
"LabelSettingsChromecastSupport": "Chromecastunterstützung",
"LabelSettingsDateFormat": "Datumsformat",
"LabelSettingsDisableWatcher": "Überwachung deaktivieren",
"LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren",
"LabelSettingsDisableWatcherHelp": "Deaktiviert das automatische Hinzufügen/Aktualisieren von Elementen, wenn Dateiänderungen erkannt werden. *Erfordert einen Server-Neustart",
"LabelSettingsEnableEReader": "E-Reader für alle Benutzer aktivieren",
"LabelSettingsEnableEReaderHelp": "Der E-Reader befindet sich noch in der Entwicklung, aber mit dieser Einstellung können Sie ihn für alle Benutzer aktivieren (oder aktivieren Sie die Option \"Experimentelle Funktionen\", dann Sie ihn nur selbst verwenden)",
"LabelSettingsExperimentalFeatures": "Experimentelle Funktionen",
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.",
"LabelSettingsFindCovers": "Suche Titelbilder",
"LabelSettingsFindCoversHelp": "Wenn Ihr Medium kein eingebettetes Titelbild oder kein Titelbild im Ordner hat, versucht der Scanner, ein Titelbild online zu finden.<br>Hinweis: Dies verlängert die Scandauer",
"LabelSettingsHomePageBookshelfView": "Starseite verwendet die Bücherregalansicht",
"LabelSettingsHomePageBookshelfView": "Startseite verwendet die Bücherregalansicht",
"LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht",
"LabelSettingsOverdriveMediaMarkers": "Verwende Overdrive Media Marker für Kapitel",
"LabelSettingsOverdriveMediaMarkersHelp": "MP3-Dateien von Overdrive werden mit eingebetteten Kapitel-Timings als benutzerdefinierte Metadaten geliefert. Wenn Sie dies aktivieren, werden diese Markierungen automatisch für die Kapiteltaktung verwendet",
@@ -413,7 +437,7 @@
"LabelTag": "Schlagwort",
"LabelTags": "Schlagwörter",
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTagsNotAccessibleToUser": "Für Benutzer nicht zugängliche Schlagwörter",
"LabelTasks": "Laufende Aufgaben",
"LabelTimeBase": "Basiszeit",
"LabelTimeListened": "Gehörte Zeit",
@@ -464,7 +488,7 @@
"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)",
@@ -474,15 +498,17 @@
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
"MessageCheckingCron": "Überprüfe Cron...",
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
"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.",
@@ -490,6 +516,7 @@
"MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
"MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.",
"MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "Episode herunterladen",
"MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge",
"MessageEmbedFinished": "Einbettung abgeschlossen!",
@@ -644,6 +671,8 @@
"ToastRemoveItemFromCollectionSuccess": "Medium aus der Sammlung gelöscht",
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
"ToastSendEbookToDeviceFailed": "Failed to send ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Aktualisierung der Serien fehlgeschlagen",
"ToastSeriesUpdateSuccess": "Serien aktualisiert",
"ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden",
@@ -653,4 +682,4 @@
"ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
"ToastUserDeleteSuccess": "Benutzer gelöscht"
}
}

View File

@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Remove All",
"ButtonRemoveAllLibraryItems": "Remove All Library Items",
"ButtonRemoveFromContinueListening": "Remove from Continue Listening",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Remove Series from Continue Series",
"ButtonReScan": "Re-Scan",
"ButtonReset": "Reset",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Start M4B Encode",
"ButtonStartMetadataEmbed": "Start Metadata Embed",
"ButtonSubmit": "Submit",
"ButtonTest": "Test",
"ButtonUpload": "Upload",
"ButtonUploadBackup": "Upload Backup",
"ButtonUploadCover": "Upload Cover",
@@ -96,7 +98,11 @@
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodes",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files",
@@ -197,6 +203,7 @@
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
"LabelContinueListening": "Continue Listening",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Continue Series",
"LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL",
@@ -216,7 +223,13 @@
"LabelDownload": "Download",
"LabelDuration": "Duration",
"LabelDurationFound": "Duration found:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Edit",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable",
"LabelEnd": "End",
@@ -239,6 +252,9 @@
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host",
"LabelHour": "Hour",
"LabelIcon": "Icon",
"LabelIncludeInTracklist": "Include in Tracklist",
@@ -324,13 +340,18 @@
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Progress",
"LabelProvider": "Provider",
"LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended",
@@ -347,18 +368,21 @@
"LabelSearchTitle": "Search Title",
"LabelSearchTitleOrASIN": "Search Title or ASIN",
"LabelSeason": "Season",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequence",
"LabelSeries": "Series",
"LabelSeriesName": "Series Name",
"LabelSeriesProgress": "Series Progress",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
"LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Date Format",
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableEReader": "Enable e-reader for all users",
"LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
@@ -474,6 +498,7 @@
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
@@ -483,6 +508,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.",
@@ -490,6 +516,7 @@
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!",
@@ -644,6 +671,8 @@
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSendEbookToDeviceFailed": "Failed to send ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session",

View File

@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Remover Todos",
"ButtonRemoveAllLibraryItems": "Remover Todos los Elementos de la Biblioteca",
"ButtonRemoveFromContinueListening": "Remover de Continuar Escuchando",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Remover Serie de Continuar Series",
"ButtonReScan": "Re-Escanear",
"ButtonReset": "Reiniciar",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Iniciar Codificación M4B",
"ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata",
"ButtonSubmit": "Enviar",
"ButtonTest": "Test",
"ButtonUpload": "Subir",
"ButtonUploadBackup": "Subir Respaldo",
"ButtonUploadCover": "Subir Portada",
@@ -96,7 +98,11 @@
"HeaderCurrentDownloads": "Descargando Actualmente",
"HeaderDetails": "Detalles",
"HeaderDownloadQueue": "Lista de Descarga",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodios",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Elemento",
"HeaderFindChapters": "Buscar Capitulo",
"HeaderIgnoredFiles": "Ignorar Elemento",
@@ -197,6 +203,7 @@
"LabelComplete": "Completo",
"LabelConfirmPassword": "Confirmar Contraseña",
"LabelContinueListening": "Continuar Escuchando",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Continuar Series",
"LabelCover": "Portada",
"LabelCoverImageURL": "URL de Imagen de Portada",
@@ -216,7 +223,13 @@
"LabelDownload": "Descargar",
"LabelDuration": "Duración",
"LabelDurationFound": "Duración Comprobada:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Editar",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"LabelEmbeddedCover": "Portada Integrada",
"LabelEnable": "Habilitar",
"LabelEnd": "Fin",
@@ -239,6 +252,9 @@
"LabelGenre": "Genero",
"LabelGenres": "Géneros",
"LabelHardDeleteFile": "Eliminar Definitivamente",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host",
"LabelHour": "Hora",
"LabelIcon": "Icono",
"LabelIncludeInTracklist": "Incluir en Tracklist",
@@ -324,13 +340,18 @@
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Tipo Podcast",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefijos para Ignorar (no distingue entre mayúsculas y minúsculas.)",
"LabelPreventIndexing": "Evite que su fuente sea indexado por iTunes y Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Progreso",
"LabelProvider": "Proveedor",
"LabelPubDate": "Fecha de Publicación",
"LabelPublisher": "Editor",
"LabelPublishYear": "Año de Publicación",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Agregado Reciente",
"LabelRecentSeries": "Series Recientes",
"LabelRecommended": "Recomendados",
@@ -347,18 +368,21 @@
"LabelSearchTitle": "Buscar Titulo",
"LabelSearchTitleOrASIN": "Buscar Titulo o ASIN",
"LabelSeason": "Temporada",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Secuencia",
"LabelSeries": "Series",
"LabelSeriesName": "Nombre de la Serie",
"LabelSeriesProgress": "Progreso de la Serie",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Diseño Skeumorphic con Estantes de Madera",
"LabelSettingsChromecastSupport": "Soporte para Chromecast",
"LabelSettingsDateFormat": "Formato de Fecha",
"LabelSettingsDisableWatcher": "Deshabilitar Watcher",
"LabelSettingsDisableWatcherForLibrary": "Deshabilitar Watcher de Carpetas para esta biblioteca",
"LabelSettingsDisableWatcherHelp": "Deshabilitar la función automática de agregar/actualizar los elementos, cuando se detecta cambio en los archivos. *Require Reiniciar el Servidor",
"LabelSettingsEnableEReader": "Habilitar e-reader para todos los usuarios",
"LabelSettingsEnableEReaderHelp": "E-reader sigue en proceso, pero use esta configuración para hacerlo disponible a todos los usuarios. (o use las \"Funciones Experimentales\" para habilitarla para solo este usuario)",
"LabelSettingsExperimentalFeatures": "Funciones Experimentales",
"LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo sobre las que esperamos sus comentarios y experiencia. Haga click aquí para abrir una conversación en Github.",
"LabelSettingsFindCovers": "Buscar Portadas",
@@ -474,6 +498,7 @@
"MessageChapterStartIsAfter": "El comienzo del capítulo es después del final de su audiolibro",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Esta seguro que desea eliminar el respaldo {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?",
"MessageConfirmDeleteSession": "Esta seguro que desea eliminar esta session?",
"MessageConfirmForceReScan": "Esta seguro que desea forzar re-escanear?",
@@ -483,6 +508,7 @@
"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.",
@@ -490,6 +516,7 @@
"MessageConfirmRenameTag": "Esta seguro que desea renombrar la etiqueta \"{0}\" a \"{1}\" de todos los elementos?",
"MessageConfirmRenameTagMergeNote": "Nota: Esta etiqueta ya existe por lo que se fusionarán.",
"MessageConfirmRenameTagWarning": "Advertencia! Una etiqueta similar ya existe \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "Descargando Capitulo",
"MessageDragFilesIntoTrackOrder": "Arrastras los archivos en el orden correcto de la pista.",
"MessageEmbedFinished": "Incorporación Terminada!",
@@ -644,6 +671,8 @@
"ToastRemoveItemFromCollectionSuccess": "Elemento eliminado de la colección.",
"ToastRSSFeedCloseFailed": "Error al cerrar fuente RSS",
"ToastRSSFeedCloseSuccess": "Fuente RSS cerrada",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Error al actualizar la serie",
"ToastSeriesUpdateSuccess": "Series actualizada",
"ToastSessionDeleteFailed": "Error al eliminar sesión",

View File

@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Supprimer tout",
"ButtonRemoveAllLibraryItems": "Supprimer tous les articles de la bibliothèque",
"ButtonRemoveFromContinueListening": "Ne plus continuer à écouter",
"ButtonRemoveFromContinueReading": "Ne plus continuer à lire",
"ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série",
"ButtonReScan": "Nouvelle analyse",
"ButtonReset": "Réinitialiser",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Démarrer lencodage M4B",
"ButtonStartMetadataEmbed": "Démarrer les Métadonnées intégrées",
"ButtonSubmit": "Soumettre",
"ButtonTest": "Test",
"ButtonUpload": "Téléverser",
"ButtonUploadBackup": "Téléverser une sauvegarde",
"ButtonUploadCover": "Téléverser une couverture",
@@ -85,7 +87,7 @@
"HeaderAdvanced": "Avancé",
"HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise",
"HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook",
"HeaderAudioTracks": "Pistes zudio",
"HeaderAudioTracks": "Pistes audio",
"HeaderBackups": "Sauvegardes",
"HeaderChangePassword": "Modifier le mot de passe",
"HeaderChapters": "Chapitres",
@@ -93,10 +95,14 @@
"HeaderCollection": "Collection",
"HeaderCollectionItems": "Entrées de la Collection",
"HeaderCover": "Couverture",
"HeaderCurrentDownloads": "File dattente de téléchargement",
"HeaderCurrentDownloads": "Téléchargements en cours",
"HeaderDetails": "Détails",
"HeaderDownloadQueue": "Queue de téléchargement",
"HeaderDownloadQueue": "File d'attente de téléchargements",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "E-mails",
"HeaderEmailSettings": "Configuration des e-mails",
"HeaderEpisodes": "Épisodes",
"HeaderEReaderDevices": "Lecteurs d'e-books",
"HeaderFiles": "Fichiers",
"HeaderFindChapters": "Trouver les chapitres",
"HeaderIgnoredFiles": "Fichiers Ignorés",
@@ -197,6 +203,7 @@
"LabelComplete": "Complet",
"LabelConfirmPassword": "Confirmer le mot de passe",
"LabelContinueListening": "Continuer la lecture",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Continuer la série",
"LabelCover": "Couverture",
"LabelCoverImageURL": "URL vers limage de couverture",
@@ -216,7 +223,13 @@
"LabelDownload": "Téléchargement",
"LabelDuration": "Durée",
"LabelDurationFound": "Durée trouvée :",
"LabelEbook": "E-book",
"LabelEbooks": "Ebooks",
"LabelEdit": "Modifier",
"LabelEmail": "E-mail",
"LabelEmailSettingsFromAddress": "Expéditeur",
"LabelEmailSettingsSecure": "Sécurisé",
"LabelEmailSettingsSecureHelp": "Si coché, la connexion utilisera TLS lors de la connexion au serveur. Sinon TLS est utilisé si le serveur prend en charge l'extension STARTTLS. Dans la plupart des cas, cochez si vous vous connectez au port 465. Décochez pour le port 587 ou 25. (source: nodemailer.com/smtp/#authentication)",
"LabelEmbeddedCover": "Couverture du livre intégrée",
"LabelEnable": "Activer",
"LabelEnd": "Fin",
@@ -225,9 +238,9 @@
"LabelEpisodeType": "Type de lépisode",
"LabelExample": "Exemple",
"LabelExplicit": "Restriction",
"LabelFeedURL": "URL deu flux",
"LabelFeedURL": "URL du flux",
"LabelFile": "Fichier",
"LabelFileBirthtime": "Creation du fichier",
"LabelFileBirthtime": "Création du fichier",
"LabelFileModified": "Modification du fichier",
"LabelFilename": "Nom de fichier",
"LabelFilterByUser": "Filtrer par lutilisateur",
@@ -239,12 +252,15 @@
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Suppression du fichier",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Hôte",
"LabelHour": "Heure",
"LabelIcon": "Icone",
"LabelIncludeInTracklist": "Inclure dans la liste des pistes",
"LabelIncomplete": "Incomplet",
"LabelInProgress": "En cours",
"LabelInterval": "Interval",
"LabelInterval": "Intervalle",
"LabelIntervalCustomDailyWeekly": "Journalier / Hebdomadaire personnalisé",
"LabelIntervalEvery12Hours": "Toutes les 12 heures",
"LabelIntervalEvery15Minutes": "Toutes les 15 minutes",
@@ -254,7 +270,7 @@
"LabelIntervalEveryDay": "Tous les jours",
"LabelIntervalEveryHour": "Toutes les heures",
"LabelInvalidParts": "Parties invalides",
"LabelInvert": "Invert",
"LabelInvert": "Inverser",
"LabelItem": "Article",
"LabelLanguage": "Langue",
"LabelLanguageDefaultServer": "Langue par défaut",
@@ -295,14 +311,14 @@
"LabelNextScheduledRun": "Prochain lancement prévu",
"LabelNotes": "Notes",
"LabelNotFinished": "Non terminé(e)",
"LabelNotificationAppriseURL": "URL(s) dapprise",
"LabelNotificationAppriseURL": "URL(s) dApprise",
"LabelNotificationAvailableVariables": "Variables disponibles",
"LabelNotificationBodyTemplate": "Modèle de Message",
"LabelNotificationEvent": "Evènement de Notification",
"LabelNotificationsMaxFailedAttempts": "Nombres de tentatives denvoi",
"LabelNotificationsMaxFailedAttemptsHelp": "La notification est abandonnée une fois ce seuil atteint",
"LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente",
"LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Le notification seront ignorées si la file dattente est à son maximum. Cela empêche un flot trop important.",
"LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Les notifications seront ignorées si la file dattente est à son maximum. Cela empêche un flot trop important.",
"LabelNotificationTitleTemplate": "Modèle de Titre",
"LabelNotStarted": "Non Démarré(e)",
"LabelNumberOfBooks": "Nombre de Livres",
@@ -324,20 +340,25 @@
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Type de Podcast",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
"LabelPreventIndexing": "Empêcher lindexation de votre flux par les bases de donénes iTunes et Google podcast",
"LabelPreventIndexing": "Empêcher lindexation de votre flux par les bases de données iTunes et Google podcast",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Progression",
"LabelProvider": "Fournisseur",
"LabelPubDate": "Date de publication",
"LabelPublisher": "Éditeur",
"LabelPublishYear": "Année dédition",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Derniers ajouts",
"LabelRecentSeries": "Séries récentes",
"LabelRecommended": "Recommandé",
"LabelRegion": "Région",
"LabelReleaseDate": "Date de parution",
"LabelRemoveCover": "Supprimer la couverture",
"LabelRSSFeedCustomOwnerEmail": "Email propriétaire personnalisé",
"LabelRSSFeedCustomOwnerEmail": "E-mail propriétaire personnalisé",
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
"LabelRSSFeedOpen": "Flux RSS ouvert",
"LabelRSSFeedPreventIndexing": "Empêcher lindexation",
@@ -347,22 +368,25 @@
"LabelSearchTitle": "Titre de recherche",
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
"LabelSeason": "Saison",
"LabelSendEbookToDevice": "Envoyer l'e-book à...",
"LabelSequence": "Séquence",
"LabelSeries": "Séries",
"LabelSeriesName": "Nom de la série",
"LabelSeriesProgress": "Progression de séries",
"LabelSettingsBookshelfViewHelp": "Interface Skeuomorphic avec une étagère en bois",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois",
"LabelSettingsChromecastSupport": "Support du Chromecast",
"LabelSettingsDateFormat": "Format de date",
"LabelSettingsDisableWatcher": "Désactiver la surveillance",
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque",
"LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque les fichiers changent. *Nécessite un redémarrage*",
"LabelSettingsEnableEReader": "Active E-reader pour tous les utilisateurs",
"LabelSettingsEnableEReaderHelp": "E-reader est toujours en cours de développement, mais ce paramètre lactive pour tous les utilisateurs (ou utiliser linterrupteur « Fonctionnalités expérimentales » pour lactiver seulement pour vous)",
"LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales",
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquels nous attendons votre retour et expérience. Cliquer pour ouvrir la discussion Github.",
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion Github.",
"LabelSettingsFindCovers": "Chercher des couvertures de livre",
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, lanalyser tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps danalyse.",
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, lanalyseur tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps danalyse.",
"LabelSettingsHomePageBookshelfView": "La page daccueil utilise la vue étagère",
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
"LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres",
@@ -466,7 +490,7 @@
"MessageBookshelfNoCollections": "Vous navez pas encore de collections",
"MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0}: {1} »",
"MessageBookshelfNoRSSFeeds": "Aucun flux RSS nest ouvert",
"MessageBookshelfNoSeries": "Vous navez aucune séries",
"MessageBookshelfNoSeries": "Vous navez aucune série",
"MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio",
"MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0",
"MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre",
@@ -474,6 +498,7 @@
"MessageChapterStartIsAfter": "Le Chapitre Début est situé au début de votre Livre Audio",
"MessageCheckingCron": "Vérification du cron…",
"MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la Sauvegarde de {0} ?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?",
"MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?",
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?",
@@ -483,13 +508,15 @@
"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": "Êtes-vous sûr de vouloir supprimer le narrateur \"{0}\"?",
"MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?",
"MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » vers « {1} » pour tous les articles ?",
"MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » en « {1} » pour tous les articles ?",
"MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.",
"MessageConfirmRenameGenreWarning": "Attention ! Un genre similaire avec une casse différente existe déjà « {0} ».",
"MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer létiquette « {0} » vers « {1} » pour tous les articles ?",
"MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer létiquette « {0} » en « {1} » pour tous les articles ?",
"MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.",
"MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».",
"MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer l'ebook {0} \"{1}\" à l'appareil \"{2}\"?",
"MessageDownloadingEpisode": "Téléchargement de lépisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans lordre correct",
"MessageEmbedFinished": "Intégration Terminée !",
@@ -577,7 +604,7 @@
"NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux en HTTPS.",
"NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
"NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.",
"NoteUploaderOnlyAudioFiles": "Si vous téléverser uniquement des fichiers audio, chaque fichier audio sera traité comme un livre audio distinct.",
"NoteUploaderOnlyAudioFiles": "Si vous téléversez uniquement des fichiers audio, chaque fichier audio sera traité comme un livre audio distinct.",
"NoteUploaderUnsupportedFiles": "Les fichiers non pris en charge sont ignorés. Lorsque vous choisissez ou déposez un dossier, les autres fichiers qui ne sont pas dans un dossier délément sont ignorés.",
"PlaceholderNewCollection": "Nom de la nouvelle collection",
"PlaceholderNewFolderPath": "Nouveau chemin de dossier",
@@ -644,6 +671,8 @@
"ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection",
"ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS",
"ToastRSSFeedCloseSuccess": "Flux RSS fermé",
"ToastSendEbookToDeviceFailed": "Échec de l'envoi de l'e-book à l'appareil",
"ToastSendEbookToDeviceSuccess": "E-book envoyé à l'appareil \"{0}\"",
"ToastSeriesUpdateFailed": "Échec de la mise à jour de la série",
"ToastSeriesUpdateSuccess": "Mise à jour de la série réussie",
"ToastSessionDeleteFailed": "Échec de la suppression de session",

View File

@@ -55,6 +55,7 @@
"ButtonRemoveAll": "બધું કાઢી નાખો",
"ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
"ButtonRemoveFromContinueListening": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો",
"ButtonReScan": "ફરીથી સ્કેન કરો",
"ButtonReset": "રીસેટ કરો",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
"ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
"ButtonSubmit": "સબમિટ કરો",
"ButtonTest": "Test",
"ButtonUpload": "અપલોડ કરો",
"ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
"ButtonUploadCover": "કવર અપલોડ કરો",
@@ -96,7 +98,11 @@
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodes",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files",
@@ -197,6 +203,7 @@
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
"LabelContinueListening": "Continue Listening",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Continue Series",
"LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL",
@@ -216,7 +223,13 @@
"LabelDownload": "Download",
"LabelDuration": "Duration",
"LabelDurationFound": "Duration found:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Edit",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable",
"LabelEnd": "End",
@@ -239,6 +252,9 @@
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host",
"LabelHour": "Hour",
"LabelIcon": "Icon",
"LabelIncludeInTracklist": "Include in Tracklist",
@@ -324,13 +340,18 @@
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Progress",
"LabelProvider": "Provider",
"LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended",
@@ -347,18 +368,21 @@
"LabelSearchTitle": "Search Title",
"LabelSearchTitleOrASIN": "Search Title or ASIN",
"LabelSeason": "Season",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequence",
"LabelSeries": "Series",
"LabelSeriesName": "Series Name",
"LabelSeriesProgress": "Series Progress",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
"LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Date Format",
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableEReader": "Enable e-reader for all users",
"LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
@@ -474,6 +498,7 @@
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
@@ -483,6 +508,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.",
@@ -490,6 +516,7 @@
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!",
@@ -644,6 +671,8 @@
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session",

View File

@@ -55,6 +55,7 @@
"ButtonRemoveAll": "सभी हटाएं",
"ButtonRemoveAllLibraryItems": "पुस्तकालय की सभी आइटम हटाएं",
"ButtonRemoveFromContinueListening": "सुनना जारी रखें से हटाएं",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "इस सीरीज को कंटिन्यू सीरीज से हटा दें",
"ButtonReScan": "पुन: स्कैन करें",
"ButtonReset": "रीसेट करें",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "M4B एन्कोडिंग शुरू करें",
"ButtonStartMetadataEmbed": "मेटाडेटा एम्बेडिंग शुरू करें",
"ButtonSubmit": "जमा करें",
"ButtonTest": "Test",
"ButtonUpload": "अपलोड करें",
"ButtonUploadBackup": "बैकअप अपलोड करें",
"ButtonUploadCover": "कवर अपलोड करें",
@@ -96,7 +98,11 @@
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodes",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files",
@@ -197,6 +203,7 @@
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
"LabelContinueListening": "Continue Listening",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Continue Series",
"LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL",
@@ -216,7 +223,13 @@
"LabelDownload": "Download",
"LabelDuration": "Duration",
"LabelDurationFound": "Duration found:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Edit",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable",
"LabelEnd": "End",
@@ -239,6 +252,9 @@
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host",
"LabelHour": "Hour",
"LabelIcon": "Icon",
"LabelIncludeInTracklist": "Include in Tracklist",
@@ -324,13 +340,18 @@
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Progress",
"LabelProvider": "Provider",
"LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended",
@@ -347,18 +368,21 @@
"LabelSearchTitle": "Search Title",
"LabelSearchTitleOrASIN": "Search Title or ASIN",
"LabelSeason": "Season",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequence",
"LabelSeries": "Series",
"LabelSeriesName": "Series Name",
"LabelSeriesProgress": "Series Progress",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
"LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Date Format",
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableEReader": "Enable e-reader for all users",
"LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
@@ -474,6 +498,7 @@
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
@@ -483,6 +508,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.",
@@ -490,6 +516,7 @@
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!",
@@ -644,6 +671,8 @@
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session",

View File

@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Ukloni sve",
"ButtonRemoveAllLibraryItems": "Ukloni sve stvari iz biblioteke",
"ButtonRemoveFromContinueListening": "Ukloni iz Nastavi slušati",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Ukloni seriju iz Nastavi seriju",
"ButtonReScan": "Skeniraj ponovno",
"ButtonReset": "Poništi",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Pokreni M4B kodiranje",
"ButtonStartMetadataEmbed": "Pokreni ugradnju metapodataka",
"ButtonSubmit": "Submit",
"ButtonTest": "Test",
"ButtonUpload": "Upload",
"ButtonUploadBackup": "Upload backup",
"ButtonUploadCover": "Upload Cover",
@@ -96,7 +98,11 @@
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Detalji",
"HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Epizode",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Datoteke",
"HeaderFindChapters": "Pronađi poglavlja",
"HeaderIgnoredFiles": "Zanemarene datoteke",
@@ -197,6 +203,7 @@
"LabelComplete": "Complete",
"LabelConfirmPassword": "Potvrdi lozinku",
"LabelContinueListening": "Nastavi slušanje",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Nastavi seriju",
"LabelCover": "Cover",
"LabelCoverImageURL": "URL od covera",
@@ -216,7 +223,13 @@
"LabelDownload": "Preuzmi",
"LabelDuration": "Trajanje",
"LabelDurationFound": "Pronađeno trajanje:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Uredi",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Uključi",
"LabelEnd": "Kraj",
@@ -239,6 +252,9 @@
"LabelGenre": "Genre",
"LabelGenres": "Žanrovi",
"LabelHardDeleteFile": "Obriši datoteku zauvijek",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host",
"LabelHour": "Sat",
"LabelIcon": "Ikona",
"LabelIncludeInTracklist": "Dodaj u Tracklist",
@@ -324,13 +340,18 @@
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefiksi za ignorirati (mala i velika slova nisu bitna)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Napredak",
"LabelProvider": "Dobavljač",
"LabelPubDate": "Datam izdavanja",
"LabelPublisher": "Izdavač",
"LabelPublishYear": "Godina izdavanja",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Nedavno dodano",
"LabelRecentSeries": "Nedavne serije",
"LabelRecommended": "Recommended",
@@ -347,18 +368,21 @@
"LabelSearchTitle": "Traži naslov",
"LabelSearchTitleOrASIN": "Traži naslov ili ASIN",
"LabelSeason": "Sezona",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sekvenca",
"LabelSeries": "Serije",
"LabelSeriesName": "Ime serije",
"LabelSeriesProgress": "Series Progress",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorfski (što god to bilo) dizajn sa drvenim policama",
"LabelSettingsChromecastSupport": "Chromecast podrška",
"LabelSettingsDateFormat": "Format datuma",
"LabelSettingsDisableWatcher": "Isključi Watchera",
"LabelSettingsDisableWatcherForLibrary": "Isključi folder watchera za biblioteku",
"LabelSettingsDisableWatcherHelp": "Isključi automatsko dodavanje/aktualiziranje stavci ako su promjene prepoznate. *Potreban restart servera",
"LabelSettingsEnableEReader": "Uključi e-readere za sve korisnike",
"LabelSettingsEnableEReaderHelp": "E-reader je i dalje rad u tijeku, ali s ovom postavkom ga možete uključiti za sve korisnike (ili koristi \"Eksperimentalni features\" toggle da bi uključio postavku samo za sebe)",
"LabelSettingsExperimentalFeatures": "Eksperimentalni features",
"LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.",
"LabelSettingsFindCovers": "Pronađi covers",
@@ -474,6 +498,7 @@
"MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.",
"MessageCheckingCron": "Provjeravam cron...",
"MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?",
"MessageConfirmDeleteSession": "Jeste li sigurni da želite obrisati ovu sesiju?",
"MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?",
@@ -483,6 +508,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.",
@@ -490,6 +516,7 @@
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "Preuzimam epizodu",
"MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.",
"MessageEmbedFinished": "Embed završen!",
@@ -644,6 +671,8 @@
"ToastRemoveItemFromCollectionSuccess": "Stavka uklonjena iz kolekcije",
"ToastRSSFeedCloseFailed": "Neuspješno zatvaranje RSS Feeda",
"ToastRSSFeedCloseSuccess": "RSS Feed zatvoren",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Neuspješno brisanje serije",

View File

@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Rimuovi Tutto",
"ButtonRemoveAllLibraryItems": "Rimuovi tutto il contenuto della libreria",
"ButtonRemoveFromContinueListening": "Rimuovi per proseguire l'ascolto",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla",
"ButtonReScan": "Ri-scansiona",
"ButtonReset": "Reset",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Inizia L'Encoda del M4B",
"ButtonStartMetadataEmbed": "Inizia Incorporo Metadata",
"ButtonSubmit": "Invia",
"ButtonTest": "Test",
"ButtonUpload": "Carica",
"ButtonUploadBackup": "Carica Backup",
"ButtonUploadCover": "Carica Cover",
@@ -96,7 +98,11 @@
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Dettagli",
"HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodi",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "File",
"HeaderFindChapters": "Trova Capitoli",
"HeaderIgnoredFiles": "File Ignorati",
@@ -197,6 +203,7 @@
"LabelComplete": "Completo",
"LabelConfirmPassword": "Conferma Password",
"LabelContinueListening": "Continua ad Ascoltare",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Continua Serie",
"LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL",
@@ -216,7 +223,13 @@
"LabelDownload": "Download",
"LabelDuration": "Durata",
"LabelDurationFound": "Durata Trovata:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Modifica",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Abilita",
"LabelEnd": "Fine",
@@ -239,6 +252,9 @@
"LabelGenre": "Genere",
"LabelGenres": "Generi",
"LabelHardDeleteFile": "Elimina Definitivamente",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host",
"LabelHour": "Ora",
"LabelIcon": "Icona",
"LabelIncludeInTracklist": "Includi nella Tracklist",
@@ -324,13 +340,18 @@
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Timo di Podcast",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)",
"LabelPreventIndexing": "Impedisci che il tuo feed venga indicizzato da iTunes e dalle directory dei podcast di Google",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Cominciati",
"LabelProvider": "Provider",
"LabelPubDate": "Data Pubblicazione",
"LabelPublisher": "Editore",
"LabelPublishYear": "Anno Pubblicazione",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Aggiunti Recentemente",
"LabelRecentSeries": "Serie Recenti",
"LabelRecommended": "Raccomandati",
@@ -347,18 +368,21 @@
"LabelSearchTitle": "Cerca Titolo",
"LabelSearchTitleOrASIN": "Cerca titolo o ASIN",
"LabelSeason": "Stagione",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequenza",
"LabelSeries": "Serie",
"LabelSeriesName": "Nome Serie",
"LabelSeriesProgress": "Cominciato",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Design con scaffali in legno",
"LabelSettingsChromecastSupport": "Supporto a Chromecast",
"LabelSettingsDateFormat": "Formato Data",
"LabelSettingsDisableWatcher": "Disattiva Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie",
"LabelSettingsDisableWatcherHelp": "Disattiva il controllo automatico libri nelle cartelle delle librerie. *Richiede il Riavvio del Server",
"LabelSettingsEnableEReader": "Abilita e-reader for tutti gli Utenti",
"LabelSettingsEnableEReaderHelp": "L'e-reader è ancora un work in progress, ma usa questa impostazione per abilitarlo a tutti i tuoi utenti (o usa lo switch \"Funzionalità sperimentali\" solo per te)",
"LabelSettingsExperimentalFeatures": "Opzioni Sperimentali",
"LabelSettingsExperimentalFeaturesHelp": "Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.",
"LabelSettingsFindCovers": "Trova covers",
@@ -474,6 +498,7 @@
"MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro",
"MessageCheckingCron": "Controllo cron...",
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
@@ -483,6 +508,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.",
@@ -490,6 +516,7 @@
"MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?",
"MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.",
"MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "Download episodio in corso",
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
"MessageEmbedFinished": "Incorporamento finito!",
@@ -644,6 +671,8 @@
"ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta",
"ToastRSSFeedCloseFailed": "Errore chiusura RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed chiuso",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Aggiornaento Serie Fallito",
"ToastSeriesUpdateSuccess": "Serie Aggornate",
"ToastSessionDeleteFailed": "Errore eliminazione sessione",

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

@@ -0,0 +1,685 @@
{
"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",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"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",
"ButtonTest": "Test",
"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": "Audiotracks",
"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",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Afleveringen",
"HeaderEReaderDevices": "E-Reader Devices",
"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": "Account bijwerken",
"HeaderUpdateAuthor": "Auteur bijwerken",
"HeaderUpdateDetails": "Details bijwerken",
"HeaderUpdateLibrary": "Bibliotheek bijwerken",
"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": "Series inklappen",
"LabelCollections": "Collecties",
"LabelComplete": "Compleet",
"LabelConfirmPassword": "Bevestig wachtwoord",
"LabelContinueListening": "Verder luisteren",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Ga verder met serie",
"LabelCover": "Cover",
"LabelCoverImageURL": "Coverafbeelding URL",
"LabelCreatedAt": "Gecreëerd op",
"LabelCronExpression": "Cron-uitdrukking",
"LabelCurrent": "Huidig",
"LabelCurrently": "Op dit moment:",
"LabelCustomCronExpression": "Aangepaste 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:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Wijzig",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"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",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host",
"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 bijgewerkte 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": "Mediatype",
"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 bijwerken",
"LabelPermissionsUpload": "Kan uploaden",
"LabelPhotoPathURL": "Foto pad/URL",
"LabelPlaylists": "Afspeellijsten",
"LabelPlayMethod": "Afspeelwijze",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcasttype",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Te negeren voorzetsels (ongeacht hoofdlettergebruik)",
"LabelPreventIndexing": "Voorkom indexering van je feed door iTunes- en Google podcastmappen",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Voortgang",
"LabelProvider": "Bron",
"LabelPubDate": "Publicatiedatum",
"LabelPublisher": "Uitgever",
"LabelPublishYear": "Jaar van uitgave",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"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",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequentie",
"LabelSeries": "Serie",
"LabelSeriesName": "Naam serie",
"LabelSeriesProgress": "Voortgang serie",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
"LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Datum format",
"LabelSettingsDisableWatcher": "Watcher uitschakelen",
"LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen",
"LabelSettingsDisableWatcherHelp": "Schakelt het automatisch toevoegen/bijwerken van onderdelen wanneer bestandswijzigingen gedetecteerd zijn uit. *Vereist herstart server",
"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": "Boekenplank-view voor homepagina",
"LabelSettingsLibraryBookshelfView": "Boekenplank-view voor bibliotheek",
"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": "Splitst M4B in MP3's",
"LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplitst 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": "Cover bijwerken",
"LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden",
"LabelUpdatedAt": "Bijgewerkt op",
"LabelUpdateDetails": "Details bijwerken",
"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": "Je audioboekduur",
"LabelYourBookmarks": "Je boekwijzers",
"LabelYourPlaylists": "Je afspeellijsten",
"LabelYourProgress": "Je 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 voor 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?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?",
"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 hoofdlettergebruik 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 hoofdlettergebruik bestaat al: \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"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": "zal 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 bijgewerkt",
"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 bestaande 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 audiotracks",
"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 zoekresultaten voor \"{0}\"",
"MessageNoSeries": "Geen series",
"MessageNoTags": "Geen tags",
"MessageNoTasksRunning": "Geen lopende taken",
"MessageNotYetImplemented": "Nog niet geimplementeerd",
"MessageNoUpdateNecessary": "Geen bijwerking noodzakelijk",
"MessageNoUpdatesWereNecessary": "Geen bijwerkingen 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 bijwerkingen 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": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel",
"MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {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 je audioboek is langer dan de gevonden duur",
"MessageYourAudiobookDurationIsShorter": "Duur van je 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": "Bijwerken account mislukt",
"ToastAccountUpdateSuccess": "Account bijgewerkt",
"ToastAuthorImageRemoveFailed": "Afbeelding verwijderen mislukt",
"ToastAuthorImageRemoveSuccess": "Afbeelding auteur verwijderd",
"ToastAuthorUpdateFailed": "Bijwerken auteur mislukt",
"ToastAuthorUpdateMerged": "Auteur samengevoegd",
"ToastAuthorUpdateSuccess": "Auteur bijgewerkt",
"ToastAuthorUpdateSuccessNoImageFound": "Auteur bijgewerkt (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-bijwerking mislukt",
"ToastBatchUpdateSuccess": "Bulk-bijwerking gelukt",
"ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt",
"ToastBookmarkCreateSuccess": "boekwijzer toegevoegd",
"ToastBookmarkRemoveFailed": "Verwijderen boekwijzer mislukt",
"ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd",
"ToastBookmarkUpdateFailed": "Bijwerken boekwijzer mislukt",
"ToastBookmarkUpdateSuccess": "Boekwijzer bijgewerkt",
"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": "Bijwerken collectie mislukt",
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
"ToastItemCoverUpdateFailed": "Bijwerken cover onderdeel mislukt",
"ToastItemCoverUpdateSuccess": "Cover onderdeel bijgewerkt",
"ToastItemDetailsUpdateFailed": "Bijwerken details onderdeel mislukt",
"ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt",
"ToastItemDetailsUpdateUnneeded": "Geen bijwerking 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": "Bijwerken bibliotheek mislukt",
"ToastLibraryUpdateSuccess": "Bibliotheek \"{0}\" bijgewerkt",
"ToastPlaylistCreateFailed": "Aanmaken afspeellijst mislukt",
"ToastPlaylistCreateSuccess": "Afspeellijst aangemaakt",
"ToastPlaylistRemoveFailed": "Verwijderen afspeellijst mislukt",
"ToastPlaylistRemoveSuccess": "Afspeellijst verwijderd",
"ToastPlaylistUpdateFailed": "Afspeellijst bijwerken mislukt",
"ToastPlaylistUpdateSuccess": "Afspeellijst bijgewerkt",
"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",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Bijwerken serie mislukt",
"ToastSeriesUpdateSuccess": "Bijwerken serie 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

@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Usuń wszystko",
"ButtonRemoveAllLibraryItems": "Usuń wszystkie elementy z biblioteki",
"ButtonRemoveFromContinueListening": "Usuń z listy odtwarzania",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Usuń serię z listy odtwarzania",
"ButtonReScan": "Ponowne skanowanie",
"ButtonReset": "Resetowanie",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Eksportuj jako plik M4B",
"ButtonStartMetadataEmbed": "Osadź metadane",
"ButtonSubmit": "Zaloguj",
"ButtonTest": "Test",
"ButtonUpload": "Wgraj",
"ButtonUploadBackup": "Wgraj kopię zapasową",
"ButtonUploadCover": "Wgraj okładkę",
@@ -96,7 +98,11 @@
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Szczegóły",
"HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Rozdziały",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Pliki",
"HeaderFindChapters": "Wyszukaj rozdziały",
"HeaderIgnoredFiles": "Zignoruj pliki",
@@ -197,6 +203,7 @@
"LabelComplete": "Ukończone",
"LabelConfirmPassword": "Potwierdź hasło",
"LabelContinueListening": "Kontynuuj odtwarzanie",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Kontynuuj serię",
"LabelCover": "Okładka",
"LabelCoverImageURL": "URL okładki",
@@ -216,7 +223,13 @@
"LabelDownload": "Pobierz",
"LabelDuration": "Czas trwania",
"LabelDurationFound": "Znaleziona długość:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Edytuj",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Włącz",
"LabelEnd": "Zakończ",
@@ -239,6 +252,9 @@
"LabelGenre": "Gatunek",
"LabelGenres": "Gatunki",
"LabelHardDeleteFile": "Usuń trwale plik",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host",
"LabelHour": "Godzina",
"LabelIcon": "Ikona",
"LabelIncludeInTracklist": "Dołącz do listy odtwarzania",
@@ -324,13 +340,18 @@
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasty",
"LabelPodcastType": "Podcast Type",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Postęp",
"LabelProvider": "Dostawca",
"LabelPubDate": "Data publikacji",
"LabelPublisher": "Wydawca",
"LabelPublishYear": "Rok publikacji",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Niedawno dodany",
"LabelRecentSeries": "Ostatnie serie",
"LabelRecommended": "Recommended",
@@ -347,18 +368,21 @@
"LabelSearchTitle": "Wyszukaj tytuł",
"LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN",
"LabelSeason": "Sezon",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Kolejność",
"LabelSeries": "Serie",
"LabelSeriesName": "Nazwy serii",
"LabelSeriesProgress": "Postęp w serii",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Widok półki z ksiązkami",
"LabelSettingsChromecastSupport": "Wsparcie Chromecast",
"LabelSettingsDateFormat": "Format daty",
"LabelSettingsDisableWatcher": "Wyłącz monitorowanie",
"LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki",
"LabelSettingsDisableWatcherHelp": "Wyłącz automatyczne dodawanie/aktualizowanie elementów po wykryciu zmian w plikach. *Wymaga restartu serwera",
"LabelSettingsEnableEReader": "Włącz e-czytnika dla wszystkich użytkowników",
"LabelSettingsEnableEReaderHelp": "E-czytnik jest wciąż w fazie rozwoju, ale użyj tego ustawienia, aby udostępnić go wszystkim użytkownikom (lub użyj przełącznika \"Funkcje eksperymentalne\" aby włączyć funkcję tylko dla Ciebie)",
"LabelSettingsExperimentalFeatures": "Funkcje eksperymentalne",
"LabelSettingsExperimentalFeaturesHelp": "Funkcje w trakcie rozwoju, które mogą zyskanć na Twojej opinii i pomocy w testowaniu. Kliknij, aby otworzyć dyskusję na githubie.",
"LabelSettingsFindCovers": "Szukanie okładek",
@@ -474,6 +498,7 @@
"MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka",
"MessageCheckingCron": "Sprawdzanie cron...",
"MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",
"MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?",
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
@@ -483,6 +508,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.",
@@ -490,6 +516,7 @@
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "Pobieranie odcinka",
"MessageDragFilesIntoTrackOrder": "przeciągnij pliki aby ustawić właściwą kolejność utworów",
"MessageEmbedFinished": "Osadzanie zakończone!",
@@ -644,6 +671,8 @@
"ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji",
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
"ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Nie udało się usunąć sesji",

View File

@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Удалить всё",
"ButtonRemoveAllLibraryItems": "Удалить все элементы библиотеки",
"ButtonRemoveFromContinueListening": "Удалить из Продолжить слушать",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию",
"ButtonReScan": "Пересканировать",
"ButtonReset": "Сбросить",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Начать кодирование M4B",
"ButtonStartMetadataEmbed": "Начать встраивание метаданных",
"ButtonSubmit": "Применить",
"ButtonTest": "Test",
"ButtonUpload": "Загрузить",
"ButtonUploadBackup": "Загрузить бэкап",
"ButtonUploadCover": "Загрузить обложку",
@@ -96,7 +98,11 @@
"HeaderCurrentDownloads": "Текущие закачки",
"HeaderDetails": "Подробности",
"HeaderDownloadQueue": "Очередь скачивания",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Эпизоды",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Файлы",
"HeaderFindChapters": "Найти главы",
"HeaderIgnoredFiles": "Игнорируемые Файлы",
@@ -197,6 +203,7 @@
"LabelComplete": "Завершить",
"LabelConfirmPassword": "Подтвердить пароль",
"LabelContinueListening": "Продолжить слушать",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Продолжить серию",
"LabelCover": "Обложка",
"LabelCoverImageURL": "URL изображения обложки",
@@ -216,7 +223,13 @@
"LabelDownload": "Скачать",
"LabelDuration": "Длина",
"LabelDurationFound": "Найденная длина:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Редактировать",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Включить",
"LabelEnd": "Конец",
@@ -239,6 +252,9 @@
"LabelGenre": "Жанр",
"LabelGenres": "Жанры",
"LabelHardDeleteFile": "Жесткое удаление файла",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host",
"LabelHour": "Часы",
"LabelIcon": "Иконка",
"LabelIncludeInTracklist": "Включать в список воспроизведения",
@@ -324,13 +340,18 @@
"LabelPodcast": "Подкаст",
"LabelPodcasts": "Подкасты",
"LabelPodcastType": "Тип подкаста",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Игнорируемые префиксы (без учета регистра)",
"LabelPreventIndexing": "Запретить индексацию фида каталогами подкастов iTunes и Google",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "Прогресс",
"LabelProvider": "Провайдер",
"LabelPubDate": "Дата публикации",
"LabelPublisher": "Издатель",
"LabelPublishYear": "Год публикации",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "Недавно добавленные",
"LabelRecentSeries": "Последние серии",
"LabelRecommended": "Рекомендованное",
@@ -347,18 +368,21 @@
"LabelSearchTitle": "Поиск по названию",
"LabelSearchTitleOrASIN": "Поиск по названию или ASIN",
"LabelSeason": "Сезон",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Последовательность",
"LabelSeries": "Серия",
"LabelSeriesName": "Имя серии",
"LabelSeriesProgress": "Прогресс серии",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками",
"LabelSettingsChromecastSupport": "Поддержка Chromecast",
"LabelSettingsDateFormat": "Формат даты",
"LabelSettingsDisableWatcher": "Отключить отслеживание",
"LabelSettingsDisableWatcherForLibrary": "Отключить отслеживание для библиотеки",
"LabelSettingsDisableWatcherHelp": "Отключает автоматическое добавление/обновление элементов, когда обнаружено изменение файлов. *Требуется перезапуск сервера",
"LabelSettingsEnableEReader": "Включить e-reader для всех пользователей",
"LabelSettingsEnableEReaderHelp": "E-reader все еще находится в стадии разработки, используйте эту настройку, чтобы открыть его для всех ваших пользователей (Только для Вас используйте переключатель \"Экспериментальные Функции\")",
"LabelSettingsExperimentalFeatures": "Экспериментальные функции",
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
"LabelSettingsFindCovers": "Найти обложки",
@@ -474,6 +498,7 @@
"MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги",
"MessageCheckingCron": "Проверка cron...",
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
@@ -483,6 +508,7 @@
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?",
"MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?",
"MessageConfirmRenameGenreMergeNote": "Примечание: Этот жанр уже существует, поэтому они будут объединены.",
@@ -490,6 +516,7 @@
"MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?",
"MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.",
"MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "Эпизод скачивается",
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
"MessageEmbedFinished": "Встраивание завершено!",
@@ -644,6 +671,8 @@
"ToastRemoveItemFromCollectionSuccess": "Элемент удален из коллекции",
"ToastRSSFeedCloseFailed": "Не удалось закрыть RSS-канал",
"ToastRSSFeedCloseSuccess": "RSS-канал закрыт",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Не удалось обновить серию",
"ToastSeriesUpdateSuccess": "Успешное обновление серии",
"ToastSessionDeleteFailed": "Не удалось удалить сеанс",

View File

@@ -55,6 +55,7 @@
"ButtonRemoveAll": "移除所有",
"ButtonRemoveAllLibraryItems": "移除所有媒体库项目",
"ButtonRemoveFromContinueListening": "从继续收听中删除",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除",
"ButtonReScan": "重新扫描",
"ButtonReset": "重置",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "开始 M4B 编码",
"ButtonStartMetadataEmbed": "开始嵌入元数据",
"ButtonSubmit": "提交",
"ButtonTest": "Test",
"ButtonUpload": "上传",
"ButtonUploadBackup": "上传备份",
"ButtonUploadCover": "上传封面",
@@ -96,7 +98,11 @@
"HeaderCurrentDownloads": "当前下载",
"HeaderDetails": "详情",
"HeaderDownloadQueue": "下载队列",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "剧集",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "文件",
"HeaderFindChapters": "查找章节",
"HeaderIgnoredFiles": "忽略的文件",
@@ -155,13 +161,13 @@
"HeaderUpdateLibrary": "更新媒体库",
"HeaderUsers": "用户",
"HeaderYourStats": "你的统计数据",
"LabelAbridged": "Abridged",
"LabelAbridged": "概要",
"LabelAccountType": "帐户类型",
"LabelAccountTypeAdmin": "管理员",
"LabelAccountTypeGuest": "来宾",
"LabelAccountTypeUser": "用户",
"LabelActivity": "活动",
"LabelAdded": "Added",
"LabelAdded": "添加",
"LabelAddedAt": "添加于",
"LabelAddToCollection": "添加到收藏",
"LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏",
@@ -183,20 +189,21 @@
"LabelBackupsMaxBackupSizeHelp": "为了防止错误配置, 如果备份超过配置的大小, 备份将失败.",
"LabelBackupsNumberToKeep": "要保留的备份个数",
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
"LabelBitrate": "Bitrate",
"LabelBitrate": "比特率",
"LabelBooks": "图书",
"LabelChangePassword": "修改密码",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChannels": "声道",
"LabelChapters": "章节",
"LabelChaptersFound": "找到的章节",
"LabelChapterTitle": "章节标题",
"LabelClosePlayer": "关闭播放器",
"LabelCodec": "Codec",
"LabelCodec": "编解码",
"LabelCollapseSeries": "折叠系列",
"LabelCollections": "收藏",
"LabelComplete": "已完成",
"LabelConfirmPassword": "确认密码",
"LabelContinueListening": "继续收听",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "继续收听系列",
"LabelCover": "封面",
"LabelCoverImageURL": "封面图像 URL",
@@ -216,8 +223,14 @@
"LabelDownload": "下载",
"LabelDuration": "持续时间",
"LabelDurationFound": "找到持续时间:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "编辑",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"LabelEmbeddedCover": "嵌入封面",
"LabelEnable": "启用",
"LabelEnd": "结束",
"LabelEpisode": "剧集",
@@ -235,10 +248,13 @@
"LabelFinished": "已听完",
"LabelFolder": "文件夹",
"LabelFolders": "文件夹",
"LabelFormat": "Format",
"LabelFormat": "编码格式",
"LabelGenre": "流派",
"LabelGenres": "流派",
"LabelHardDeleteFile": "完全删除文件",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHost": "Host",
"LabelHour": "小时",
"LabelIcon": "图标",
"LabelIncludeInTracklist": "包含在音轨列表中",
@@ -254,12 +270,12 @@
"LabelIntervalEveryDay": "每天",
"LabelIntervalEveryHour": "每小时",
"LabelInvalidParts": "无效部件",
"LabelInvert": "Invert",
"LabelInvert": "倒转",
"LabelItem": "项目",
"LabelLanguage": "语言",
"LabelLanguageDefaultServer": "默认服务器语言",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastBookAdded": "最后添加的书",
"LabelLastBookUpdated": "最后更新的书",
"LabelLastSeen": "上次查看时间",
"LabelLastTime": "最近一次",
"LabelLastUpdate": "最近更新",
@@ -278,12 +294,12 @@
"LabelMediaType": "媒体类型",
"LabelMetadataProvider": "元数据提供者",
"LabelMetaTag": "元数据标签",
"LabelMetaTags": "Meta Tags",
"LabelMetaTags": "元标签",
"LabelMinute": "分钟",
"LabelMissing": "丢失",
"LabelMissingParts": "丢失的部分",
"LabelMore": "更多",
"LabelMoreInfo": "More Info",
"LabelMoreInfo": "更多..",
"LabelName": "名称",
"LabelNarrator": "演播者",
"LabelNarrators": "演播者",
@@ -324,13 +340,18 @@
"LabelPodcast": "播客",
"LabelPodcasts": "播客",
"LabelPodcastType": "播客类型",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "忽略的前缀 (不区分大小写)",
"LabelPreventIndexing": "防止 iTunes 和 Google 播客目录对你的源进行索引",
"LabelPrimaryEbook": "Primary ebook",
"LabelProgress": "进度",
"LabelProvider": "供应商",
"LabelPubDate": "出版日期",
"LabelPublisher": "出版商",
"LabelPublishYear": "发布年份",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentlyAdded": "最近添加",
"LabelRecentSeries": "最近添加系列",
"LabelRecommended": "推荐内容",
@@ -347,18 +368,21 @@
"LabelSearchTitle": "搜索标题",
"LabelSearchTitleOrASIN": "搜索标题或 ASIN",
"LabelSeason": "季",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "序列",
"LabelSeries": "系列",
"LabelSeriesName": "系列名称",
"LabelSeriesProgress": "系列进度",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计",
"LabelSettingsChromecastSupport": "Chromecast 支持",
"LabelSettingsDateFormat": "日期格式",
"LabelSettingsDisableWatcher": "禁用监视程序",
"LabelSettingsDisableWatcherForLibrary": "禁用媒体库的文件夹监视程序",
"LabelSettingsDisableWatcherHelp": "检测到文件更改时禁用自动添加和更新项目. *需要重启服务器",
"LabelSettingsEnableEReader": "为所有用户启用电子阅读器",
"LabelSettingsEnableEReaderHelp": "电子阅读器仍在开发中,但可以使用此设置向所有用户打开它(或使用 \"实验功能\" 切换仅供你使用)",
"LabelSettingsExperimentalFeatures": "实验功能",
"LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.",
"LabelSettingsFindCovers": "查找封面",
@@ -413,9 +437,9 @@
"LabelTag": "标签",
"LabelTags": "标签",
"LabelTagsAccessibleToUser": "用户可访问的标签",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTagsNotAccessibleToUser": "用户无法访问标签",
"LabelTasks": "正在运行的任务",
"LabelTimeBase": "Time Base",
"LabelTimeBase": "时间基准",
"LabelTimeListened": "收听时间",
"LabelTimeListenedToday": "今日收听的时间",
"LabelTimeRemaining": "剩余 {0}",
@@ -435,7 +459,7 @@
"LabelTracksMultiTrack": "多轨",
"LabelTracksSingleTrack": "单轨",
"LabelType": "类型",
"LabelUnabridged": "Unabridged",
"LabelUnabridged": "未删节",
"LabelUnknown": "未知",
"LabelUpdateCover": "更新封面",
"LabelUpdateCoverHelp": "找到匹配项时允许覆盖所选书籍存在的封面",
@@ -474,15 +498,17 @@
"MessageChapterStartIsAfter": "章节开始是在有声读物结束之后",
"MessageCheckingCron": "检查计划任务...",
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
"MessageConfirmDeleteSession": "你确定要删除此会话吗?",
"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": "注意: 该流派已经存在, 因此它们将被合并.",
@@ -490,6 +516,7 @@
"MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?",
"MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.",
"MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "正在下载剧集",
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
"MessageEmbedFinished": "嵌入完成!",
@@ -583,7 +610,7 @@
"PlaceholderNewFolderPath": "输入文件夹路径",
"PlaceholderNewPlaylist": "输入播放列表名称",
"PlaceholderSearch": "查找..",
"PlaceholderSearchEpisode": "Search episode..",
"PlaceholderSearchEpisode": "搜索剧集..",
"ToastAccountUpdateFailed": "账户更新失败",
"ToastAccountUpdateSuccess": "帐户已更新",
"ToastAuthorImageRemoveFailed": "作者图像删除失败",
@@ -644,6 +671,8 @@
"ToastRemoveItemFromCollectionSuccess": "项目已从收藏中删除",
"ToastRSSFeedCloseFailed": "关闭 RSS 源失败",
"ToastRSSFeedCloseSuccess": "RSS 源已关闭",
"ToastSendEbookToDeviceFailed": "Failed to send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "更新系列失败",
"ToastSeriesUpdateSuccess": "系列已更新",
"ToastSessionDeleteFailed": "删除会话失败",

18
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.2.19",
"version": "2.2.23",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.2.19",
"version": "2.2.23",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
@@ -14,6 +14,7 @@
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"node-tone": "^1.0.1",
"nodemailer": "^6.9.2",
"socket.io": "^4.5.4",
"xml2js": "^0.5.0"
},
@@ -835,6 +836,14 @@
"resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz",
"integrity": "sha512-wi7L0taDZMN6tM5l85TDKHsYzdhqJTtPNgvgpk2zHeZzPt6ZIUZ9vBLTJRRDpm0xzCvbsvFHjAaudeQjLHTE4w=="
},
"node_modules/nodemailer": {
"version": "6.9.2",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.2.tgz",
"integrity": "sha512-4+TYaa/e1nIxQfyw/WzNPYTEZ5OvHIDEnmjs4LPmIfccPQN+2CYKmGHjWixn/chzD3bmUTu5FMfpltizMxqzdg==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz",
@@ -1946,6 +1955,11 @@
"resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz",
"integrity": "sha512-wi7L0taDZMN6tM5l85TDKHsYzdhqJTtPNgvgpk2zHeZzPt6ZIUZ9vBLTJRRDpm0xzCvbsvFHjAaudeQjLHTE4w=="
},
"nodemailer": {
"version": "6.9.2",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.2.tgz",
"integrity": "sha512-4+TYaa/e1nIxQfyw/WzNPYTEZ5OvHIDEnmjs4LPmIfccPQN+2CYKmGHjWixn/chzD3bmUTu5FMfpltizMxqzdg=="
},
"nodemon": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.2.19",
"version": "2.2.23",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {
@@ -35,6 +35,7 @@
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"node-tone": "^1.0.1",
"nodemailer": "^6.9.2",
"socket.io": "^4.5.4",
"xml2js": "^0.5.0"
},

View File

@@ -32,7 +32,7 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
* Chapter editor and chapter lookup (using [Audnexus API](https://audnex.us/))
* Merge your audio files into a single m4b
* Embed metadata and cover image into your audio files (using [Tone](https://github.com/sandreas/tone))
* Basic ebook support and e-reader *(experimental)*
* Basic ebook support and ereader
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose)
@@ -117,9 +117,11 @@ Add this to the site config file on your Apache server after you have changed th
For this to work you must enable at least the following mods using `a2enmod`:
- `ssl`
- `proxy_module`
- `proxy_wstunnel_module`
- `rewrite_module`
- `proxy`
- `proxy_http`
- `proxy_balancer`
- `proxy_wstunnel`
- `rewrite`
```bash
<IfModule mod_ssl.c>
@@ -144,6 +146,26 @@ For this to work you must enable at least the following mods using `a2enmod`:
</IfModule>
```
Some SSL certificates like those signed by Let's Encrypt require ACME validation. To allow Let's Encrypt to write and confirm
the ACME challenge, edit your VirtualHost definition to prevent proxying traffic that queries `/.well-known` and instead
serve that directly:
```bash
<VirtualHost *:443>
# ...
# create the directory structure /.well-known/acme-challenges
# within DocumentRoot and give the HTTP user recursive write
# access to it.
DocumentRoot /path/to/local/directory
ProxyPreserveHost On
ProxyPass /.well-known !
ProxyPass / http://localhost:<audiobookshelf_port>/
# ...
</VirtualHost>
```
### SWAG Reverse Proxy

View File

@@ -120,6 +120,7 @@ class Auth {
user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSONForBrowser(),
ereaderDevices: this.db.emailSettings.getEReaderDevices(user),
Source: global.Source
}
}

View File

@@ -12,6 +12,7 @@ const Author = require('./objects/entities/Author')
const Series = require('./objects/entities/Series')
const ServerSettings = require('./objects/settings/ServerSettings')
const NotificationSettings = require('./objects/settings/NotificationSettings')
const EmailSettings = require('./objects/settings/EmailSettings')
const PlaybackSession = require('./objects/PlaybackSession')
class Db {
@@ -49,6 +50,7 @@ class Db {
this.serverSettings = null
this.notificationSettings = null
this.emailSettings = null
// Stores previous version only if upgraded
this.previousVersion = null
@@ -156,6 +158,10 @@ class Db {
this.notificationSettings = new NotificationSettings()
await this.insertEntity('settings', this.notificationSettings)
}
if (!this.emailSettings) {
this.emailSettings = new EmailSettings()
await this.insertEntity('settings', this.emailSettings)
}
global.ServerSettings = this.serverSettings.toJSON()
}
@@ -202,6 +208,11 @@ class Db {
if (notificationSettings) {
this.notificationSettings = new NotificationSettings(notificationSettings)
}
const emailSettings = this.settings.find(s => s.id === 'email-settings')
if (emailSettings) {
this.emailSettings = new EmailSettings(emailSettings)
}
}
})
const p5 = this.collectionsDb.select(() => true).then((results) => {

View File

@@ -11,6 +11,7 @@ const { version } = require('../package.json')
const dbMigration = require('./utils/dbMigration')
const filePerms = require('./utils/filePerms')
const fileUtils = require('./utils/fileUtils')
const globals = require('./utils/globals')
const Logger = require('./Logger')
const Auth = require('./Auth')
@@ -24,6 +25,7 @@ const HlsRouter = require('./routers/HlsRouter')
const StaticRouter = require('./routers/StaticRouter')
const NotificationManager = require('./managers/NotificationManager')
const EmailManager = require('./managers/EmailManager')
const CoverManager = require('./managers/CoverManager')
const AbMergeManager = require('./managers/AbMergeManager')
const CacheManager = require('./managers/CacheManager')
@@ -65,6 +67,7 @@ class Server {
// Managers
this.taskManager = new TaskManager()
this.notificationManager = new NotificationManager(this.db)
this.emailManager = new EmailManager(this.db)
this.backupManager = new BackupManager(this.db)
this.logManager = new LogManager(this.db)
this.cacheManager = new CacheManager()
@@ -75,7 +78,7 @@ class Server {
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
this.rssFeedManager = new RssFeedManager(this.db)
this.scanner = new Scanner(this.db, this.coverManager)
this.scanner = new Scanner(this.db, this.coverManager, this.taskManager)
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
// Routers
@@ -145,7 +148,12 @@ class Server {
this.server = http.createServer(app)
router.use(this.auth.cors)
router.use(fileUpload())
router.use(fileUpload({
defCharset: 'utf8',
defParamCharset: 'utf8',
useTempFiles: true,
tempFileDir: Path.join(global.MetadataPath, 'tmp')
}))
router.use(express.urlencoded({ extended: true, limit: "5mb" }));
router.use(express.json({ limit: "5mb" }))
@@ -161,16 +169,35 @@ class Server {
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
// TODO: Deprecated as of 2.2.21 edge
router.use('/s', this.authMiddleware.bind(this), this.staticRouter.router)
// EBook static file routes
// TODO: Deprecated as of 2.2.21 edge
router.get('/ebook/:library/:folder/*', (req, res) => {
const library = this.db.libraries.find(lib => lib.id === req.params.library)
if (!library) return res.sendStatus(404)
const folder = library.folders.find(fol => fol.id === req.params.folder)
if (!folder) return res.status(404).send('Folder not found')
const remainingPath = req.params['0']
// Replace backslashes with forward slashes
const remainingPath = req.params['0'].replace(/\\/g, '/')
// Prevent path traversal
// e.g. ../../etc/passwd
if (/\/?\.?\.\//.test(remainingPath)) {
Logger.error(`[Server] Invalid path to get ebook "${remainingPath}"`)
return res.sendStatus(403)
}
// Check file ext is a valid ebook file
const filext = (Path.extname(remainingPath) || '').slice(1).toLowerCase()
if (!globals.SupportedEbookTypes.includes(filext)) {
Logger.error(`[Server] Invalid ebook file ext requested "${remainingPath}"`)
return res.sendStatus(403)
}
const fullPath = Path.join(folder.fullPath, remainingPath)
res.sendFile(fullPath)
})

View File

@@ -0,0 +1,86 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
class EmailController {
constructor() { }
getSettings(req, res) {
res.json({
settings: this.db.emailSettings
})
}
async updateSettings(req, res) {
const updated = this.db.emailSettings.update(req.body)
if (updated) {
await this.db.updateEntity('settings', this.db.emailSettings)
}
res.json({
settings: this.db.emailSettings
})
}
async sendTest(req, res) {
this.emailManager.sendTest(res)
}
async updateEReaderDevices(req, res) {
if (!req.body.ereaderDevices || !Array.isArray(req.body.ereaderDevices)) {
return res.status(400).send('Invalid payload. ereaderDevices array required')
}
const ereaderDevices = req.body.ereaderDevices
for (const device of ereaderDevices) {
if (!device.name || !device.email) {
return res.status(400).send('Invalid payload. ereaderDevices array items must have name and email')
}
}
const updated = this.db.emailSettings.update({
ereaderDevices
})
if (updated) {
await this.db.updateEntity('settings', this.db.emailSettings)
SocketAuthority.adminEmitter('ereader-devices-updated', {
ereaderDevices: this.db.emailSettings.ereaderDevices
})
}
res.json({
ereaderDevices: this.db.emailSettings.ereaderDevices
})
}
async sendEBookToDevice(req, res) {
Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
const libraryItem = this.db.getLibraryItem(req.body.libraryItemId)
if (!libraryItem) {
return res.status(404).send('Library item not found')
}
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
return res.sendStatus(403)
}
const ebookFile = libraryItem.media.ebookFile
if (!ebookFile) {
return res.status(404).send('EBook file not found')
}
const device = this.db.emailSettings.getEReaderDevice(req.body.deviceName)
if (!device) {
return res.status(404).send('E-reader device not found')
}
this.emailManager.sendEBookToDevice(ebookFile, device, res)
}
middleware(req, res, next) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(404)
}
next()
}
}
module.exports = new EmailController()

View File

@@ -1,27 +1,50 @@
const Logger = require('../Logger')
const Path = require('path')
const Logger = require('../Logger')
const fs = require('../libs/fsExtra')
class FileSystemController {
constructor() { }
async getPaths(req, res) {
var excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => {
if (!req.user.isAdminOrUp) {
Logger.error(`[FileSystemController] Non-admin user attempting to get filesystem paths`, req.user)
return res.sendStatus(403)
}
const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => {
return Path.sep + dirname
})
// Do not include existing mapped library paths in response
this.db.libraries.forEach(lib => {
lib.folders.forEach((folder) => {
var dir = folder.fullPath
let dir = folder.fullPath
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
excludedDirs.push(dir)
})
})
Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`)
res.json({
directories: await this.getDirectories(global.appRoot, '/', excludedDirs)
})
}
// POST: api/filesystem/pathexists
async checkPathExists(req, res) {
if (!req.user.canUpload) {
Logger.error(`[FileSystemController] Non-admin user attempting to check path exists`, req.user)
return res.sendStatus(403)
}
const filepath = req.body.filepath
if (!filepath?.length) {
return res.sendStatus(400)
}
const exists = await fs.pathExists(filepath)
res.json({
exists
})
}
}
module.exports = new FileSystemController()

View File

@@ -684,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
@@ -707,6 +706,85 @@ class LibraryController {
})
}
async getNarrators(req, res) {
const narrators = {}
req.libraryItems.forEach((li) => {
if (li.media.metadata.narrators?.length) {
li.media.metadata.narrators.forEach((n) => {
if (typeof n !== 'string') {
Logger.error(`[LibraryController] getNarrators: Invalid narrator "${n}" on book "${li.media.metadata.title}"`)
} else 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)
@@ -770,13 +848,19 @@ class LibraryController {
res.json(payload)
}
getOPMLFile(req, res) {
const opmlText = this.podcastManager.generateOPMLFileText(req.libraryItems)
res.type('application/xml')
res.send(opmlText)
}
middleware(req, res, next) {
if (!req.user.checkCanAccessLibrary(req.params.id)) {
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
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

@@ -1,3 +1,4 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
@@ -5,6 +6,7 @@ const SocketAuthority = require('../SocketAuthority')
const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
const { ScanResult } = require('../utils/constants')
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
class LibraryItemController {
constructor() { }
@@ -379,20 +381,23 @@ class LibraryItemController {
return res.sendStatus(403)
}
var itemsUpdated = 0
var itemsUnmatched = 0
let itemsUpdated = 0
let itemsUnmatched = 0
var matchData = req.body
var options = matchData.options || {}
var items = matchData.libraryItemIds
if (!items || !items.length) {
return res.sendStatus(500)
const options = req.body.options || {}
if (!req.body.libraryItemIds?.length) {
return res.sendStatus(400)
}
const libraryItems = req.body.libraryItemIds.map(lid => this.db.getLibraryItem(lid)).filter(li => li)
if (!libraryItems?.length) {
return res.sendStatus(400)
}
res.sendStatus(200)
for (let i = 0; i < items.length; i++) {
var libraryItem = this.db.libraryItems.find(_li => _li.id === items[i])
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
for (const libraryItem of libraryItems) {
const matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
if (matchResult.updated) {
itemsUpdated++
} else if (matchResult.warning) {
@@ -400,7 +405,7 @@ class LibraryItemController {
}
}
var result = {
const result = {
success: itemsUpdated > 0,
updates: itemsUpdated,
unmatched: itemsUnmatched
@@ -408,6 +413,33 @@ class LibraryItemController {
SocketAuthority.clientEmitter(req.user.id, 'batch_quickmatch_complete', result)
}
// POST: api/items/batch/scan
async batchScan(req, res) {
if (!req.user.isAdminOrUp) {
Logger.warn('User other than admin attempted to batch scan library items', req.user)
return res.sendStatus(403)
}
if (!req.body.libraryItemIds?.length) {
return res.sendStatus(400)
}
const libraryItems = req.body.libraryItemIds.map(lid => this.db.getLibraryItem(lid)).filter(li => li)
if (!libraryItems?.length) {
return res.sendStatus(400)
}
res.sendStatus(200)
for (const libraryItem of libraryItems) {
if (libraryItem.isFile) {
Logger.warn(`[LibraryItemController] Re-scanning file library items not yet supported`)
} else {
await this.scanner.scanLibraryItemByRequest(libraryItem)
}
}
}
// DELETE: api/items/all
async deleteAll(req, res) {
if (!req.user.isAdminOrUp) {
@@ -432,7 +464,7 @@ class LibraryItemController {
return res.sendStatus(500)
}
var result = await this.scanner.scanLibraryItemById(req.libraryItem.id)
const result = await this.scanner.scanLibraryItemByRequest(req.libraryItem)
res.json({
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
})
@@ -498,19 +530,45 @@ class LibraryItemController {
res.json(toneData)
}
async deleteLibraryFile(req, res) {
const libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.ino)
if (!libraryFile) {
Logger.error(`[LibraryItemController] Unable to delete library file. Not found. "${req.params.ino}"`)
return res.sendStatus(404)
/**
* GET api/items/:id/file/:fileid
*
* @param {express.Request} req
* @param {express.Response} res
*/
async getLibraryFile(req, res) {
const libraryFile = req.libraryFile
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${libraryFile.metadata.path}`)
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + libraryFile.metadata.path }).send()
}
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryFile.metadata.path))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
res.sendFile(libraryFile.metadata.path)
}
/**
* DELETE api/items/:id/file/:fileid
*
* @param {express.Request} req
* @param {express.Response} res
*/
async deleteLibraryFile(req, res) {
const libraryFile = req.libraryFile
Logger.info(`[LibraryItemController] User "${req.user.username}" requested file delete at "${libraryFile.metadata.path}"`)
await fs.remove(libraryFile.metadata.path).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error)
})
req.libraryItem.removeLibraryFile(req.params.ino)
req.libraryItem.removeLibraryFile(req.params.fileid)
if (req.libraryItem.media.removeFileWithInode(req.params.ino)) {
if (req.libraryItem.media.removeFileWithInode(req.params.fileid)) {
// If book has no more media files then mark it as missing
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
req.libraryItem.setMissing()
@@ -522,15 +580,120 @@ class LibraryItemController {
res.sendStatus(200)
}
/**
* GET api/items/:id/file/:fileid/download
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
* @param {express.Request} req
* @param {express.Response} res
*/
async downloadLibraryFile(req, res) {
const libraryFile = req.libraryFile
if (!req.user.canDownload) {
Logger.error(`[LibraryItemController] User without download permission attempted to download file "${libraryFile.metadata.path}"`, req.user)
return res.sendStatus(403)
}
Logger.info(`[LibraryItemController] User "${req.user.username}" requested file download at "${libraryFile.metadata.path}"`)
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${libraryFile.metadata.path}`)
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + libraryFile.metadata.path }).send()
}
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryFile.metadata.path))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
res.download(libraryFile.metadata.path, libraryFile.metadata.filename)
}
/**
* GET api/items/:id/ebook/:fileid?
* fileid is the inode value stored in LibraryFile.ino or EBookFile.ino
* fileid is only required when reading a supplementary ebook
* when no fileid is passed in the primary ebook will be returned
*
* @param {express.Request} req
* @param {express.Response} res
*/
async getEBookFile(req, res) {
let ebookFile = null
if (req.params.fileid) {
ebookFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
if (!ebookFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id')
}
} else {
ebookFile = req.libraryItem.media.ebookFile
}
if (!ebookFile) {
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.metadata.title}"`)
return res.sendStatus(404)
}
const ebookFilePath = ebookFile.metadata.path
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${ebookFilePath}`)
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + ebookFilePath }).send()
}
res.sendFile(ebookFilePath)
}
/**
* PATCH api/items/:id/ebook/:fileid/status
* toggle the status of an ebook file.
* if an ebook file is the primary ebook, then it will be changed to supplementary
* if an ebook file is supplementary, then it will be changed to primary
*
* @param {express.Request} req
* @param {express.Response} res
*/
async updateEbookFileStatus(req, res) {
const ebookLibraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
if (!ebookLibraryFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id')
}
if (ebookLibraryFile.isSupplementary) {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`)
req.libraryItem.setPrimaryEbook(ebookLibraryFile)
} else {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`)
ebookLibraryFile.isSupplementary = true
req.libraryItem.setPrimaryEbook(null)
}
req.libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
res.sendStatus(200)
}
middleware(req, res, next) {
const item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
req.libraryItem = this.db.libraryItems.find(li => li.id === req.params.id)
if (!req.libraryItem?.media) return res.sendStatus(404)
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) {
if (!req.user.checkCanAccessLibraryItem(req.libraryItem)) {
return res.sendStatus(403)
}
// For library file routes, get the library file
if (req.params.fileid) {
req.libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
if (!req.libraryFile) {
Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`)
return res.sendStatus(404)
}
}
if (req.path.includes('/play')) {
// allow POST requests using /play and /play/:episodeId
} else if (req.method == 'DELETE' && !req.user.canDelete) {
@@ -541,7 +704,6 @@ class LibraryItemController {
return res.sendStatus(403)
}
req.libraryItem = item
next()
}
}

View File

@@ -48,8 +48,7 @@ class MeController {
// DELETE: api/me/progress/:id
async removeMediaProgress(req, res) {
var wasRemoved = req.user.removeMediaProgress(req.params.id)
if (!wasRemoved) {
if (!req.user.removeMediaProgress(req.params.id)) {
return res.sendStatus(200)
}
await this.db.updateEntity('user', req.user)

View File

@@ -109,7 +109,7 @@ class PodcastController {
res.json({ podcast })
}
async getOPMLFeeds(req, res) {
async getFeedsFromOPMLText(req, res) {
if (!req.body.opmlText) {
return res.sendStatus(400)
}

View File

@@ -11,7 +11,7 @@ class SeriesController {
// Add progress map with isFinished flag
if (include.includes('progress')) {
const libraryItemsInSeries = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(seriesJson.id))
const libraryItemsInSeries = req.libraryItemsInSeries
const libraryItemsFinished = libraryItemsInSeries.filter(li => {
const mediaProgress = req.user.getMediaProgress(li.id)
return mediaProgress && mediaProgress.isFinished
@@ -55,6 +55,12 @@ class SeriesController {
const series = this.db.series.find(se => se.id === req.params.id)
if (!series) return res.sendStatus(404)
const libraryItemsInSeries = this.db.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
if (libraryItemsInSeries.some(li => !req.user.checkCanAccessLibrary(li.libraryId))) {
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to the library`, req.user)
return res.sendStatus(403)
}
if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[SeriesController] User attempted to delete without permission`, req.user)
return res.sendStatus(403)
@@ -64,6 +70,7 @@ class SeriesController {
}
req.series = series
req.libraryItemsInSeries = libraryItemsInSeries
next()
}
}

View File

@@ -74,7 +74,9 @@ class SessionController {
// POST: api/session/:id/close
close(req, res) {
this.playbackSessionManager.closeSessionRequest(req.user, req.session, req.body, res)
let syncData = req.body
if (syncData && !Object.keys(syncData).length) syncData = null
this.playbackSessionManager.closeSessionRequest(req.user, req.session, syncData, res)
}
// DELETE: api/session/:id

View File

@@ -18,6 +18,8 @@ class BookFinder {
this.fantLab = new FantLab()
this.audiobookCovers = new AudiobookCovers()
this.providers = ['google', 'itunes', 'openlibrary', 'fantlab', 'audiobookcovers', 'audible', 'audible.ca', 'audible.uk', 'audible.au', 'audible.fr', 'audible.de', 'audible.jp', 'audible.it', 'audible.in', 'audible.es']
this.verbose = false
}
@@ -183,7 +185,7 @@ class BookFinder {
var books = []
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
Logger.debug(`Book Search: title: "${title}", author: "${author}", provider: ${provider}`)
Logger.debug(`Book Search: title: "${title}", author: "${author || ''}", provider: ${provider}`)
if (provider === 'google') {
books = await this.getGoogleBooksResults(title, author)
@@ -222,19 +224,29 @@ class BookFinder {
}
async findCovers(provider, title, author, options = {}) {
var searchResults = await this.search(provider, title, author, options)
let searchResults = []
if (provider === 'all') {
for (const providerString of this.providers) {
const providerResults = await this.search(providerString, title, author, options)
Logger.debug(`[BookFinder] Found ${providerResults.length} covers from ${providerString}`)
searchResults.push(...providerResults)
}
} else {
searchResults = await this.search(provider, title, author, options)
}
Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
var covers = []
const covers = []
searchResults.forEach((result) => {
if (result.covers && result.covers.length) {
covers = covers.concat(result.covers)
covers.push(...result.covers)
}
if (result.cover) {
covers.push(result.cover)
}
})
return covers
return [...(new Set(covers))]
}
findChapters(asin, region) {

Some files were not shown because too many files have changed in this diff Show More