Compare commits

..

50 Commits

Author SHA1 Message Date
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
112 changed files with 2605 additions and 711 deletions
+14
View File
@@ -232,6 +232,20 @@ Bookshelf Label
-webkit-box-orient: vertical; -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 */ /* Padding for toastification toasts in the top right to not cover appbar/toolbar */
.app-bar-and-toolbar .Vue-Toastification__container.top-right { .app-bar-and-toolbar .Vue-Toastification__container.top-right {
+34 -3
View File
@@ -15,8 +15,6 @@
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" /> <controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
<div class="flex-grow" /> <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"> <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> <span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
</ui-tooltip> </ui-tooltip>
@@ -24,6 +22,8 @@
<google-cast-launcher></google-cast-launcher> <google-cast-launcher></google-cast-launcher>
</div> </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"> <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"> <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> <span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
@@ -178,6 +178,11 @@ export default {
}) })
} }
options.push({
text: 'Re-Scan',
action: 'rescan'
})
return options return options
} }
}, },
@@ -206,13 +211,39 @@ export default {
} }
this.$store.commit('globals/setConfirmPrompt', payload) this.$store.commit('globals/setConfirmPrompt', payload)
}, },
contextMenuAction(action) { contextMenuAction({ action }) {
if (action === 'quick-embed') { if (action === 'quick-embed') {
this.requestBatchQuickEmbed() this.requestBatchQuickEmbed()
} else if (action === 'quick-match') { } else if (action === 'quick-match') {
this.batchAutoMatchClick() 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() { async playSelectedItems() {
this.$store.commit('setProcessingBatch', true) this.$store.commit('setProcessingBatch', true)
+19 -18
View File
@@ -16,7 +16,7 @@
<!-- Alternate plain view --> <!-- Alternate plain view -->
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24"> <div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
<template v-for="(shelf, index) in shelves"> <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> <p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-item-slider> </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)"> <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 --> <!-- Regular bookshelf view -->
<div v-else class="w-full"> <div v-else class="w-full">
<template v-for="(shelf, index) in shelves"> <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> </template>
</div> </div>
</div> </div>
@@ -286,7 +286,8 @@ export default {
} }
if (user.mediaProgress.length) { if (user.mediaProgress.length) {
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening) const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
this.removeItemsFromContinueListening(mediaProgressToHide) this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')
} }
}, },
libraryItemAdded(libraryItem) { libraryItemAdded(libraryItem) {
@@ -336,8 +337,9 @@ export default {
}, },
libraryItemsAdded(libraryItems) { libraryItemsAdded(libraryItems) {
console.log('libraryItems added', 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() this.fetchCategories()
} }
}, },
@@ -346,6 +348,14 @@ export default {
this.libraryItemUpdated(li) 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) { removeAllSeriesFromContinueSeries(seriesIds) {
this.shelves.forEach((shelf) => { this.shelves.forEach((shelf) => {
if (shelf.type == 'book' && shelf.id == 'continue-series') { if (shelf.type == 'book' && shelf.id == 'continue-series') {
@@ -357,8 +367,8 @@ export default {
} }
}) })
}, },
removeItemsFromContinueListening(mediaProgressItems) { removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {
const continueListeningShelf = this.shelves.find((s) => s.id === 'continue-listening') const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)
if (continueListeningShelf) { if (continueListeningShelf) {
if (continueListeningShelf.type === 'book') { if (continueListeningShelf.type === 'book') {
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => { continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
@@ -373,17 +383,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) { authorUpdated(author) {
this.shelves.forEach((shelf) => { this.shelves.forEach((shelf) => {
@@ -417,6 +416,7 @@ export default {
this.$root.socket.on('item_removed', this.libraryItemRemoved) this.$root.socket.on('item_removed', this.libraryItemRemoved)
this.$root.socket.on('items_updated', this.libraryItemsUpdated) this.$root.socket.on('items_updated', this.libraryItemsUpdated)
this.$root.socket.on('items_added', this.libraryItemsAdded) this.$root.socket.on('items_added', this.libraryItemsAdded)
this.$root.socket.on('episode_added', this.episodeAdded)
} else { } else {
console.error('Error socket not initialized') console.error('Error socket not initialized')
} }
@@ -431,6 +431,7 @@ export default {
this.$root.socket.off('item_removed', this.libraryItemRemoved) this.$root.socket.off('item_removed', this.libraryItemRemoved)
this.$root.socket.off('items_updated', this.libraryItemsUpdated) this.$root.socket.off('items_updated', this.libraryItemsUpdated)
this.$root.socket.off('items_added', this.libraryItemsAdded) this.$root.socket.off('items_added', this.libraryItemsAdded)
this.$root.socket.off('episode_added', this.episodeAdded)
} else { } else {
console.error('Error socket not initialized') console.error('Error socket not initialized')
} }
+26 -1
View File
@@ -81,6 +81,8 @@
<!-- issues page remove all button --> <!-- 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-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="110px" class="ml-2" @action="contextMenuAction" />
</template> </template>
<!-- search page --> <!-- search page -->
<template v-else-if="page === 'search'"> <template v-else-if="page === 'search'">
@@ -186,6 +188,9 @@ export default {
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
}, },
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
@@ -276,10 +281,30 @@ export default {
}, },
isIssuesFilter() { isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues' 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: { 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') { if (action === 'open-rss-feed') {
this.showOpenSeriesRSSFeed() this.showOpenSeriesRSSFeed()
} else if (action === 're-add-to-continue-listening') { } else if (action === 're-add-to-continue-listening') {
+5
View File
@@ -90,6 +90,11 @@ export default {
title: this.$strings.HeaderNotifications, title: this.$strings.HeaderNotifications,
path: '/config/notifications' path: '/config/notifications'
}, },
{
id: 'config-email',
title: this.$strings.HeaderEmail,
path: '/config/email'
},
{ {
id: 'config-item-metadata-utils', id: 'config-item-metadata-utils',
title: this.$strings.HeaderItemMetadataUtils, title: this.$strings.HeaderItemMetadataUtils,
+9 -4
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-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="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"> <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>
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</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> <widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
@@ -268,6 +268,10 @@ export default {
seek(time) { seek(time) {
this.playerHandler.seek(time) this.playerHandler.seek(time)
}, },
playbackTimeUpdate(time) {
// When updating progress from another session
this.playerHandler.seek(time, false)
},
setCurrentTime(time) { setCurrentTime(time) {
this.currentTime = time this.currentTime = time
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
@@ -366,9 +370,8 @@ export default {
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward) navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward) navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo) navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack) navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
} else { } else {
console.warn('Media session not available') console.warn('Media session not available')
} }
@@ -478,12 +481,14 @@ export default {
mounted() { mounted() {
this.$eventBus.$on('cast-session-active', this.castSessionActive) this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('playback-seek', this.seek) 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('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem) this.$eventBus.$on('pause-item', this.pauseItem)
}, },
beforeDestroy() { beforeDestroy() {
this.$eventBus.$off('cast-session-active', this.castSessionActive) this.$eventBus.$off('cast-session-active', this.castSessionActive)
this.$eventBus.$off('playback-seek', this.seek) 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('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem) this.$eventBus.$off('pause-item', this.pauseItem)
} }
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<nuxt-link :to="`/author/${author.id}`"> <nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`">
<div @mouseover="mouseover" @mouseleave="mouseleave"> <div @mouseover="mouseover" @mouseleave="mouseleave">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden"> <div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<!-- Image or placeholder --> <!-- Image or placeholder -->
+2 -2
View File
@@ -5,7 +5,7 @@
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p> <p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" /> <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-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" /> <p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
@@ -61,7 +61,6 @@ export default {
}, },
matchHtml() { matchHtml() {
if (!this.matchText || !this.search) return '' if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return ''
// This used to highlight the part of the search found // This used to highlight the part of the search found
// but with removing commas periods etc this is no longer plausible // 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 === '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 === '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 === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>` if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>` if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
@@ -1,8 +1,10 @@
<template> <template>
<div class="flex items-center h-full px-1 overflow-hidden"> <div class="flex items-center px-1 overflow-hidden">
<div class="h-5 w-5 min-w-5 text-lg mr-1.5 flex items-center justify-center"> <div class="w-8 flex items-center justify-center">
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span> <!-- <div class="text-lg"> -->
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span>
<widgets-loading-spinner v-else /> <widgets-loading-spinner v-else />
<!-- </div> -->
</div> </div>
<div class="flex-grow px-2 taskRunningCardContent"> <div class="flex-grow px-2 taskRunningCardContent">
<p class="truncate text-sm">{{ title }}</p> <p class="truncate text-sm">{{ title }}</p>
@@ -36,10 +38,13 @@ export default {
return this.task.details || 'Unknown' return this.task.details || 'Unknown'
}, },
isFinished() { isFinished() {
return this.task.isFinished || false return !!this.task.isFinished
}, },
isFailed() { isFailed() {
return this.task.isFailed || false return !!this.task.isFailed
},
isSuccess() {
return this.isFinished && !this.isFailed
}, },
failedMessage() { failedMessage() {
return this.task.error || '' return this.task.error || ''
@@ -48,6 +53,11 @@ export default {
return this.task.action || '' return this.task.action || ''
}, },
actionIcon() { actionIcon() {
if (this.isFailed) {
return 'error'
} else if (this.isSuccess) {
return 'done'
}
switch (this.action) { switch (this.action) {
case 'download-podcast-episode': case 'download-podcast-episode':
return 'cloud_download' return 'cloud_download'
@@ -68,16 +78,15 @@ export default {
return '' return ''
} }
}, },
methods: { methods: {},
},
mounted() {} mounted() {}
} }
</script> </script>
<style> <style>
.taskRunningCardContent { .taskRunningCardContent {
width: calc(100% - 80px); width: calc(100% - 84px);
height: 75px; height: 60px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -114,6 +114,7 @@ export default {
var files = this.item.itemFiles.concat(this.item.otherFiles) var files = this.item.itemFiles.concat(this.item.otherFiles)
return { return {
index: this.item.index, index: this.item.index,
directory: this.directory,
...this.itemData, ...this.itemData,
files files
} }
+61 -10
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"> <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> <span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div> </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> </div>
<!-- Processing/loading spinner overlay --> <!-- Processing/loading spinner overlay -->
@@ -221,7 +225,7 @@ export default {
libraryId() { libraryId() {
return this._libraryItem.libraryId return this._libraryItem.libraryId
}, },
hasEbook() { ebookFormat() {
return this.media.ebookFormat return this.media.ebookFormat
}, },
numTracks() { numTracks() {
@@ -252,14 +256,14 @@ export default {
}, },
booksInSeries() { booksInSeries() {
// Only added to item object when collapseSeries is enabled // Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0 return this.collapsedSeries?.numBooks || 0
}, },
seriesSequenceList() { seriesSequenceList() {
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null return this.collapsedSeries?.seriesSequenceList || null
}, },
libraryItemIdsInSeries() { libraryItemIdsInSeries() {
// Only added to item object when collapseSeries is enabled // Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : [] return this.collapsedSeries?.libraryItemIds || []
}, },
hasCover() { hasCover() {
return !!this.media.coverPath return !!this.media.coverPath
@@ -325,6 +329,9 @@ export default {
if (this.episodeProgress) return this.episodeProgress if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
isEBookOnly() {
return !this.numTracks && this.ebookFormat
},
useEBookProgress() { useEBookProgress() {
if (!this.userProgress || this.userProgress.progress) return false if (!this.userProgress || this.userProgress.progress) return false
return this.userProgress.ebookProgress > 0 return this.userProgress.ebookProgress > 0
@@ -360,13 +367,13 @@ export default {
return this.store.getters['getIsStreamingFromDifferentLibrary'] return this.store.getters['getIsStreamingFromDifferentLibrary']
}, },
showReadButton() { showReadButton() {
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader) return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat && (this.showExperimentalFeatures || this.enableEReader)
}, },
showPlayButton() { showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic) return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
}, },
showSmallEBookIcon() { showSmallEBookIcon() {
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader) return !this.isSelectionMode && this.ebookFormat && (this.showExperimentalFeatures || this.enableEReader)
}, },
isMissing() { isMissing() {
return this._libraryItem.isMissing return this._libraryItem.isMissing
@@ -482,6 +489,18 @@ export default {
text: this.$strings.LabelAddToPlaylist 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) { if (this.userCanUpdate) {
items.push({ items.push({
@@ -508,7 +527,7 @@ export default {
if (this.continueListeningShelf) { if (this.continueListeningShelf) {
items.push({ items.push({
func: 'removeFromContinueListening', func: 'removeFromContinueListening',
text: this.$strings.ButtonRemoveFromContinueListening text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
}) })
} }
if (!this.isPodcast) { if (!this.isPodcast) {
@@ -712,6 +731,37 @@ export default {
// More menu func // More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' }) 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() { removeSeriesFromContinueListening() {
const axios = this.$axios || this.$nuxt.$axios const axios = this.$axios || this.$nuxt.$axios
this.processing = true this.processing = true
@@ -825,8 +875,8 @@ export default {
items: this.moreMenuItems items: this.moreMenuItems
}, },
created() { created() {
this.$on('action', (func) => { this.$on('action', (action) => {
if (_this[func]) _this[func]() if (action.func && _this[action.func]) _this[action.func](action.data)
}) })
this.$on('close', () => { this.$on('close', () => {
_this.isMoreMenuOpen = false _this.isMoreMenuOpen = false
@@ -865,7 +915,8 @@ export default {
this.createMoreMenu() this.createMoreMenu()
}, },
async clickReadEBook() { 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) console.error('Failed to get lirbary item', this.libraryItemId)
return null return null
}) })
+2 -2
View File
@@ -197,8 +197,8 @@ export default {
} }
</script> </script>
<style> <style scoped>
.globalSearchMenu { .globalSearchMenu {
max-height: 80vh; max-height: calc(100vh - 75px);
} }
</style> </style>
@@ -14,12 +14,12 @@
</div> </div>
</button> </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"> <ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems"> <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="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)">
<div class="flex items-center justify-between"> <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>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center"> <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> <span class="material-icons text-2xl">arrow_right</span>
@@ -185,6 +185,11 @@ export default {
value: 'tracks', value: 'tracks',
sublist: true sublist: true
}, },
{
text: this.$strings.LabelEbook,
value: 'ebook',
sublist: false
},
{ {
text: this.$strings.LabelAbridged, text: this.$strings.LabelAbridged,
value: 'abridged', value: 'abridged',
@@ -440,3 +445,9 @@ export default {
} }
} }
</script> </script>
<style scoped>
.libraryFilterMenu {
max-height: calc(100vh - 125px);
}
</style>
+1 -1
View File
@@ -15,7 +15,7 @@
</template> </template>
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime"> <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-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> </form>
</div> </div>
<div v-else class="w-full p-4"> <div v-else class="w-full p-4">
@@ -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>
+19 -15
View File
@@ -74,6 +74,9 @@ export default {
this.$store.commit('setEditModalTab', val) this.$store.commit('setEditModalTab', val)
} }
}, },
height() {
return Math.min(this.availableHeight, 650)
},
tabs() { tabs() {
return [ return [
{ {
@@ -136,6 +139,18 @@ export default {
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] 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() { availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return [] if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => { return this.tabs.filter((tab) => {
@@ -144,6 +159,7 @@ export default {
if (tab.admin && !this.userIsAdminOrUp) return false if (tab.admin && !this.userIsAdminOrUp) return false
if (tab.id === 'tools' && this.isMissing) 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.userCanDownload) return true
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
@@ -151,9 +167,6 @@ export default {
return false return false
}) })
}, },
height() {
return Math.min(this.availableHeight, 650)
},
tabName() { tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab) var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : '' return _tab ? _tab.component : ''
@@ -161,20 +174,11 @@ export default {
isMissing() { isMissing() {
return this.selectedLibraryItem.isMissing return this.selectedLibraryItem.isMissing
}, },
selectedLibraryItem() { isEBookOnly() {
return this.$store.state.selectedLibraryItem || {} return this.media.ebookFile && !this.media.tracks?.length
},
selectedLibraryItemId() {
return this.selectedLibraryItem.id
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
}, },
mediaType() { mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null return this.libraryItem?.mediaType || null
}, },
title() { title() {
return this.mediaMetadata.title || 'No Title' return this.mediaMetadata.title || 'No Title'
+13 -13
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-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
<div class="flex items-center"> <div class="flex items-center">
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32"> <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" <ui-file-input ref="fileInput" @change="fileUploadSelected">
><span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span <span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
><span class="material-icons text-2xl inline-block md:!hidden">upload</span></ui-file-input <span class="material-icons text-2xl inline-block md:!hidden">upload</span>
> </ui-file-input>
</div> </div>
<form @submit.prevent="submitForm" class="flex flex-grow"> <form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" /> <ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
@@ -36,10 +36,10 @@
</div> </div>
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2"> <div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
<template v-for="cover in localCovers"> <template v-for="localCoverFile 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)"> <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' }"> <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>
</div> </div>
</template> </template>
@@ -128,7 +128,7 @@ export default {
}, },
providers() { providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders 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() { searchTitleLabel() {
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
@@ -169,8 +169,8 @@ export default {
return this.libraryFiles return this.libraryFiles
.filter((f) => f.fileType === 'image') .filter((f) => f.fileType === 'image')
.map((file) => { .map((file) => {
var _file = { ...file } const _file = { ...file }
_file.localPath = `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}` _file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
return _file return _file
}) })
} }
@@ -223,7 +223,7 @@ export default {
this.searchTitle = this.mediaMetadata.title || '' this.searchTitle = this.mediaMetadata.title || ''
this.searchAuthor = this.mediaMetadata.authorName || '' this.searchAuthor = this.mediaMetadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes' 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() { removeCover() {
if (!this.media.coverPath) { if (!this.media.coverPath) {
@@ -288,13 +288,13 @@ export default {
}, },
getSearchQuery() { getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}` 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' if (this.isPodcast) searchQuery += '&podcast=1'
return searchQuery return searchQuery
}, },
persistProvider() { persistProvider() {
try { try {
localStorage.setItem('book-provider', this.provider) localStorage.setItem('book-cover-provider', this.provider)
} catch (error) { } catch (error) {
console.error('PersistProvider', error) console.error('PersistProvider', error)
} }
+27 -26
View File
@@ -1,11 +1,11 @@
<template> <template>
<div class="w-full h-full"> <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-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 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-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)">
<p class="text-sm truncate">{{ file }}</p> <p class="text-sm truncate">{{ file }}</p>
</div> </div>
</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"> <div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs"> <p class="text-xs">
<strong>{{ key }}</strong <strong>{{ key }}</strong
@@ -14,17 +14,17 @@
</div> </div>
</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"> <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="showInfoMenu = !showInfoMenu">
<span class="material-icons text-xl">more</span> <span class="material-icons text-xl">more</span>
</div> </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 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="showPageMenu = !showPageMenu">
<span class="material-icons text-xl">menu</span> <span class="material-icons text-xl">menu</span>
</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 z-20"> <div 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 + 1 }} / {{ numPages }}</p> <p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
</div> </div>
<div class="overflow-hidden m-auto comicwrapper relative"> <div class="overflow-hidden w-full h-full 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 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="flex items-center justify-center h-full w-1/2"> <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> <span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
@@ -36,17 +36,13 @@
</div> </div>
</div> </div>
<div class="h-full flex justify-center"> <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>
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10"> <div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div>
</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> </div>
</template> </template>
@@ -61,7 +57,11 @@ Archive.init({
export default { export default {
props: { props: {
url: String libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean
}, },
data() { data() {
return { return {
@@ -87,6 +87,15 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
ebookUrl() {
return `/api/items/${this.libraryItemId}/ebook`
},
comicMetadataKeys() { comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : [] return this.comicMetadata ? Object.keys(this.comicMetadata) : []
}, },
@@ -145,10 +154,11 @@ export default {
}, },
async extract() { async extract() {
this.loading = true this.loading = true
console.log('Extracting', this.url) var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob',
var buff = await this.$axios.$get(this.url, { headers: {
responseType: 'blob' Authorization: `Bearer ${this.userToken}`
}
}) })
const archive = await Archive.open(buff) const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject() const originalFilesObject = await archive.getFilesObject()
@@ -249,15 +259,6 @@ export default {
<style scoped> <style scoped>
.pagemenu { .pagemenu {
max-height: calc(100vh - 60px); max-height: calc(100% - 48px);
}
.comicimg {
height: calc(100vh - 40px);
margin: auto;
}
.comicwrapper {
width: 100vw;
height: calc(100vh - 40px);
margin-top: 20px;
} }
</style> </style>
+35 -13
View File
@@ -1,5 +1,5 @@
<template> <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 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"> <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> <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,31 @@ import ePub from 'epubjs'
*/ */
export default { export default {
props: { props: {
url: String,
libraryItem: { libraryItem: {
type: Object, type: Object,
default: () => {} default: () => {}
} },
playerOpen: Boolean
}, },
data() { data() {
return { return {
windowWidth: 0, windowWidth: 0,
windowHeight: 0,
/** @type {ePub.Book} */ /** @type {ePub.Book} */
book: null, book: null,
/** @type {ePub.Rendition} */ /** @type {ePub.Rendition} */
rendition: null rendition: null
} }
}, },
watch: {
playerOpen() {
this.resize()
}
},
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
/** @returns {string} */ /** @returns {string} */
libraryItemId() { libraryItemId() {
return this.libraryItem?.id return this.libraryItem?.id
@@ -64,6 +73,13 @@ export default {
readerWidth() { readerWidth() {
if (this.windowWidth < 640) return this.windowWidth if (this.windowWidth < 640) return this.windowWidth
return this.windowWidth - 200 return this.windowWidth - 200
},
readerHeight() {
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight
return this.windowHeight - 164
},
epubUrl() {
return `/api/items/${this.libraryItemId}/ebook`
} }
}, },
methods: { methods: {
@@ -201,22 +217,26 @@ export default {
const reader = this const reader = this
/** @type {ePub.Book} */ /** @type {ePub.Book} */
reader.book = new ePub(reader.url, { reader.book = new ePub(reader.epubUrl, {
width: this.readerWidth, width: this.readerWidth,
height: window.innerHeight - 50 height: this.readerHeight - 50,
openAs: 'epub',
requestHeaders: {
Authorization: `Bearer ${this.userToken}`
}
}) })
/** @type {ePub.Rendition} */ /** @type {ePub.Rendition} */
reader.rendition = reader.book.renderTo('viewer', { reader.rendition = reader.book.renderTo('viewer', {
width: this.readerWidth, width: this.readerWidth,
height: window.innerHeight * 0.8 height: this.readerHeight * 0.8
}) })
// load saved progress // load saved progress
reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start) reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
// load style // load style
reader.rendition.themes.default({ '*': { color: '#fff!important' } }) reader.rendition.themes.default({ '*': { color: '#fff!important', 'background-color': 'rgb(35 35 35)!important' } })
reader.book.ready.then(() => { reader.book.ready.then(() => {
// set up event listeners // set up event listeners
@@ -253,17 +273,19 @@ export default {
}, },
resize() { resize() {
this.windowWidth = window.innerWidth 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() { beforeDestroy() {
window.removeEventListener('resize', this.resize) window.removeEventListener('resize', this.resize)
this.book?.destroy() this.book?.destroy()
},
mounted() {
this.windowWidth = window.innerWidth
window.addEventListener('resize', this.resize)
this.initEpub()
} }
} }
</script> </script>
+22 -5
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<div class="h-full max-h-full w-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> <iframe title="html-viewer" width="100%"> Loading </iframe>
</div> </div>
</div> </div>
@@ -15,12 +15,26 @@ import defaultCss from '@/assets/ebooks/basic.js'
export default { export default {
props: { props: {
url: String libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean
}, },
data() { data() {
return {} return {}
}, },
computed: {}, computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
ebookUrl() {
return `/api/items/${this.libraryItemId}/ebook`
}
},
methods: { methods: {
addHtmlCss() { addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0] let iframe = document.getElementsByTagName('iframe')[0]
@@ -78,8 +92,11 @@ export default {
}, },
async initMobi() { async initMobi() {
// Fetch mobi file as blob // Fetch mobi file as blob
var buff = await this.$axios.$get(this.url, { var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob' responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
}) })
var reader = new FileReader() var reader = new FileReader()
reader.onload = async (event) => { reader.onload = async (event) => {
+94 -8
View File
@@ -11,15 +11,19 @@
</div> </div>
</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> <p class="font-mono">{{ page }} / {{ numPages }}</p>
</div> </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 :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
<div class="flex items-center justify-center"> <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> <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> </div>
</div> </div>
@@ -30,17 +34,24 @@
</template> </template>
<script> <script>
import pdf from 'vue-pdf' import pdf from '@teckel/vue-pdf'
export default { export default {
components: { components: {
pdf pdf
}, },
props: { props: {
url: String libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean
}, },
data() { data() {
return { return {
windowWidth: 0,
windowHeight: 0,
scale: 1,
rotate: 0, rotate: 0,
loadedRatio: 0, loadedRatio: 0,
page: 1, page: 1,
@@ -48,35 +59,110 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
fitToPageWidth() {
return this.pdfHeight * 0.6
},
pdfWidth() { pdfWidth() {
return this.pdfHeight * 0.6667 return this.fitToPageWidth * this.scale
}, },
pdfHeight() { 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() { canGoNext() {
return this.page < this.numPages return this.page < this.numPages
}, },
canGoPrev() { canGoPrev() {
return this.page > 1 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() {
return Number(this.userMediaProgress?.ebookLocation || 0)
},
pdfDocInitParams() {
return {
url: `/api/items/${this.libraryItemId}/ebook`,
httpHeaders: {
Authorization: `Bearer ${this.userToken}`
}
}
} }
}, },
methods: { methods: {
zoomIn() {
this.scale += 0.1
},
zoomOut() {
this.scale -= 0.1
},
updateProgress() {
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 && this.savedPage > 0 && this.savedPage <= this.numPages) {
this.page = this.savedPage
}
},
progressEvt(progress) {
this.loadedRatio = progress
},
numPagesLoaded(e) { numPagesLoaded(e) {
this.numPages = e this.numPages = e
}, },
prev() { prev() {
if (this.page <= 1) return if (this.page <= 1) return
this.page-- this.page--
this.updateProgress()
}, },
next() { next() {
if (this.page >= this.numPages) return if (this.page >= this.numPages) return
this.page++ this.page++
this.updateProgress()
}, },
error(err) { error(err) {
console.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> </script>
+24 -25
View File
@@ -1,5 +1,5 @@
<template> <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"> <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> <span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
</div> </div>
@@ -17,17 +17,22 @@
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span> <span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
</div> </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" />
<!-- TOC side nav --> <!-- 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="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 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 overflow-hidden"> <div class="p-4 h-full">
<p class="text-lg font-semibold mb-2">Table of Contents</p> <p class="text-lg font-semibold mb-2">Table of Contents</p>
<div class="tocContent"> <div class="tocContent">
<ul> <ul>
<li v-for="chapter in chapters" :key="chapter.id" class="py-1"> <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> </li>
</ul> </ul>
</div> </div>
@@ -67,6 +72,9 @@ export default {
else if (this.ebookType === 'comic') return 'readers-comic-reader' else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null return null
}, },
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
hasToC() { hasToC() {
return this.isEpub return this.isEpub
}, },
@@ -120,21 +128,6 @@ export default {
isComic() { isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr' 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() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
} }
@@ -146,7 +139,6 @@ export default {
}, },
openSettings() {}, openSettings() {},
hotkey(action) { hotkey(action) {
console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return if (!this.$refs.readerComponent) return
if (action === this.$hotkeys.EReader.NEXT_PAGE) { if (action === this.$hotkeys.EReader.NEXT_PAGE) {
@@ -187,12 +179,19 @@ export default {
</script> </script>
<style> <style>
/* @import url(@/assets/calibre/basic.css); */
.ebook-viewer {
height: calc(100% - 96px);
}
.tocContent { .tocContent {
height: calc(100% - 36px); height: calc(100% - 36px);
overflow-y: auto; 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> </style>
@@ -73,11 +73,11 @@ export default {
return items return items
}, },
downloadUrl() { 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: { methods: {
contextMenuAction(action) { contextMenuAction({ action }) {
if (action === 'delete') { if (action === 'delete') {
this.deleteLibraryFile() this.deleteLibraryFile()
} else if (action === 'download') { } else if (action === 'download') {
@@ -107,15 +107,7 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload) this.$store.commit('globals/setConfirmPrompt', payload)
}, },
downloadLibraryFile() { downloadLibraryFile() {
const a = document.createElement('a') this.$downloadFile(this.downloadUrl, this.track.metadata.filename)
a.style.display = 'none'
a.href = this.downloadUrl
a.download = this.track.metadata.filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
} }
}, },
mounted() {} mounted() {}
@@ -45,7 +45,7 @@ export default {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
downloadUrl() { 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() { contextMenuItems() {
const items = [] const items = []
@@ -72,7 +72,7 @@ export default {
} }
}, },
methods: { methods: {
contextMenuAction(action) { contextMenuAction({ action }) {
if (action === 'delete') { if (action === 'delete') {
this.deleteLibraryFile() this.deleteLibraryFile()
} else if (action === 'download') { } else if (action === 'download') {
@@ -102,15 +102,7 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload) this.$store.commit('globals/setConfirmPrompt', payload)
}, },
downloadLibraryFile() { downloadLibraryFile() {
const a = document.createElement('a') this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
a.style.display = 'none'
a.href = this.downloadUrl
a.download = this.file.metadata.filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
} }
}, },
mounted() {} mounted() {}
@@ -1,16 +1,18 @@
<template> <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 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="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
<div class="flex h-full items-center justify-center"> <div class="flex h-full items-center justify-center">
<span class="material-icons drag-handle text-lg md:text-xl">menu</span> <span class="material-icons drag-handle text-lg md:text-xl">menu</span>
</div> </div>
</div> </div>
<div class="h-full relative flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }"> <div class="h-full 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="relative" :style="{ height: coverHeight + 'px', minHeight: coverHeight + 'px', maxHeight: coverHeight + 'px' }">
<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"> <covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<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"> <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">
<span class="material-icons text-2xl">play_arrow</span> <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> </div>
</div> </div>
@@ -19,9 +21,12 @@
<div class="truncate max-w-48 md:max-w-md"> <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> <nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline text-sm md:text-base">{{ bookTitle }}</nuxt-link>
</div> </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"> <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"> <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> ><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">,&nbsp;</span>
</template> </template>
</div> </div>
@@ -96,6 +101,19 @@ export default {
bookDuration() { bookDuration() {
return this.$elapsedPretty(this.media.duration) 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() { isMissing() {
return this.book.isMissing return this.book.isMissing
}, },
@@ -117,6 +135,9 @@ export default {
coverSize() { coverSize() {
return this.$store.state.globals.isMobile ? 30 : 50 return this.$store.state.globals.isMobile ? 30 : 50
}, },
coverHeight() {
return this.coverSize * 1.6
},
coverWidth() { coverWidth() {
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6 if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
return this.coverSize return this.coverSize
@@ -94,7 +94,7 @@ export default {
} }
}, },
methods: { methods: {
contextMenuAction(action) { contextMenuAction({ action }) {
this.showMobileMenu = false this.showMobileMenu = false
if (action === 'edit') { if (action === 'edit') {
this.editClick() this.editClick()
@@ -21,7 +21,7 @@
</div> </div>
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300"> <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"> <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> ><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">,&nbsp;</span>
</template> </template>
<nuxt-link v-if="episode" :to="`/item/${libraryItem.id}`" class="truncate hover:underline">{{ mediaMetadata.title }}</nuxt-link> <nuxt-link v-if="episode" :to="`/item/${libraryItem.id}`" class="truncate hover:underline">{{ mediaMetadata.title }}</nuxt-link>
@@ -7,8 +7,7 @@
<widgets-podcast-type-indicator :type="episode.episodeType" /> <widgets-podcast-type-indicator :type="episode.episodeType" />
</div> </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"> <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.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> <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> <p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</button> </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-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-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick" />
</ui-tooltip> </ui-tooltip>
@@ -89,7 +84,7 @@ export default {
return this.episode.title || '' return this.episode.title || ''
}, },
subtitle() { subtitle() {
return this.episode.subtitle || '' return this.episode.subtitle || this.description
}, },
description() { description() {
return this.episode.description || '' return this.episode.description || ''
@@ -185,7 +185,7 @@ export default {
this.searchText = this.search.toLowerCase().trim() this.searchText = this.search.toLowerCase().trim()
}, 500) }, 500)
}, },
contextMenuAction(action) { contextMenuAction({ action }) {
if (action === 'quick-match-episodes') { if (action === 'quick-match-episodes') {
if (this.quickMatchingEpisodes) return if (this.quickMatchingEpisodes) return
+35 -5
View File
@@ -7,9 +7,19 @@
</slot> </slot>
<transition name="menu"> <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" 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 }">
<template v-for="(item, index) in items"> <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)"> <template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-default" @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 w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50 -ml-px" :style="{ left: menuWidth, top: index * 29 + 'px' }">
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" 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(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 hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p>{{ item.text }}</p> <p>{{ item.text }}</p>
</div> </div>
</template> </template>
@@ -42,11 +52,31 @@ export default {
events: ['mousedown'], events: ['mousedown'],
isActive: true isActive: true
}, },
showMenu: false showMenu: false,
mouseoverItemIndex: null,
isOverSubItemMenu: false
} }
}, },
computed: {}, computed: {},
methods: { 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() { clickShowMenu() {
if (this.disabled) return if (this.disabled) return
this.showMenu = !this.showMenu this.showMenu = !this.showMenu
@@ -54,10 +84,10 @@ export default {
clickedOutside() { clickedOutside() {
this.showMenu = false this.showMenu = false
}, },
clickAction(action) { clickAction(action, data) {
if (this.disabled) return if (this.disabled) return
this.showMenu = false this.showMenu = false
this.$emit('action', action) this.$emit('action', { action, data })
} }
}, },
mounted() {} mounted() {}
-61
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>
+37 -4
View File
@@ -1,7 +1,17 @@
<template> <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 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">
<template v-for="(item, index) in items"> <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 hover:bg-opacity-5 text-white text-xs cursor-default" @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 w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" :style="{ left: 143 + 'px', top: index * 28 + 'px' }">
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" 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(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 hover:bg-opacity-5 text-white text-xs cursor-pointer" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop="clickAction(item.func)">
<p>{{ item.text }}</p> <p>{{ item.text }}</p>
</div> </div>
</template> </template>
@@ -22,13 +32,36 @@ export default {
handler: this.clickedOutside, handler: this.clickedOutside,
events: ['mousedown'], events: ['mousedown'],
isActive: true isActive: true
} },
mouseoverItemIndex: null,
isOverSubItemMenu: false
} }
}, },
computed: {}, computed: {},
methods: { methods: {
clickAction(func) { mouseoverSubItemMenu(index) {
this.$emit('action', func) 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() this.close()
}, },
clickedOutside(e) { clickedOutside(e) {
@@ -1,19 +1,21 @@
<template> <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"> <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"> <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 /> <widgets-loading-spinner />
</ui-tooltip> </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> </div>
</button> </button>
<transition name="menu"> <transition name="menu">
<div class="sm:w-80 w-full relative"> <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"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-if="tasksRunningOrFailed.length"> <template v-if="tasksToShow.length">
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelTasks }}</p> <template v-for="task in tasksToShow">
<template v-for="task in tasksRunningOrFailed">
<nuxt-link :key="task.id" v-if="actionLink(task)" :to="actionLink(task)"> <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"> <li class="text-gray-50 select-none relative hover:bg-black-400 py-1 cursor-pointer">
<cards-item-task-running-card :task="task" /> <cards-item-task-running-card :task="task" />
@@ -54,9 +56,10 @@ export default {
tasksRunning() { tasksRunning() {
return this.tasks.some((t) => !t.isFinished) return this.tasks.some((t) => !t.isFinished)
}, },
tasksRunningOrFailed() { tasksToShow() {
// return just the tasks that are running or failed in the last 1 minute // return just the tasks that are running or failed (or show success) in the last 1 minute
return this.tasks.filter((t) => !t.isFinished || (t.isFailed && t.finishedAt > new Date().getTime() - 1000 * 60)) || [] 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: { methods: {
@@ -75,6 +78,8 @@ export default {
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b` return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
case 'embed-metadata': case 'embed-metadata':
return `/audiobook/${task.data.libraryItemId}/manage?tool=embed` return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`
case 'scan-item':
return `/item/${task.data.libraryItemId}`
default: default:
return '' return ''
} }
+26 -3
View File
@@ -35,7 +35,9 @@ export default {
isSocketConnected: false, isSocketConnected: false,
isFirstSocketConnection: true, isFirstSocketConnection: true,
socketConnectionToastId: null, 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: { watch: {
@@ -300,14 +302,27 @@ export default {
this.$store.commit('users/updateUserOnline', user) this.$store.commit('users/updateUserOnline', user)
}, },
userSessionClosed(sessionId) { 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) if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
}, },
userMediaProgressUpdate(payload) { userMediaProgressUpdate(payload) {
this.$store.commit('user/updateMediaProgress', payload) this.$store.commit('user/updateMediaProgress', payload)
if (payload.data) { if (payload.data) {
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId)) { if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId) && this.$store.state.playbackSessionId !== payload.sessionId) {
// TODO: Update currently open session if being played from another device 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) { adminMessageEvt(message) {
this.$toast.info(message) this.$toast.info(message)
}, },
ereaderDevicesUpdated(data) {
if (!data?.ereaderDevices) return
this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices)
},
initializeSocket() { initializeSocket() {
this.socket = this.$nuxtSocket({ this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@@ -437,6 +457,9 @@ export default {
this.socket.on('task_finished', this.taskFinished) this.socket.on('task_finished', this.taskFinished)
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate) 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('backup_applied', this.backupApplied)
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete) this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
+2 -3
View File
@@ -71,9 +71,8 @@ module.exports = {
], ],
proxy: { proxy: {
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } }, '/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }, '/api/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' + process.env : '/' },
}, },
io: { io: {
+73 -73
View File
@@ -1,16 +1,17 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.20", "version": "2.2.22",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.20", "version": "2.2.22",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
"@nuxtjs/proxy": "^2.1.0", "@nuxtjs/proxy": "^2.1.0",
"@teckel/vue-pdf": "^4.3.5",
"core-js": "^3.16.0", "core-js": "^3.16.0",
"cron-parser": "^4.7.1", "cron-parser": "^4.7.1",
"date-fns": "^2.25.0", "date-fns": "^2.25.0",
@@ -21,7 +22,6 @@
"nuxt-socket-io": "^1.1.18", "nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1", "trix": "^1.3.1",
"v-click-outside": "^3.1.2", "v-click-outside": "^3.1.2",
"vue-pdf": "^4.2.0",
"vue-toastification": "^1.7.11", "vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3" "vuedraggable": "^2.24.3"
}, },
@@ -2983,6 +2983,43 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" "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": { "node_modules/@types/anymatch": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g==" "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": { "node_modules/vue-resize-sensor": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" "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": { "@types/anymatch": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g==" "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": { "vue-resize-sensor": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz", "resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.20", "version": "2.2.22",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
"@nuxtjs/proxy": "^2.1.0", "@nuxtjs/proxy": "^2.1.0",
"@teckel/vue-pdf": "^4.3.5",
"core-js": "^3.16.0", "core-js": "^3.16.0",
"cron-parser": "^4.7.1", "cron-parser": "^4.7.1",
"date-fns": "^2.25.0", "date-fns": "^2.25.0",
@@ -25,7 +26,6 @@
"nuxt-socket-io": "^1.1.18", "nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1", "trix": "^1.3.1",
"v-click-outside": "^3.1.2", "v-click-outside": "^3.1.2",
"vue-pdf": "^4.2.0",
"vue-toastification": "^1.7.11", "vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3" "vuedraggable": "^2.24.3"
}, },
+6 -2
View File
@@ -43,8 +43,8 @@
<script> <script>
export default { export default {
async asyncData({ store, app, params, redirect }) { async asyncData({ store, app, params, redirect, query }) {
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => { 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) console.error('Failed to get author', error)
return null return null
}) })
@@ -53,6 +53,10 @@ export default {
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`) return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
} }
if (query.library) {
store.commit('libraries/setCurrentLibrary', query.library)
}
return { return {
author author
} }
+1 -1
View File
@@ -145,7 +145,7 @@ export default {
feed: this.rssFeed feed: this.rssFeed
}) })
}, },
contextMenuAction(action) { contextMenuAction({ action }) {
if (action === 'delete') { if (action === 'delete') {
this.removeClick() this.removeClick()
} else if (action === 'create-playlist') { } else if (action === 'create-playlist') {
+2 -12
View File
@@ -4,7 +4,7 @@
<div class="configContent" :class="`page-${currentPage}`"> <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"> <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> <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> </div>
<nuxt-child /> <nuxt-child />
</div> </div>
@@ -55,6 +55,7 @@ export default {
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
else if (pageName === 'users') return this.$strings.HeaderUsers else if (pageName === 'users') return this.$strings.HeaderUsers
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
else if (pageName === 'email') return this.$strings.HeaderEmail
} }
return this.$strings.HeaderSettings return this.$strings.HeaderSettings
} }
@@ -79,14 +80,6 @@ export default {
width: 900px; width: 900px;
max-width: calc(100% - 176px); 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) { @media (max-width: 1240px) {
.configContent { .configContent {
margin-left: 176px; margin-left: 176px;
@@ -98,8 +91,5 @@ export default {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
} }
.configContent.page-library-stats {
margin-left: 0px;
}
} }
</style> </style>
+244
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>
+21 -4
View File
@@ -39,11 +39,15 @@
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="updateSortingPrefixes" :disabled="updatingServerSettings" /> <ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
</div> </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)" /> <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> <p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div> </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"> <div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsDisplay }}</h2> <h2 class="font-semibold">{{ $strings.HeaderSettingsDisplay }}</h2>
</div> </div>
@@ -272,7 +276,17 @@ export default {
useBookshelfView: false, useBookshelfView: false,
isPurgingCache: false, isPurgingCache: false,
newServerSettings: {}, newServerSettings: {},
showConfirmPurgeCache: false showConfirmPurgeCache: false,
metadataFileFormats: [
{
text: '.json',
value: 'json'
},
{
text: '.abs',
value: 'abs'
}
]
} }
}, },
watch: { watch: {
@@ -341,6 +355,10 @@ export default {
updateServerLanguage(val) { updateServerLanguage(val) {
this.updateSettingsKey('language', val) this.updateSettingsKey('language', val)
}, },
updateMetadataFileFormat(val) {
if (this.serverSettings.metadataFileFormat === val) return
this.updateSettingsKey('metadataFileFormat', val)
},
updateSettingsKey(key, val) { updateSettingsKey(key, val) {
this.updateServerSettings({ this.updateServerSettings({
[key]: val [key]: val
@@ -350,8 +368,7 @@ export default {
this.updatingServerSettings = true this.updatingServerSettings = true
this.$store this.$store
.dispatch('updateServerSettings', payload) .dispatch('updateServerSettings', payload)
.then((success) => { .then(() => {
console.log('Updated Server Settings', success)
this.updatingServerSettings = false this.updatingServerSettings = false
this.$toast.success('Server settings updated') this.$toast.success('Server settings updated')
+48 -11
View File
@@ -42,7 +42,7 @@
<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> <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>
<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"> <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>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p> <p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
</template> </template>
@@ -431,6 +431,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) { if (this.userCanDelete) {
items.push({ items.push({
text: this.$strings.ButtonDelete, text: this.$strings.ButtonDelete,
@@ -602,10 +615,11 @@ export default {
} }
}, },
clearProgressClick() { clearProgressClick() {
if (!this.userMediaProgress) return
if (confirm(`Are you sure you want to reset your progress?`)) { if (confirm(`Are you sure you want to reset your progress?`)) {
this.resettingProgress = true this.resettingProgress = true
this.$axios this.$axios
.$delete(`/api/me/progress/${this.libraryItemId}`) .$delete(`/api/me/progress/${this.userMediaProgress.id}`)
.then(() => { .then(() => {
console.log('Progress reset complete') console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`) this.$toast.success(`Your progress was reset`)
@@ -676,14 +690,7 @@ export default {
} }
}, },
downloadLibraryItem() { downloadLibraryItem() {
const a = document.createElement('a') this.$downloadFile(this.downloadUrl)
a.style.display = 'none'
a.href = this.downloadUrl
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
}, },
deleteLibraryItem() { deleteLibraryItem() {
const payload = { const payload = {
@@ -710,7 +717,35 @@ export default {
} }
this.$store.commit('globals/setConfirmPrompt', payload) 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') { if (action === 'collections') {
this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowCollectionsModal', true) this.$store.commit('globals/setShowCollectionsModal', true)
@@ -725,6 +760,8 @@ export default {
this.downloadLibraryItem() this.downloadLibraryItem()
} else if (action === 'delete') { } else if (action === 'delete') {
this.deleteLibraryItem() this.deleteLibraryItem()
} else if (action === 'sendToDevice') {
this.sendToDevice(data)
} }
} }
}, },
@@ -45,7 +45,7 @@
<widgets-podcast-type-indicator :type="episode.episodeType" /> <widgets-podcast-type-indicator :type="episode.episodeType" />
</div> </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"> <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)"> <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)">
+3 -2
View File
@@ -107,7 +107,7 @@ export default {
const payload = { const payload = {
newRoot: { ...this.newRoot } newRoot: { ...this.newRoot }
} }
var success = await this.$axios const success = await this.$axios
.$post('/init', payload) .$post('/init', payload)
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
@@ -124,9 +124,10 @@ export default {
location.reload() location.reload()
}, },
setUser({ user, userDefaultLibraryId, serverSettings, Source, feeds }) { setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) {
this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source) this.$store.commit('setSource', Source)
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
this.$setServerLanguageCode(serverSettings.language) this.$setServerLanguageCode(serverSettings.language)
if (serverSettings.chromecastEnabled) { if (serverSettings.chromecastEnabled) {
+31 -7
View File
@@ -78,6 +78,7 @@
</template> </template>
<script> <script>
import Path from 'path'
import uploadHelpers from '@/mixins/uploadHelpers' import uploadHelpers from '@/mixins/uploadHelpers'
export default { export default {
@@ -243,7 +244,7 @@ export default {
ref.setUploadStatus(status) ref.setUploadStatus(status)
} }
}, },
uploadItem(item) { async uploadItem(item) {
var form = new FormData() var form = new FormData()
form.set('title', item.title) form.set('title', item.title)
if (!this.selectedLibraryIsPodcast) { if (!this.selectedLibraryIsPodcast) {
@@ -294,18 +295,41 @@ export default {
return return
} }
var items = this.validateItems() const items = this.validateItems()
if (!items) { if (!items) {
this.$toast.error('Some invalid items') this.$toast.error('Some invalid items')
return return
} }
this.processing = true this.processing = true
var itemsUploaded = 0
var itemsFailed = 0 const itemsToUpload = []
for (let i = 0; i < items.length; i++) {
var item = items[i] // 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') this.updateItemCardStatus(item.index, 'uploading')
var result = await this.uploadItem(item) const result = await this.uploadItem(item)
if (result) itemsUploaded++ if (result) itemsUploaded++
else itemsFailed++ else itemsFailed++
this.updateItemCardStatus(item.index, result ? 'success' : 'failed') this.updateItemCardStatus(item.index, result ? 'success' : 'failed')
+23 -11
View File
@@ -24,7 +24,6 @@ export default class PlayerHandler {
this.failedProgressSyncs = 0 this.failedProgressSyncs = 0
this.lastSyncTime = 0 this.lastSyncTime = 0
this.lastSyncedAt = 0
this.listeningTimeSinceSync = 0 this.listeningTimeSinceSync = 0
this.playInterval = null this.playInterval = null
@@ -53,6 +52,11 @@ export default class PlayerHandler {
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId) 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) { load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
this.libraryItem = libraryItem this.libraryItem = libraryItem
this.isVideo = libraryItem.mediaType === 'video' this.isVideo = libraryItem.mediaType === 'video'
@@ -183,7 +187,7 @@ export default class PlayerHandler {
} }
async prepare(forceTranscode = false) { async prepare(forceTranscode = false) {
this.currentSessionId = null // Reset session this.setSessionId(null) // Reset session
const payload = { const payload = {
deviceInfo: { deviceInfo: {
@@ -210,6 +214,8 @@ export default class PlayerHandler {
this.playWhenReady = false this.playWhenReady = false
this.initialPlaybackRate = playbackRate this.initialPlaybackRate = playbackRate
this.startTimeOverride = undefined this.startTimeOverride = undefined
this.lastSyncTime = 0
this.listeningTimeSinceSync = 0
this.prepareSession(session) this.prepareSession(session)
} }
@@ -217,7 +223,7 @@ export default class PlayerHandler {
prepareSession(session) { prepareSession(session) {
this.failedProgressSyncs = 0 this.failedProgressSyncs = 0
this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime
this.currentSessionId = session.id this.setSessionId(session.id)
this.displayTitle = session.displayTitle this.displayTitle = session.displayTitle
this.displayAuthor = session.displayAuthor this.displayAuthor = session.displayAuthor
@@ -262,7 +268,7 @@ export default class PlayerHandler {
this.player = null this.player = null
this.playerState = 'IDLE' this.playerState = 'IDLE'
this.libraryItem = null this.libraryItem = null
this.currentSessionId = null this.setSessionId(null)
this.startTime = 0 this.startTime = 0
this.stopPlayInterval() this.stopPlayInterval()
} }
@@ -287,7 +293,8 @@ export default class PlayerHandler {
const exactTimeElapsed = ((Date.now() - lastTick) / 1000) const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
lastTick = Date.now() lastTick = Date.now()
this.listeningTimeSinceSync += exactTimeElapsed this.listeningTimeSinceSync += exactTimeElapsed
if (this.listeningTimeSinceSync >= 5) { const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 5 : 20
if (this.listeningTimeSinceSync >= TimeToWaitBeforeSync) {
this.sendProgressSync(currentTime) this.sendProgressSync(currentTime)
} }
}, 1000) }, 1000)
@@ -297,13 +304,17 @@ export default class PlayerHandler {
let syncData = null let syncData = null
if (this.player) { if (this.player) {
const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync)) const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
syncData = { // When opening player and quickly closing dont save progress
timeListened: listeningTimeToAdd, if (listeningTimeToAdd > 20) {
duration: this.getDuration(), syncData = {
currentTime: this.getCurrentTime() timeListened: listeningTimeToAdd,
duration: this.getDuration(),
currentTime: this.getCurrentTime()
}
} }
} }
this.listeningTimeSinceSync = 0 this.listeningTimeSinceSync = 0
this.lastSyncTime = 0
return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 1000 }).catch((error) => { return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 1000 }).catch((error) => {
console.error('Failed to close session', error) console.error('Failed to close session', error)
}) })
@@ -322,6 +333,7 @@ export default class PlayerHandler {
duration: this.getDuration(), duration: this.getDuration(),
currentTime currentTime
} }
this.listeningTimeSinceSync = 0 this.listeningTimeSinceSync = 0
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 3000 }).then(() => { this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 3000 }).then(() => {
this.failedProgressSyncs = 0 this.failedProgressSyncs = 0
@@ -383,13 +395,13 @@ export default class PlayerHandler {
this.player.setPlaybackRate(playbackRate) this.player.setPlaybackRate(playbackRate)
} }
seek(time) { seek(time, shouldSync = true) {
if (!this.player) return if (!this.player) return
this.player.seek(time, this.playerPlaying) this.player.seek(time, this.playerPlaying)
this.ctx.setCurrentTime(time) this.ctx.setCurrentTime(time)
// Update progress if paused // Update progress if paused
if (!this.playerPlaying) { if (!this.playerPlaying && shouldSync) {
this.sendProgressSync(time) this.sendProgressSync(time)
} }
} }
+1 -6
View File
@@ -7,15 +7,10 @@ export default function ({ $axios, store, $config }) {
if (config.url.startsWith('http:') || config.url.startsWith('https:')) { if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
return return
} }
var bearerToken = store.state.user.user ? store.state.user.user.token : null const bearerToken = store.state.user.user?.token || null
if (bearerToken) { if (bearerToken) {
config.headers.common['Authorization'] = `Bearer ${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 => { $axios.onError(error => {
+1 -1
View File
@@ -4,7 +4,7 @@ const SupportedFileTypes = {
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'], info: ['nfo'],
text: ['txt'], text: ['txt'],
metadata: ['opf', 'abs'] metadata: ['opf', 'abs', 'xml', 'json']
} }
const DownloadStatus = { const DownloadStatus = {
+19
View File
@@ -145,6 +145,25 @@ Vue.prototype.$getNextScheduledDate = (expression) => {
return interval.next().toDate() 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) { export function supplant(str, subs) {
// source: http://crockford.com/javascript/remedial.html // source: http://crockford.com/javascript/remedial.html
return str.replace(/{([^{}]*)}/g, return str.replace(/{([^{}]*)}/g,
+5 -1
View File
@@ -6,6 +6,7 @@ export const state = () => ({
Source: null, Source: null,
versionData: null, versionData: null,
serverSettings: null, serverSettings: null,
playbackSessionId: null,
streamLibraryItem: null, streamLibraryItem: null,
streamEpisodeId: null, streamEpisodeId: null,
streamIsPlaying: false, streamIsPlaying: false,
@@ -35,7 +36,7 @@ export const getters = {
return state.serverSettings[key] return state.serverSettings[key]
}, },
getLibraryItemIdStreaming: state => { getLibraryItemIdStreaming: state => {
return state.streamLibraryItem ? state.streamLibraryItem.id : null return state.streamLibraryItem?.id || null
}, },
getIsStreamingFromDifferentLibrary: (state, getters, rootState) => { getIsStreamingFromDifferentLibrary: (state, getters, rootState) => {
if (!state.streamLibraryItem) return false if (!state.streamLibraryItem) return false
@@ -150,6 +151,9 @@ export const mutations = {
if (!settings) return if (!settings) return
state.serverSettings = settings state.serverSettings = settings
}, },
setPlaybackSessionId(state, playbackSessionId) {
state.playbackSessionId = playbackSessionId
},
setMediaPlaying(state, payload) { setMediaPlaying(state, payload) {
if (!payload) { if (!payload) {
state.streamLibraryItem = null state.streamLibraryItem = null
+5 -1
View File
@@ -11,7 +11,8 @@ export const state = () => ({
filterData: null, filterData: null,
numUserPlaylists: 0, numUserPlaylists: 0,
collections: [], collections: [],
userPlaylists: [] userPlaylists: [],
ereaderDevices: []
}) })
export const getters = { export const getters = {
@@ -339,5 +340,8 @@ export const mutations = {
removeUserPlaylist(state, playlist) { removeUserPlaylist(state, playlist) {
state.userPlaylists = state.userPlaylists.filter(p => p.id !== playlist.id) state.userPlaylists = state.userPlaylists.filter(p => p.id !== playlist.id)
state.numUserPlaylists = state.userPlaylists.length state.numUserPlaylists = state.userPlaylists.length
},
setEReaderDevices(state, ereaderDevices) {
state.ereaderDevices = ereaderDevices
} }
} }
+1 -1
View File
@@ -19,7 +19,7 @@ export const getters = {
getIsRoot: (state) => state.user && state.user.type === 'root', getIsRoot: (state) => state.user && state.user.type === 'root',
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'), getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
getToken: (state) => { getToken: (state) => {
return state.user ? state.user.token : null return state.user?.token || null
}, },
getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => { getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => {
if (!state.user.mediaProgress) return null if (!state.user.mediaProgress) return null
+19 -1
View File
@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Alles löschen", "ButtonRemoveAll": "Alles löschen",
"ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge", "ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge",
"ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste", "ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste", "ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste",
"ButtonReScan": "Neu scannen", "ButtonReScan": "Neu scannen",
"ButtonReset": "Zurücksetzen", "ButtonReset": "Zurücksetzen",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "M4B-Kodierung starten", "ButtonStartM4BEncode": "M4B-Kodierung starten",
"ButtonStartMetadataEmbed": "Metadateneinbettung starten", "ButtonStartMetadataEmbed": "Metadateneinbettung starten",
"ButtonSubmit": "Ok", "ButtonSubmit": "Ok",
"ButtonTest": "Test",
"ButtonUpload": "Hochladen", "ButtonUpload": "Hochladen",
"ButtonUploadBackup": "Sicherung hochladen", "ButtonUploadBackup": "Sicherung hochladen",
"ButtonUploadCover": "Titelbild hochladen", "ButtonUploadCover": "Titelbild hochladen",
@@ -96,7 +98,10 @@
"HeaderCurrentDownloads": "Aktuelle Downloads", "HeaderCurrentDownloads": "Aktuelle Downloads",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Warteschlange", "HeaderDownloadQueue": "Download Warteschlange",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episoden", "HeaderEpisodes": "Episoden",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Dateien", "HeaderFiles": "Dateien",
"HeaderFindChapters": "Kapitel suchen", "HeaderFindChapters": "Kapitel suchen",
"HeaderIgnoredFiles": "Ignorierte Dateien", "HeaderIgnoredFiles": "Ignorierte Dateien",
@@ -197,6 +202,7 @@
"LabelComplete": "Vollständig", "LabelComplete": "Vollständig",
"LabelConfirmPassword": "Passwort bestätigen", "LabelConfirmPassword": "Passwort bestätigen",
"LabelContinueListening": "Weiterhören", "LabelContinueListening": "Weiterhören",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Serien fortsetzen", "LabelContinueSeries": "Serien fortsetzen",
"LabelCover": "Titelbild", "LabelCover": "Titelbild",
"LabelCoverImageURL": "URL des Titelbildes", "LabelCoverImageURL": "URL des Titelbildes",
@@ -216,7 +222,12 @@
"LabelDownload": "Herunterladen", "LabelDownload": "Herunterladen",
"LabelDuration": "Laufzeit", "LabelDuration": "Laufzeit",
"LabelDurationFound": "Gefundene Laufzeit:", "LabelDurationFound": "Gefundene Laufzeit:",
"LabelEbook": "Ebook",
"LabelEdit": "Bearbeiten", "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", "LabelEmbeddedCover": "Eingebettetes Cover",
"LabelEnable": "Aktivieren", "LabelEnable": "Aktivieren",
"LabelEnd": "Ende", "LabelEnd": "Ende",
@@ -239,6 +250,7 @@
"LabelGenre": "Kategorie", "LabelGenre": "Kategorie",
"LabelGenres": "Kategorien", "LabelGenres": "Kategorien",
"LabelHardDeleteFile": "Datei dauerhaft löschen", "LabelHardDeleteFile": "Datei dauerhaft löschen",
"LabelHost": "Host",
"LabelHour": "Stunde", "LabelHour": "Stunde",
"LabelIcon": "Symbol", "LabelIcon": "Symbol",
"LabelIncludeInTracklist": "In die Titelliste aufnehmen", "LabelIncludeInTracklist": "In die Titelliste aufnehmen",
@@ -324,6 +336,7 @@
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Typ", "LabelPodcastType": "Podcast Typ",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)", "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", "LabelPreventIndexing": "Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird",
"LabelProgress": "Fortschritt", "LabelProgress": "Fortschritt",
@@ -331,6 +344,7 @@
"LabelPubDate": "Veröffentlichungsdatum", "LabelPubDate": "Veröffentlichungsdatum",
"LabelPublisher": "Herausgeber", "LabelPublisher": "Herausgeber",
"LabelPublishYear": "Jahr", "LabelPublishYear": "Jahr",
"LabelReadAgain": "Read Again",
"LabelRecentlyAdded": "Kürzlich hinzugefügt", "LabelRecentlyAdded": "Kürzlich hinzugefügt",
"LabelRecentSeries": "Aktuelle Serien", "LabelRecentSeries": "Aktuelle Serien",
"LabelRecommended": "Empfohlen", "LabelRecommended": "Empfohlen",
@@ -347,6 +361,7 @@
"LabelSearchTitle": "Titel", "LabelSearchTitle": "Titel",
"LabelSearchTitleOrASIN": "Titel oder ASIN", "LabelSearchTitleOrASIN": "Titel oder ASIN",
"LabelSeason": "Staffel", "LabelSeason": "Staffel",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Reihenfolge", "LabelSequence": "Reihenfolge",
"LabelSeries": "Serien", "LabelSeries": "Serien",
"LabelSeriesName": "Serienname", "LabelSeriesName": "Serienname",
@@ -363,7 +378,7 @@
"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.", "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", "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", "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", "LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht",
"LabelSettingsOverdriveMediaMarkers": "Verwende Overdrive Media Marker für Kapitel", "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", "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",
@@ -491,6 +506,7 @@
"MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?", "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.", "MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.",
"MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".", "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", "MessageDownloadingEpisode": "Episode herunterladen",
"MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge", "MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge",
"MessageEmbedFinished": "Einbettung abgeschlossen!", "MessageEmbedFinished": "Einbettung abgeschlossen!",
@@ -645,6 +661,8 @@
"ToastRemoveItemFromCollectionSuccess": "Medium aus der Sammlung gelöscht", "ToastRemoveItemFromCollectionSuccess": "Medium aus der Sammlung gelöscht",
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden", "ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen", "ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
"ToastSendEbookToDeviceFailed": "Failed to send ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Aktualisierung der Serien fehlgeschlagen", "ToastSeriesUpdateFailed": "Aktualisierung der Serien fehlgeschlagen",
"ToastSeriesUpdateSuccess": "Serien aktualisiert", "ToastSeriesUpdateSuccess": "Serien aktualisiert",
"ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden", "ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden",
+18
View File
@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Remove All", "ButtonRemoveAll": "Remove All",
"ButtonRemoveAllLibraryItems": "Remove All Library Items", "ButtonRemoveAllLibraryItems": "Remove All Library Items",
"ButtonRemoveFromContinueListening": "Remove from Continue Listening", "ButtonRemoveFromContinueListening": "Remove from Continue Listening",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Remove Series from Continue Series", "ButtonRemoveSeriesFromContinueSeries": "Remove Series from Continue Series",
"ButtonReScan": "Re-Scan", "ButtonReScan": "Re-Scan",
"ButtonReset": "Reset", "ButtonReset": "Reset",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Start M4B Encode", "ButtonStartM4BEncode": "Start M4B Encode",
"ButtonStartMetadataEmbed": "Start Metadata Embed", "ButtonStartMetadataEmbed": "Start Metadata Embed",
"ButtonSubmit": "Submit", "ButtonSubmit": "Submit",
"ButtonTest": "Test",
"ButtonUpload": "Upload", "ButtonUpload": "Upload",
"ButtonUploadBackup": "Upload Backup", "ButtonUploadBackup": "Upload Backup",
"ButtonUploadCover": "Upload Cover", "ButtonUploadCover": "Upload Cover",
@@ -96,7 +98,10 @@
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodes", "HeaderEpisodes": "Episodes",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Files", "HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters", "HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files", "HeaderIgnoredFiles": "Ignored Files",
@@ -197,6 +202,7 @@
"LabelComplete": "Complete", "LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password", "LabelConfirmPassword": "Confirm Password",
"LabelContinueListening": "Continue Listening", "LabelContinueListening": "Continue Listening",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Continue Series", "LabelContinueSeries": "Continue Series",
"LabelCover": "Cover", "LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL", "LabelCoverImageURL": "Cover Image URL",
@@ -216,7 +222,12 @@
"LabelDownload": "Download", "LabelDownload": "Download",
"LabelDuration": "Duration", "LabelDuration": "Duration",
"LabelDurationFound": "Duration found:", "LabelDurationFound": "Duration found:",
"LabelEbook": "Ebook",
"LabelEdit": "Edit", "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", "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable", "LabelEnable": "Enable",
"LabelEnd": "End", "LabelEnd": "End",
@@ -239,6 +250,7 @@
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file", "LabelHardDeleteFile": "Hard delete file",
"LabelHost": "Host",
"LabelHour": "Hour", "LabelHour": "Hour",
"LabelIcon": "Icon", "LabelIcon": "Icon",
"LabelIncludeInTracklist": "Include in Tracklist", "LabelIncludeInTracklist": "Include in Tracklist",
@@ -324,6 +336,7 @@
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type", "LabelPodcastType": "Podcast Type",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)", "LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories", "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Progress", "LabelProgress": "Progress",
@@ -331,6 +344,7 @@
"LabelPubDate": "Pub Date", "LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher", "LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year", "LabelPublishYear": "Publish Year",
"LabelReadAgain": "Read Again",
"LabelRecentlyAdded": "Recently Added", "LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series", "LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended", "LabelRecommended": "Recommended",
@@ -347,6 +361,7 @@
"LabelSearchTitle": "Search Title", "LabelSearchTitle": "Search Title",
"LabelSearchTitleOrASIN": "Search Title or ASIN", "LabelSearchTitleOrASIN": "Search Title or ASIN",
"LabelSeason": "Season", "LabelSeason": "Season",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequence", "LabelSequence": "Sequence",
"LabelSeries": "Series", "LabelSeries": "Series",
"LabelSeriesName": "Series Name", "LabelSeriesName": "Series Name",
@@ -491,6 +506,7 @@
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "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.", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", "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", "MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!", "MessageEmbedFinished": "Embed Finished!",
@@ -645,6 +661,8 @@
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection", "ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed", "ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed", "ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSendEbookToDeviceFailed": "Failed to send ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Series update failed", "ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success", "ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session", "ToastSessionDeleteFailed": "Failed to delete session",
+18
View File
@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Remover Todos", "ButtonRemoveAll": "Remover Todos",
"ButtonRemoveAllLibraryItems": "Remover Todos los Elementos de la Biblioteca", "ButtonRemoveAllLibraryItems": "Remover Todos los Elementos de la Biblioteca",
"ButtonRemoveFromContinueListening": "Remover de Continuar Escuchando", "ButtonRemoveFromContinueListening": "Remover de Continuar Escuchando",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Remover Serie de Continuar Series", "ButtonRemoveSeriesFromContinueSeries": "Remover Serie de Continuar Series",
"ButtonReScan": "Re-Escanear", "ButtonReScan": "Re-Escanear",
"ButtonReset": "Reiniciar", "ButtonReset": "Reiniciar",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Iniciar Codificación M4B", "ButtonStartM4BEncode": "Iniciar Codificación M4B",
"ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata", "ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata",
"ButtonSubmit": "Enviar", "ButtonSubmit": "Enviar",
"ButtonTest": "Test",
"ButtonUpload": "Subir", "ButtonUpload": "Subir",
"ButtonUploadBackup": "Subir Respaldo", "ButtonUploadBackup": "Subir Respaldo",
"ButtonUploadCover": "Subir Portada", "ButtonUploadCover": "Subir Portada",
@@ -96,7 +98,10 @@
"HeaderCurrentDownloads": "Descargando Actualmente", "HeaderCurrentDownloads": "Descargando Actualmente",
"HeaderDetails": "Detalles", "HeaderDetails": "Detalles",
"HeaderDownloadQueue": "Lista de Descarga", "HeaderDownloadQueue": "Lista de Descarga",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodios", "HeaderEpisodes": "Episodios",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Elemento", "HeaderFiles": "Elemento",
"HeaderFindChapters": "Buscar Capitulo", "HeaderFindChapters": "Buscar Capitulo",
"HeaderIgnoredFiles": "Ignorar Elemento", "HeaderIgnoredFiles": "Ignorar Elemento",
@@ -197,6 +202,7 @@
"LabelComplete": "Completo", "LabelComplete": "Completo",
"LabelConfirmPassword": "Confirmar Contraseña", "LabelConfirmPassword": "Confirmar Contraseña",
"LabelContinueListening": "Continuar Escuchando", "LabelContinueListening": "Continuar Escuchando",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Continuar Series", "LabelContinueSeries": "Continuar Series",
"LabelCover": "Portada", "LabelCover": "Portada",
"LabelCoverImageURL": "URL de Imagen de Portada", "LabelCoverImageURL": "URL de Imagen de Portada",
@@ -216,7 +222,12 @@
"LabelDownload": "Descargar", "LabelDownload": "Descargar",
"LabelDuration": "Duración", "LabelDuration": "Duración",
"LabelDurationFound": "Duración Comprobada:", "LabelDurationFound": "Duración Comprobada:",
"LabelEbook": "Ebook",
"LabelEdit": "Editar", "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", "LabelEmbeddedCover": "Portada Integrada",
"LabelEnable": "Habilitar", "LabelEnable": "Habilitar",
"LabelEnd": "Fin", "LabelEnd": "Fin",
@@ -239,6 +250,7 @@
"LabelGenre": "Genero", "LabelGenre": "Genero",
"LabelGenres": "Géneros", "LabelGenres": "Géneros",
"LabelHardDeleteFile": "Eliminar Definitivamente", "LabelHardDeleteFile": "Eliminar Definitivamente",
"LabelHost": "Host",
"LabelHour": "Hora", "LabelHour": "Hora",
"LabelIcon": "Icono", "LabelIcon": "Icono",
"LabelIncludeInTracklist": "Incluir en Tracklist", "LabelIncludeInTracklist": "Incluir en Tracklist",
@@ -324,6 +336,7 @@
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Tipo Podcast", "LabelPodcastType": "Tipo Podcast",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefijos para Ignorar (no distingue entre mayúsculas y minúsculas.)", "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", "LabelPreventIndexing": "Evite que su fuente sea indexado por iTunes y Google podcast directories",
"LabelProgress": "Progreso", "LabelProgress": "Progreso",
@@ -331,6 +344,7 @@
"LabelPubDate": "Fecha de Publicación", "LabelPubDate": "Fecha de Publicación",
"LabelPublisher": "Editor", "LabelPublisher": "Editor",
"LabelPublishYear": "Año de Publicación", "LabelPublishYear": "Año de Publicación",
"LabelReadAgain": "Read Again",
"LabelRecentlyAdded": "Agregado Reciente", "LabelRecentlyAdded": "Agregado Reciente",
"LabelRecentSeries": "Series Recientes", "LabelRecentSeries": "Series Recientes",
"LabelRecommended": "Recomendados", "LabelRecommended": "Recomendados",
@@ -347,6 +361,7 @@
"LabelSearchTitle": "Buscar Titulo", "LabelSearchTitle": "Buscar Titulo",
"LabelSearchTitleOrASIN": "Buscar Titulo o ASIN", "LabelSearchTitleOrASIN": "Buscar Titulo o ASIN",
"LabelSeason": "Temporada", "LabelSeason": "Temporada",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Secuencia", "LabelSequence": "Secuencia",
"LabelSeries": "Series", "LabelSeries": "Series",
"LabelSeriesName": "Nombre de la Serie", "LabelSeriesName": "Nombre de la Serie",
@@ -491,6 +506,7 @@
"MessageConfirmRenameTag": "Esta seguro que desea renombrar la etiqueta \"{0}\" a \"{1}\" de todos los elementos?", "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.", "MessageConfirmRenameTagMergeNote": "Nota: Esta etiqueta ya existe por lo que se fusionarán.",
"MessageConfirmRenameTagWarning": "Advertencia! Una etiqueta similar ya existe \"{0}\".", "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", "MessageDownloadingEpisode": "Descargando Capitulo",
"MessageDragFilesIntoTrackOrder": "Arrastras los archivos en el orden correcto de la pista.", "MessageDragFilesIntoTrackOrder": "Arrastras los archivos en el orden correcto de la pista.",
"MessageEmbedFinished": "Incorporación Terminada!", "MessageEmbedFinished": "Incorporación Terminada!",
@@ -645,6 +661,8 @@
"ToastRemoveItemFromCollectionSuccess": "Elemento eliminado de la colección.", "ToastRemoveItemFromCollectionSuccess": "Elemento eliminado de la colección.",
"ToastRSSFeedCloseFailed": "Error al cerrar fuente RSS", "ToastRSSFeedCloseFailed": "Error al cerrar fuente RSS",
"ToastRSSFeedCloseSuccess": "Fuente RSS cerrada", "ToastRSSFeedCloseSuccess": "Fuente RSS cerrada",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Error al actualizar la serie", "ToastSeriesUpdateFailed": "Error al actualizar la serie",
"ToastSeriesUpdateSuccess": "Series actualizada", "ToastSeriesUpdateSuccess": "Series actualizada",
"ToastSessionDeleteFailed": "Error al eliminar sesión", "ToastSessionDeleteFailed": "Error al eliminar sesión",
+18
View File
@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Supprimer tout", "ButtonRemoveAll": "Supprimer tout",
"ButtonRemoveAllLibraryItems": "Supprimer tous les articles de la bibliothèque", "ButtonRemoveAllLibraryItems": "Supprimer tous les articles de la bibliothèque",
"ButtonRemoveFromContinueListening": "Ne plus continuer à écouter", "ButtonRemoveFromContinueListening": "Ne plus continuer à écouter",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série", "ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série",
"ButtonReScan": "Nouvelle analyse", "ButtonReScan": "Nouvelle analyse",
"ButtonReset": "Réinitialiser", "ButtonReset": "Réinitialiser",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Démarrer lencodage M4B", "ButtonStartM4BEncode": "Démarrer lencodage M4B",
"ButtonStartMetadataEmbed": "Démarrer les Métadonnées intégrées", "ButtonStartMetadataEmbed": "Démarrer les Métadonnées intégrées",
"ButtonSubmit": "Soumettre", "ButtonSubmit": "Soumettre",
"ButtonTest": "Test",
"ButtonUpload": "Téléverser", "ButtonUpload": "Téléverser",
"ButtonUploadBackup": "Téléverser une sauvegarde", "ButtonUploadBackup": "Téléverser une sauvegarde",
"ButtonUploadCover": "Téléverser une couverture", "ButtonUploadCover": "Téléverser une couverture",
@@ -96,7 +98,10 @@
"HeaderCurrentDownloads": "File dattente de téléchargement", "HeaderCurrentDownloads": "File dattente de téléchargement",
"HeaderDetails": "Détails", "HeaderDetails": "Détails",
"HeaderDownloadQueue": "Queue de téléchargement", "HeaderDownloadQueue": "Queue de téléchargement",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Épisodes", "HeaderEpisodes": "Épisodes",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Fichiers", "HeaderFiles": "Fichiers",
"HeaderFindChapters": "Trouver les chapitres", "HeaderFindChapters": "Trouver les chapitres",
"HeaderIgnoredFiles": "Fichiers Ignorés", "HeaderIgnoredFiles": "Fichiers Ignorés",
@@ -197,6 +202,7 @@
"LabelComplete": "Complet", "LabelComplete": "Complet",
"LabelConfirmPassword": "Confirmer le mot de passe", "LabelConfirmPassword": "Confirmer le mot de passe",
"LabelContinueListening": "Continuer la lecture", "LabelContinueListening": "Continuer la lecture",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Continuer la série", "LabelContinueSeries": "Continuer la série",
"LabelCover": "Couverture", "LabelCover": "Couverture",
"LabelCoverImageURL": "URL vers limage de couverture", "LabelCoverImageURL": "URL vers limage de couverture",
@@ -216,7 +222,12 @@
"LabelDownload": "Téléchargement", "LabelDownload": "Téléchargement",
"LabelDuration": "Durée", "LabelDuration": "Durée",
"LabelDurationFound": "Durée trouvée :", "LabelDurationFound": "Durée trouvée :",
"LabelEbook": "Ebook",
"LabelEdit": "Modifier", "LabelEdit": "Modifier",
"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": "Couverture du livre intégrée", "LabelEmbeddedCover": "Couverture du livre intégrée",
"LabelEnable": "Activer", "LabelEnable": "Activer",
"LabelEnd": "Fin", "LabelEnd": "Fin",
@@ -239,6 +250,7 @@
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Suppression du fichier", "LabelHardDeleteFile": "Suppression du fichier",
"LabelHost": "Host",
"LabelHour": "Heure", "LabelHour": "Heure",
"LabelIcon": "Icone", "LabelIcon": "Icone",
"LabelIncludeInTracklist": "Inclure dans la liste des pistes", "LabelIncludeInTracklist": "Inclure dans la liste des pistes",
@@ -324,6 +336,7 @@
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Type de Podcast", "LabelPodcastType": "Type de Podcast",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)", "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 donénes iTunes et Google podcast",
"LabelProgress": "Progression", "LabelProgress": "Progression",
@@ -331,6 +344,7 @@
"LabelPubDate": "Date de publication", "LabelPubDate": "Date de publication",
"LabelPublisher": "Éditeur", "LabelPublisher": "Éditeur",
"LabelPublishYear": "Année d’édition", "LabelPublishYear": "Année d’édition",
"LabelReadAgain": "Read Again",
"LabelRecentlyAdded": "Derniers ajouts", "LabelRecentlyAdded": "Derniers ajouts",
"LabelRecentSeries": "Séries récentes", "LabelRecentSeries": "Séries récentes",
"LabelRecommended": "Recommandé", "LabelRecommended": "Recommandé",
@@ -347,6 +361,7 @@
"LabelSearchTitle": "Titre de recherche", "LabelSearchTitle": "Titre de recherche",
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN", "LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
"LabelSeason": "Saison", "LabelSeason": "Saison",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Séquence", "LabelSequence": "Séquence",
"LabelSeries": "Séries", "LabelSeries": "Séries",
"LabelSeriesName": "Nom de la série", "LabelSeriesName": "Nom de la série",
@@ -491,6 +506,7 @@
"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} » vers « {1} » pour tous les articles ?",
"MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.", "MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.",
"MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».", "MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "Téléchargement de l’épisode", "MessageDownloadingEpisode": "Téléchargement de l’épisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans lordre correct", "MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans lordre correct",
"MessageEmbedFinished": "Intégration Terminée !", "MessageEmbedFinished": "Intégration Terminée !",
@@ -645,6 +661,8 @@
"ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection", "ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection",
"ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS", "ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS",
"ToastRSSFeedCloseSuccess": "Flux RSS fermé", "ToastRSSFeedCloseSuccess": "Flux RSS fermé",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Échec de la mise à jour de la série", "ToastSeriesUpdateFailed": "Échec de la mise à jour de la série",
"ToastSeriesUpdateSuccess": "Mise à jour de la série réussie", "ToastSeriesUpdateSuccess": "Mise à jour de la série réussie",
"ToastSessionDeleteFailed": "Échec de la suppression de session", "ToastSessionDeleteFailed": "Échec de la suppression de session",
+18
View File
@@ -55,6 +55,7 @@
"ButtonRemoveAll": "બધું કાઢી નાખો", "ButtonRemoveAll": "બધું કાઢી નાખો",
"ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો", "ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
"ButtonRemoveFromContinueListening": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો", "ButtonRemoveFromContinueListening": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો", "ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો",
"ButtonReScan": "ફરીથી સ્કેન કરો", "ButtonReScan": "ફરીથી સ્કેન કરો",
"ButtonReset": "રીસેટ કરો", "ButtonReset": "રીસેટ કરો",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો", "ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
"ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો", "ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
"ButtonSubmit": "સબમિટ કરો", "ButtonSubmit": "સબમિટ કરો",
"ButtonTest": "Test",
"ButtonUpload": "અપલોડ કરો", "ButtonUpload": "અપલોડ કરો",
"ButtonUploadBackup": "બેકઅપ અપલોડ કરો", "ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
"ButtonUploadCover": "કવર અપલોડ કરો", "ButtonUploadCover": "કવર અપલોડ કરો",
@@ -96,7 +98,10 @@
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodes", "HeaderEpisodes": "Episodes",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Files", "HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters", "HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files", "HeaderIgnoredFiles": "Ignored Files",
@@ -197,6 +202,7 @@
"LabelComplete": "Complete", "LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password", "LabelConfirmPassword": "Confirm Password",
"LabelContinueListening": "Continue Listening", "LabelContinueListening": "Continue Listening",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Continue Series", "LabelContinueSeries": "Continue Series",
"LabelCover": "Cover", "LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL", "LabelCoverImageURL": "Cover Image URL",
@@ -216,7 +222,12 @@
"LabelDownload": "Download", "LabelDownload": "Download",
"LabelDuration": "Duration", "LabelDuration": "Duration",
"LabelDurationFound": "Duration found:", "LabelDurationFound": "Duration found:",
"LabelEbook": "Ebook",
"LabelEdit": "Edit", "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", "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable", "LabelEnable": "Enable",
"LabelEnd": "End", "LabelEnd": "End",
@@ -239,6 +250,7 @@
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file", "LabelHardDeleteFile": "Hard delete file",
"LabelHost": "Host",
"LabelHour": "Hour", "LabelHour": "Hour",
"LabelIcon": "Icon", "LabelIcon": "Icon",
"LabelIncludeInTracklist": "Include in Tracklist", "LabelIncludeInTracklist": "Include in Tracklist",
@@ -324,6 +336,7 @@
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type", "LabelPodcastType": "Podcast Type",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)", "LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories", "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Progress", "LabelProgress": "Progress",
@@ -331,6 +344,7 @@
"LabelPubDate": "Pub Date", "LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher", "LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year", "LabelPublishYear": "Publish Year",
"LabelReadAgain": "Read Again",
"LabelRecentlyAdded": "Recently Added", "LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series", "LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended", "LabelRecommended": "Recommended",
@@ -347,6 +361,7 @@
"LabelSearchTitle": "Search Title", "LabelSearchTitle": "Search Title",
"LabelSearchTitleOrASIN": "Search Title or ASIN", "LabelSearchTitleOrASIN": "Search Title or ASIN",
"LabelSeason": "Season", "LabelSeason": "Season",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequence", "LabelSequence": "Sequence",
"LabelSeries": "Series", "LabelSeries": "Series",
"LabelSeriesName": "Series Name", "LabelSeriesName": "Series Name",
@@ -491,6 +506,7 @@
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "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.", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", "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", "MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!", "MessageEmbedFinished": "Embed Finished!",
@@ -645,6 +661,8 @@
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection", "ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed", "ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed", "ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Series update failed", "ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success", "ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session", "ToastSessionDeleteFailed": "Failed to delete session",
+18
View File
@@ -55,6 +55,7 @@
"ButtonRemoveAll": "सभी हटाएं", "ButtonRemoveAll": "सभी हटाएं",
"ButtonRemoveAllLibraryItems": "पुस्तकालय की सभी आइटम हटाएं", "ButtonRemoveAllLibraryItems": "पुस्तकालय की सभी आइटम हटाएं",
"ButtonRemoveFromContinueListening": "सुनना जारी रखें से हटाएं", "ButtonRemoveFromContinueListening": "सुनना जारी रखें से हटाएं",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "इस सीरीज को कंटिन्यू सीरीज से हटा दें", "ButtonRemoveSeriesFromContinueSeries": "इस सीरीज को कंटिन्यू सीरीज से हटा दें",
"ButtonReScan": "पुन: स्कैन करें", "ButtonReScan": "पुन: स्कैन करें",
"ButtonReset": "रीसेट करें", "ButtonReset": "रीसेट करें",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "M4B एन्कोडिंग शुरू करें", "ButtonStartM4BEncode": "M4B एन्कोडिंग शुरू करें",
"ButtonStartMetadataEmbed": "मेटाडेटा एम्बेडिंग शुरू करें", "ButtonStartMetadataEmbed": "मेटाडेटा एम्बेडिंग शुरू करें",
"ButtonSubmit": "जमा करें", "ButtonSubmit": "जमा करें",
"ButtonTest": "Test",
"ButtonUpload": "अपलोड करें", "ButtonUpload": "अपलोड करें",
"ButtonUploadBackup": "बैकअप अपलोड करें", "ButtonUploadBackup": "बैकअप अपलोड करें",
"ButtonUploadCover": "कवर अपलोड करें", "ButtonUploadCover": "कवर अपलोड करें",
@@ -96,7 +98,10 @@
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodes", "HeaderEpisodes": "Episodes",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Files", "HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters", "HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files", "HeaderIgnoredFiles": "Ignored Files",
@@ -197,6 +202,7 @@
"LabelComplete": "Complete", "LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password", "LabelConfirmPassword": "Confirm Password",
"LabelContinueListening": "Continue Listening", "LabelContinueListening": "Continue Listening",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Continue Series", "LabelContinueSeries": "Continue Series",
"LabelCover": "Cover", "LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL", "LabelCoverImageURL": "Cover Image URL",
@@ -216,7 +222,12 @@
"LabelDownload": "Download", "LabelDownload": "Download",
"LabelDuration": "Duration", "LabelDuration": "Duration",
"LabelDurationFound": "Duration found:", "LabelDurationFound": "Duration found:",
"LabelEbook": "Ebook",
"LabelEdit": "Edit", "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", "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable", "LabelEnable": "Enable",
"LabelEnd": "End", "LabelEnd": "End",
@@ -239,6 +250,7 @@
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file", "LabelHardDeleteFile": "Hard delete file",
"LabelHost": "Host",
"LabelHour": "Hour", "LabelHour": "Hour",
"LabelIcon": "Icon", "LabelIcon": "Icon",
"LabelIncludeInTracklist": "Include in Tracklist", "LabelIncludeInTracklist": "Include in Tracklist",
@@ -324,6 +336,7 @@
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type", "LabelPodcastType": "Podcast Type",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)", "LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories", "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Progress", "LabelProgress": "Progress",
@@ -331,6 +344,7 @@
"LabelPubDate": "Pub Date", "LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher", "LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year", "LabelPublishYear": "Publish Year",
"LabelReadAgain": "Read Again",
"LabelRecentlyAdded": "Recently Added", "LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series", "LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended", "LabelRecommended": "Recommended",
@@ -347,6 +361,7 @@
"LabelSearchTitle": "Search Title", "LabelSearchTitle": "Search Title",
"LabelSearchTitleOrASIN": "Search Title or ASIN", "LabelSearchTitleOrASIN": "Search Title or ASIN",
"LabelSeason": "Season", "LabelSeason": "Season",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequence", "LabelSequence": "Sequence",
"LabelSeries": "Series", "LabelSeries": "Series",
"LabelSeriesName": "Series Name", "LabelSeriesName": "Series Name",
@@ -491,6 +506,7 @@
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "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.", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", "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", "MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!", "MessageEmbedFinished": "Embed Finished!",
@@ -645,6 +661,8 @@
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection", "ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed", "ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed", "ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Series update failed", "ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success", "ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session", "ToastSessionDeleteFailed": "Failed to delete session",
+18
View File
@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Ukloni sve", "ButtonRemoveAll": "Ukloni sve",
"ButtonRemoveAllLibraryItems": "Ukloni sve stvari iz biblioteke", "ButtonRemoveAllLibraryItems": "Ukloni sve stvari iz biblioteke",
"ButtonRemoveFromContinueListening": "Ukloni iz Nastavi slušati", "ButtonRemoveFromContinueListening": "Ukloni iz Nastavi slušati",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Ukloni seriju iz Nastavi seriju", "ButtonRemoveSeriesFromContinueSeries": "Ukloni seriju iz Nastavi seriju",
"ButtonReScan": "Skeniraj ponovno", "ButtonReScan": "Skeniraj ponovno",
"ButtonReset": "Poništi", "ButtonReset": "Poništi",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Pokreni M4B kodiranje", "ButtonStartM4BEncode": "Pokreni M4B kodiranje",
"ButtonStartMetadataEmbed": "Pokreni ugradnju metapodataka", "ButtonStartMetadataEmbed": "Pokreni ugradnju metapodataka",
"ButtonSubmit": "Submit", "ButtonSubmit": "Submit",
"ButtonTest": "Test",
"ButtonUpload": "Upload", "ButtonUpload": "Upload",
"ButtonUploadBackup": "Upload backup", "ButtonUploadBackup": "Upload backup",
"ButtonUploadCover": "Upload Cover", "ButtonUploadCover": "Upload Cover",
@@ -96,7 +98,10 @@
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Detalji", "HeaderDetails": "Detalji",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Epizode", "HeaderEpisodes": "Epizode",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Datoteke", "HeaderFiles": "Datoteke",
"HeaderFindChapters": "Pronađi poglavlja", "HeaderFindChapters": "Pronađi poglavlja",
"HeaderIgnoredFiles": "Zanemarene datoteke", "HeaderIgnoredFiles": "Zanemarene datoteke",
@@ -197,6 +202,7 @@
"LabelComplete": "Complete", "LabelComplete": "Complete",
"LabelConfirmPassword": "Potvrdi lozinku", "LabelConfirmPassword": "Potvrdi lozinku",
"LabelContinueListening": "Nastavi slušanje", "LabelContinueListening": "Nastavi slušanje",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Nastavi seriju", "LabelContinueSeries": "Nastavi seriju",
"LabelCover": "Cover", "LabelCover": "Cover",
"LabelCoverImageURL": "URL od covera", "LabelCoverImageURL": "URL od covera",
@@ -216,7 +222,12 @@
"LabelDownload": "Preuzmi", "LabelDownload": "Preuzmi",
"LabelDuration": "Trajanje", "LabelDuration": "Trajanje",
"LabelDurationFound": "Pronađeno trajanje:", "LabelDurationFound": "Pronađeno trajanje:",
"LabelEbook": "Ebook",
"LabelEdit": "Uredi", "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", "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Uključi", "LabelEnable": "Uključi",
"LabelEnd": "Kraj", "LabelEnd": "Kraj",
@@ -239,6 +250,7 @@
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Žanrovi", "LabelGenres": "Žanrovi",
"LabelHardDeleteFile": "Obriši datoteku zauvijek", "LabelHardDeleteFile": "Obriši datoteku zauvijek",
"LabelHost": "Host",
"LabelHour": "Sat", "LabelHour": "Sat",
"LabelIcon": "Ikona", "LabelIcon": "Ikona",
"LabelIncludeInTracklist": "Dodaj u Tracklist", "LabelIncludeInTracklist": "Dodaj u Tracklist",
@@ -324,6 +336,7 @@
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type", "LabelPodcastType": "Podcast Type",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefiksi za ignorirati (mala i velika slova nisu bitna)", "LabelPrefixesToIgnore": "Prefiksi za ignorirati (mala i velika slova nisu bitna)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories", "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Napredak", "LabelProgress": "Napredak",
@@ -331,6 +344,7 @@
"LabelPubDate": "Datam izdavanja", "LabelPubDate": "Datam izdavanja",
"LabelPublisher": "Izdavač", "LabelPublisher": "Izdavač",
"LabelPublishYear": "Godina izdavanja", "LabelPublishYear": "Godina izdavanja",
"LabelReadAgain": "Read Again",
"LabelRecentlyAdded": "Nedavno dodano", "LabelRecentlyAdded": "Nedavno dodano",
"LabelRecentSeries": "Nedavne serije", "LabelRecentSeries": "Nedavne serije",
"LabelRecommended": "Recommended", "LabelRecommended": "Recommended",
@@ -347,6 +361,7 @@
"LabelSearchTitle": "Traži naslov", "LabelSearchTitle": "Traži naslov",
"LabelSearchTitleOrASIN": "Traži naslov ili ASIN", "LabelSearchTitleOrASIN": "Traži naslov ili ASIN",
"LabelSeason": "Sezona", "LabelSeason": "Sezona",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sekvenca", "LabelSequence": "Sekvenca",
"LabelSeries": "Serije", "LabelSeries": "Serije",
"LabelSeriesName": "Ime serije", "LabelSeriesName": "Ime serije",
@@ -491,6 +506,7 @@
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "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.", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", "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", "MessageDownloadingEpisode": "Preuzimam epizodu",
"MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.", "MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.",
"MessageEmbedFinished": "Embed završen!", "MessageEmbedFinished": "Embed završen!",
@@ -645,6 +661,8 @@
"ToastRemoveItemFromCollectionSuccess": "Stavka uklonjena iz kolekcije", "ToastRemoveItemFromCollectionSuccess": "Stavka uklonjena iz kolekcije",
"ToastRSSFeedCloseFailed": "Neuspješno zatvaranje RSS Feeda", "ToastRSSFeedCloseFailed": "Neuspješno zatvaranje RSS Feeda",
"ToastRSSFeedCloseSuccess": "RSS Feed zatvoren", "ToastRSSFeedCloseSuccess": "RSS Feed zatvoren",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Series update failed", "ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success", "ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Neuspješno brisanje serije", "ToastSessionDeleteFailed": "Neuspješno brisanje serije",
+18
View File
@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Rimuovi Tutto", "ButtonRemoveAll": "Rimuovi Tutto",
"ButtonRemoveAllLibraryItems": "Rimuovi tutto il contenuto della libreria", "ButtonRemoveAllLibraryItems": "Rimuovi tutto il contenuto della libreria",
"ButtonRemoveFromContinueListening": "Rimuovi per proseguire l'ascolto", "ButtonRemoveFromContinueListening": "Rimuovi per proseguire l'ascolto",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla", "ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla",
"ButtonReScan": "Ri-scansiona", "ButtonReScan": "Ri-scansiona",
"ButtonReset": "Reset", "ButtonReset": "Reset",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Inizia L'Encoda del M4B", "ButtonStartM4BEncode": "Inizia L'Encoda del M4B",
"ButtonStartMetadataEmbed": "Inizia Incorporo Metadata", "ButtonStartMetadataEmbed": "Inizia Incorporo Metadata",
"ButtonSubmit": "Invia", "ButtonSubmit": "Invia",
"ButtonTest": "Test",
"ButtonUpload": "Carica", "ButtonUpload": "Carica",
"ButtonUploadBackup": "Carica Backup", "ButtonUploadBackup": "Carica Backup",
"ButtonUploadCover": "Carica Cover", "ButtonUploadCover": "Carica Cover",
@@ -96,7 +98,10 @@
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Dettagli", "HeaderDetails": "Dettagli",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodi", "HeaderEpisodes": "Episodi",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "File", "HeaderFiles": "File",
"HeaderFindChapters": "Trova Capitoli", "HeaderFindChapters": "Trova Capitoli",
"HeaderIgnoredFiles": "File Ignorati", "HeaderIgnoredFiles": "File Ignorati",
@@ -197,6 +202,7 @@
"LabelComplete": "Completo", "LabelComplete": "Completo",
"LabelConfirmPassword": "Conferma Password", "LabelConfirmPassword": "Conferma Password",
"LabelContinueListening": "Continua ad Ascoltare", "LabelContinueListening": "Continua ad Ascoltare",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Continua Serie", "LabelContinueSeries": "Continua Serie",
"LabelCover": "Cover", "LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL", "LabelCoverImageURL": "Cover Image URL",
@@ -216,7 +222,12 @@
"LabelDownload": "Download", "LabelDownload": "Download",
"LabelDuration": "Durata", "LabelDuration": "Durata",
"LabelDurationFound": "Durata Trovata:", "LabelDurationFound": "Durata Trovata:",
"LabelEbook": "Ebook",
"LabelEdit": "Modifica", "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", "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Abilita", "LabelEnable": "Abilita",
"LabelEnd": "Fine", "LabelEnd": "Fine",
@@ -239,6 +250,7 @@
"LabelGenre": "Genere", "LabelGenre": "Genere",
"LabelGenres": "Generi", "LabelGenres": "Generi",
"LabelHardDeleteFile": "Elimina Definitivamente", "LabelHardDeleteFile": "Elimina Definitivamente",
"LabelHost": "Host",
"LabelHour": "Ora", "LabelHour": "Ora",
"LabelIcon": "Icona", "LabelIcon": "Icona",
"LabelIncludeInTracklist": "Includi nella Tracklist", "LabelIncludeInTracklist": "Includi nella Tracklist",
@@ -324,6 +336,7 @@
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Timo di Podcast", "LabelPodcastType": "Timo di Podcast",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)", "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", "LabelPreventIndexing": "Impedisci che il tuo feed venga indicizzato da iTunes e dalle directory dei podcast di Google",
"LabelProgress": "Cominciati", "LabelProgress": "Cominciati",
@@ -331,6 +344,7 @@
"LabelPubDate": "Data Pubblicazione", "LabelPubDate": "Data Pubblicazione",
"LabelPublisher": "Editore", "LabelPublisher": "Editore",
"LabelPublishYear": "Anno Pubblicazione", "LabelPublishYear": "Anno Pubblicazione",
"LabelReadAgain": "Read Again",
"LabelRecentlyAdded": "Aggiunti Recentemente", "LabelRecentlyAdded": "Aggiunti Recentemente",
"LabelRecentSeries": "Serie Recenti", "LabelRecentSeries": "Serie Recenti",
"LabelRecommended": "Raccomandati", "LabelRecommended": "Raccomandati",
@@ -347,6 +361,7 @@
"LabelSearchTitle": "Cerca Titolo", "LabelSearchTitle": "Cerca Titolo",
"LabelSearchTitleOrASIN": "Cerca titolo o ASIN", "LabelSearchTitleOrASIN": "Cerca titolo o ASIN",
"LabelSeason": "Stagione", "LabelSeason": "Stagione",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequenza", "LabelSequence": "Sequenza",
"LabelSeries": "Serie", "LabelSeries": "Serie",
"LabelSeriesName": "Nome Serie", "LabelSeriesName": "Nome Serie",
@@ -491,6 +506,7 @@
"MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?", "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.", "MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.",
"MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".", "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", "MessageDownloadingEpisode": "Download episodio in corso",
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto", "MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
"MessageEmbedFinished": "Incorporamento finito!", "MessageEmbedFinished": "Incorporamento finito!",
@@ -645,6 +661,8 @@
"ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta", "ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta",
"ToastRSSFeedCloseFailed": "Errore chiusura RSS feed", "ToastRSSFeedCloseFailed": "Errore chiusura RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed chiuso", "ToastRSSFeedCloseSuccess": "RSS feed chiuso",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Aggiornaento Serie Fallito", "ToastSeriesUpdateFailed": "Aggiornaento Serie Fallito",
"ToastSeriesUpdateSuccess": "Serie Aggornate", "ToastSeriesUpdateSuccess": "Serie Aggornate",
"ToastSessionDeleteFailed": "Errore eliminazione sessione", "ToastSessionDeleteFailed": "Errore eliminazione sessione",
+77 -59
View File
@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Alles verwijderen", "ButtonRemoveAll": "Alles verwijderen",
"ButtonRemoveAllLibraryItems": "Verwijder volledige bibliotheekinhoud", "ButtonRemoveAllLibraryItems": "Verwijder volledige bibliotheekinhoud",
"ButtonRemoveFromContinueListening": "Vewijder uit Verder luisteren", "ButtonRemoveFromContinueListening": "Vewijder uit Verder luisteren",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Verwijder serie uit Serie vervolgen", "ButtonRemoveSeriesFromContinueSeries": "Verwijder serie uit Serie vervolgen",
"ButtonReScan": "Nieuwe scan", "ButtonReScan": "Nieuwe scan",
"ButtonReset": "Reset", "ButtonReset": "Reset",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Start M4B-encoding", "ButtonStartM4BEncode": "Start M4B-encoding",
"ButtonStartMetadataEmbed": "Start insluiten metadata", "ButtonStartMetadataEmbed": "Start insluiten metadata",
"ButtonSubmit": "Indienen", "ButtonSubmit": "Indienen",
"ButtonTest": "Test",
"ButtonUpload": "Upload", "ButtonUpload": "Upload",
"ButtonUploadBackup": "Upload back-up", "ButtonUploadBackup": "Upload back-up",
"ButtonUploadCover": "Upload cover", "ButtonUploadCover": "Upload cover",
@@ -85,7 +87,7 @@
"HeaderAdvanced": "Geavanceerd", "HeaderAdvanced": "Geavanceerd",
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen", "HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
"HeaderAudiobookTools": "Audioboekbestandbeheer tools", "HeaderAudiobookTools": "Audioboekbestandbeheer tools",
"HeaderAudioTracks": "Audio tracks", "HeaderAudioTracks": "Audiotracks",
"HeaderBackups": "Back-ups", "HeaderBackups": "Back-ups",
"HeaderChangePassword": "Wachtwoord wijzigen", "HeaderChangePassword": "Wachtwoord wijzigen",
"HeaderChapters": "Hoofdstukken", "HeaderChapters": "Hoofdstukken",
@@ -96,7 +98,10 @@
"HeaderCurrentDownloads": "Huidige downloads", "HeaderCurrentDownloads": "Huidige downloads",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download-wachtrij", "HeaderDownloadQueue": "Download-wachtrij",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Afleveringen", "HeaderEpisodes": "Afleveringen",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Bestanden", "HeaderFiles": "Bestanden",
"HeaderFindChapters": "Zoek hoofdstukken", "HeaderFindChapters": "Zoek hoofdstukken",
"HeaderIgnoredFiles": "Genegeerde bestanden", "HeaderIgnoredFiles": "Genegeerde bestanden",
@@ -149,10 +154,10 @@
"HeaderStatsTop10Authors": "Top 10 auteurs", "HeaderStatsTop10Authors": "Top 10 auteurs",
"HeaderStatsTop5Genres": "Top 5 genres", "HeaderStatsTop5Genres": "Top 5 genres",
"HeaderTools": "Tools", "HeaderTools": "Tools",
"HeaderUpdateAccount": "Update account", "HeaderUpdateAccount": "Account bijwerken",
"HeaderUpdateAuthor": "Update auteur", "HeaderUpdateAuthor": "Auteur bijwerken",
"HeaderUpdateDetails": "Update details", "HeaderUpdateDetails": "Details bijwerken",
"HeaderUpdateLibrary": "Update bibliotheek", "HeaderUpdateLibrary": "Bibliotheek bijwerken",
"HeaderUsers": "Gebruikers", "HeaderUsers": "Gebruikers",
"HeaderYourStats": "Je statistieken", "HeaderYourStats": "Je statistieken",
"LabelAbridged": "Verkort", "LabelAbridged": "Verkort",
@@ -192,11 +197,12 @@
"LabelChapterTitle": "Hoofdstuktitel", "LabelChapterTitle": "Hoofdstuktitel",
"LabelClosePlayer": "Sluit speler", "LabelClosePlayer": "Sluit speler",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Serie inklappen", "LabelCollapseSeries": "Series inklappen",
"LabelCollections": "Collecties", "LabelCollections": "Collecties",
"LabelComplete": "Compleet", "LabelComplete": "Compleet",
"LabelConfirmPassword": "Bevestig wachtwoord", "LabelConfirmPassword": "Bevestig wachtwoord",
"LabelContinueListening": "Verder luisteren", "LabelContinueListening": "Verder luisteren",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Ga verder met serie", "LabelContinueSeries": "Ga verder met serie",
"LabelCover": "Cover", "LabelCover": "Cover",
"LabelCoverImageURL": "Coverafbeelding URL", "LabelCoverImageURL": "Coverafbeelding URL",
@@ -204,7 +210,7 @@
"LabelCronExpression": "Cron-uitdrukking", "LabelCronExpression": "Cron-uitdrukking",
"LabelCurrent": "Huidig", "LabelCurrent": "Huidig",
"LabelCurrently": "Op dit moment:", "LabelCurrently": "Op dit moment:",
"LabelCustomCronExpression": "Custom Cron-uitdrukking:", "LabelCustomCronExpression": "Aangepaste Cron-uitdrukking:",
"LabelDatetime": "Datum-tijd", "LabelDatetime": "Datum-tijd",
"LabelDescription": "Beschrijving", "LabelDescription": "Beschrijving",
"LabelDeselectAll": "Deselecteer alle", "LabelDeselectAll": "Deselecteer alle",
@@ -216,7 +222,12 @@
"LabelDownload": "Download", "LabelDownload": "Download",
"LabelDuration": "Duur", "LabelDuration": "Duur",
"LabelDurationFound": "Gevonden duur:", "LabelDurationFound": "Gevonden duur:",
"LabelEbook": "Ebook",
"LabelEdit": "Wijzig", "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", "LabelEmbeddedCover": "Ingesloten cover",
"LabelEnable": "Inschakelen", "LabelEnable": "Inschakelen",
"LabelEnd": "Einde", "LabelEnd": "Einde",
@@ -239,6 +250,7 @@
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard-delete bestand", "LabelHardDeleteFile": "Hard-delete bestand",
"LabelHost": "Host",
"LabelHour": "Uur", "LabelHour": "Uur",
"LabelIcon": "Icoon", "LabelIcon": "Icoon",
"LabelIncludeInTracklist": "Includeer in tracklijst", "LabelIncludeInTracklist": "Includeer in tracklijst",
@@ -259,7 +271,7 @@
"LabelLanguage": "Taal", "LabelLanguage": "Taal",
"LabelLanguageDefaultServer": "Standaard servertaal", "LabelLanguageDefaultServer": "Standaard servertaal",
"LabelLastBookAdded": "Laatst toegevoegde boek", "LabelLastBookAdded": "Laatst toegevoegde boek",
"LabelLastBookUpdated": "Laatst geupdatete boek", "LabelLastBookUpdated": "Laatst bijgewerkte boek",
"LabelLastSeen": "Laatst gezien", "LabelLastSeen": "Laatst gezien",
"LabelLastTime": "Laatste keer", "LabelLastTime": "Laatste keer",
"LabelLastUpdate": "Laatste update", "LabelLastUpdate": "Laatste update",
@@ -275,7 +287,7 @@
"LabelLogLevelWarn": "Waarschuwing", "LabelLogLevelWarn": "Waarschuwing",
"LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum", "LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum",
"LabelMediaPlayer": "Mediaspeler", "LabelMediaPlayer": "Mediaspeler",
"LabelMediaType": "Mediaytype", "LabelMediaType": "Mediatype",
"LabelMetadataProvider": "Metadatabron", "LabelMetadataProvider": "Metadatabron",
"LabelMetaTag": "Meta-tag", "LabelMetaTag": "Meta-tag",
"LabelMetaTags": "Meta-tags", "LabelMetaTags": "Meta-tags",
@@ -316,7 +328,7 @@
"LabelPermissionsAccessExplicitContent": "Heeft toegang tot expliciete inhoud", "LabelPermissionsAccessExplicitContent": "Heeft toegang tot expliciete inhoud",
"LabelPermissionsDelete": "Kan verwijderen", "LabelPermissionsDelete": "Kan verwijderen",
"LabelPermissionsDownload": "Kan downloaden", "LabelPermissionsDownload": "Kan downloaden",
"LabelPermissionsUpdate": "Kan updaten", "LabelPermissionsUpdate": "Kan bijwerken",
"LabelPermissionsUpload": "Kan uploaden", "LabelPermissionsUpload": "Kan uploaden",
"LabelPhotoPathURL": "Foto pad/URL", "LabelPhotoPathURL": "Foto pad/URL",
"LabelPlaylists": "Afspeellijsten", "LabelPlaylists": "Afspeellijsten",
@@ -324,6 +336,7 @@
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcasttype", "LabelPodcastType": "Podcasttype",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Te negeren voorzetsels (ongeacht hoofdlettergebruik)", "LabelPrefixesToIgnore": "Te negeren voorzetsels (ongeacht hoofdlettergebruik)",
"LabelPreventIndexing": "Voorkom indexering van je feed door iTunes- en Google podcastmappen", "LabelPreventIndexing": "Voorkom indexering van je feed door iTunes- en Google podcastmappen",
"LabelProgress": "Voortgang", "LabelProgress": "Voortgang",
@@ -331,6 +344,7 @@
"LabelPubDate": "Publicatiedatum", "LabelPubDate": "Publicatiedatum",
"LabelPublisher": "Uitgever", "LabelPublisher": "Uitgever",
"LabelPublishYear": "Jaar van uitgave", "LabelPublishYear": "Jaar van uitgave",
"LabelReadAgain": "Read Again",
"LabelRecentlyAdded": "Recent toegevoegd", "LabelRecentlyAdded": "Recent toegevoegd",
"LabelRecentSeries": "Recente series", "LabelRecentSeries": "Recente series",
"LabelRecommended": "Aangeraden", "LabelRecommended": "Aangeraden",
@@ -347,6 +361,7 @@
"LabelSearchTitle": "Zoek titel", "LabelSearchTitle": "Zoek titel",
"LabelSearchTitleOrASIN": "Zoek titel of ASIN", "LabelSearchTitleOrASIN": "Zoek titel of ASIN",
"LabelSeason": "Seizoen", "LabelSeason": "Seizoen",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequentie", "LabelSequence": "Sequentie",
"LabelSeries": "Serie", "LabelSeries": "Serie",
"LabelSeriesName": "Naam serie", "LabelSeriesName": "Naam serie",
@@ -356,15 +371,15 @@
"LabelSettingsDateFormat": "Datum format", "LabelSettingsDateFormat": "Datum format",
"LabelSettingsDisableWatcher": "Watcher uitschakelen", "LabelSettingsDisableWatcher": "Watcher uitschakelen",
"LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen", "LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen",
"LabelSettingsDisableWatcherHelp": "Schakelt het automatisch toevoegen/updaten van onderdelen wanneer bestandswijzigingen gedetecteerd zijn uit. *Vereist herstart server", "LabelSettingsDisableWatcherHelp": "Schakelt het automatisch toevoegen/bijwerken van onderdelen wanneer bestandswijzigingen gedetecteerd zijn uit. *Vereist herstart server",
"LabelSettingsEnableEReader": "E-reader inschakelen voor alle gebruikers", "LabelSettingsEnableEReader": "E-reader inschakelen voor alle gebruikers",
"LabelSettingsEnableEReaderHelp": "E-reader is nog in ontwikkeling, maar gebruik deze instelling om het beschikbaar te maken voor al je gebruikers (of gebruik de \"Experimentele functies\"-schakelaar voor eigen gebruik)", "LabelSettingsEnableEReaderHelp": "E-reader is nog in ontwikkeling, maar gebruik deze instelling om het beschikbaar te maken voor al je gebruikers (of gebruik de \"Experimentele functies\"-schakelaar voor eigen gebruik)",
"LabelSettingsExperimentalFeatures": "Experimentele functies", "LabelSettingsExperimentalFeatures": "Experimentele functies",
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.", "LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
"LabelSettingsFindCovers": "Zoek covers", "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", "LabelSettingsFindCoversHelp": "Als je audioboek geen ingesloten cover of cover in de map heeft, zal de scanner proberen een cover te vinden.<br>Opmerking: Dit zal de scan-duur verlengen",
"LabelSettingsHomePageBookshelfView": "Homepagina gebruikt boekenplank-view", "LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina",
"LabelSettingsLibraryBookshelfView": "Bibliotheek gebruikt boekenplank-view", "LabelSettingsLibraryBookshelfView": "Boekenplank-view voor bibliotheek",
"LabelSettingsOverdriveMediaMarkers": "Gebruik Overdrive media markers voor hoofdstukken", "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.", "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", "LabelSettingsParseSubtitles": "Parseer subtitel",
@@ -425,8 +440,8 @@
"LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief coverafbeelding en hoofdstukken.", "LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief coverafbeelding en hoofdstukken.",
"LabelToolsMakeM4b": "Maak M4B-audioboekbestand", "LabelToolsMakeM4b": "Maak M4B-audioboekbestand",
"LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, coverafbeelding en hoofdstukken.", "LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, coverafbeelding en hoofdstukken.",
"LabelToolsSplitM4b": "Splits M4B in MP3's", "LabelToolsSplitM4b": "Splitst M4B in MP3's",
"LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplits per hoofdstuk met ingesloten metadata, coverafbeelding en hoofdstukken.", "LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplitst per hoofdstuk met ingesloten metadata, coverafbeelding en hoofdstukken.",
"LabelTotalDuration": "Totale duur", "LabelTotalDuration": "Totale duur",
"LabelTotalTimeListened": "Totale tijd geluisterd", "LabelTotalTimeListened": "Totale tijd geluisterd",
"LabelTrackFromFilename": "Track vanuit bestandsnaam", "LabelTrackFromFilename": "Track vanuit bestandsnaam",
@@ -437,10 +452,10 @@
"LabelType": "Type", "LabelType": "Type",
"LabelUnabridged": "Onverkort", "LabelUnabridged": "Onverkort",
"LabelUnknown": "Onbekend", "LabelUnknown": "Onbekend",
"LabelUpdateCover": "Update cover", "LabelUpdateCover": "Cover bijwerken",
"LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden",
"LabelUpdatedAt": "Geüpdatet op", "LabelUpdatedAt": "Bijgewerkt op",
"LabelUpdateDetails": "Update details", "LabelUpdateDetails": "Details bijwerken",
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
"LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen", "LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen",
"LabelUploaderDropFiles": "Bestanden neerzetten", "LabelUploaderDropFiles": "Bestanden neerzetten",
@@ -455,16 +470,16 @@
"LabelViewQueue": "Bekijk afspeelwachtrij", "LabelViewQueue": "Bekijk afspeelwachtrij",
"LabelVolume": "Volume", "LabelVolume": "Volume",
"LabelWeekdaysToRun": "Weekdagen om te draaien", "LabelWeekdaysToRun": "Weekdagen om te draaien",
"LabelYourAudiobookDuration": "Jouw audioboekduur", "LabelYourAudiobookDuration": "Je audioboekduur",
"LabelYourBookmarks": "Jouw boekwijzers", "LabelYourBookmarks": "Je boekwijzers",
"LabelYourPlaylists": "Jouw afspeellijsten", "LabelYourPlaylists": "Je afspeellijsten",
"LabelYourProgress": "Jouw voortgang", "LabelYourProgress": "Je voortgang",
"MessageAddToPlayerQueue": "Toevoegen aan wachtrij", "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.", "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.", "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.", "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", "MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt",
"MessageBookshelfNoResultsForFilter": "Geen resultaten voo filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Geen resultaten voor filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend", "MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend",
"MessageBookshelfNoSeries": "Je hebt geen series", "MessageBookshelfNoSeries": "Je hebt geen series",
"MessageChapterEndIsAfter": "Hoofdstukeinde is na het einde van je audioboek", "MessageChapterEndIsAfter": "Hoofdstukeinde is na het einde van je audioboek",
@@ -487,32 +502,33 @@
"MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{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?", "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.", "MessageConfirmRenameGenreMergeNote": "Opmerking: Dit genre bestaat al, dus zullen ze worden samengevoegd.",
"MessageConfirmRenameGenreWarning": "Waarschuwing! Een gelijknamig genre met ander hooflettergebruik bestaat al: \"{0}\".", "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?", "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.", "MessageConfirmRenameTagMergeNote": "Opmerking: Deze tag bestaat al, dus zullen ze worden samengevoegd.",
"MessageConfirmRenameTagWarning": "Waarschuwing! Een gelijknamige tag met ander hooflettergebruik bestaat al: \"{0}\".", "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", "MessageDownloadingEpisode": "Aflevering aan het dowloaden",
"MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde", "MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde",
"MessageEmbedFinished": "Insluiting voltooid!", "MessageEmbedFinished": "Insluiting voltooid!",
"MessageEpisodesQueuedForDownload": "{0} aflevering(en) in de rij om te downloaden", "MessageEpisodesQueuedForDownload": "{0} aflevering(en) in de rij om te downloaden",
"MessageFeedURLWillBe": "Feed URL zal {0} zijn", "MessageFeedURLWillBe": "Feed URL zal {0} zijn",
"MessageFetching": "Aan het ophalen...", "MessageFetching": "Aan het ophalen...",
"MessageForceReScanDescription": "zall alle bestanden opnieuw scannen als een verse scan. Audiobestanden ID3-tags, OPF-bestanden en textbestanden zullen als nieuw worden gescand.", "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!", "MessageImportantNotice": "Belangrijke opmerking!",
"MessageInsertChapterBelow": "Hoofdstuk hieronder invoegen", "MessageInsertChapterBelow": "Hoofdstuk hieronder invoegen",
"MessageItemsSelected": "{0} onderdelen geselecteerd", "MessageItemsSelected": "{0} onderdelen geselecteerd",
"MessageItemsUpdated": "{0} onderdelen geüpdatet", "MessageItemsUpdated": "{0} onderdelen bijgewerkt",
"MessageJoinUsOn": "Doe mee op", "MessageJoinUsOn": "Doe mee op",
"MessageListeningSessionsInTheLastYear": "{0} luistersessies in het laatste jaar", "MessageListeningSessionsInTheLastYear": "{0} luistersessies in het laatste jaar",
"MessageLoading": "Aan het laden...", "MessageLoading": "Aan het laden...",
"MessageLoadingFolders": "Mappen aan het laden...", "MessageLoadingFolders": "Mappen aan het laden...",
"MessageM4BFailed": "M4B mislukt!", "MessageM4BFailed": "M4B mislukt!",
"MessageM4BFinished": "M4B voltooid!", "MessageM4BFinished": "M4B voltooid!",
"MessageMapChapterTitles": "Map hoofdstuktitels naar je bsetaande audioboekhoofdstukken zonder aanpassing van tijden", "MessageMapChapterTitles": "Map hoofdstuktitels naar je bestaande audioboekhoofdstukken zonder aanpassing van tijden",
"MessageMarkAsFinished": "Markeer als Voltooid", "MessageMarkAsFinished": "Markeer als Voltooid",
"MessageMarkAsNotFinished": "Markeer als Niet 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.", "MessageMatchBooksDescription": "zal proberen boeken in de bibliotheek te matchen met een boek uit de geselecteerde bron en lege details en coverafbeelding te vullen. Overschrijft details niet.",
"MessageNoAudioTracks": "Geen audio tracks", "MessageNoAudioTracks": "Geen audiotracks",
"MessageNoAuthors": "Geen auteurs", "MessageNoAuthors": "Geen auteurs",
"MessageNoBackups": "Geen back-ups", "MessageNoBackups": "Geen back-ups",
"MessageNoBookmarks": "Geen boekwijzers", "MessageNoBookmarks": "Geen boekwijzers",
@@ -535,13 +551,13 @@
"MessageNoNotifications": "Geen notificaties", "MessageNoNotifications": "Geen notificaties",
"MessageNoPodcastsFound": "Geen podcasts gevonden", "MessageNoPodcastsFound": "Geen podcasts gevonden",
"MessageNoResults": "Geen resultaten", "MessageNoResults": "Geen resultaten",
"MessageNoSearchResultsFor": "Geen zoekresultatn voor \"{0}\"", "MessageNoSearchResultsFor": "Geen zoekresultaten voor \"{0}\"",
"MessageNoSeries": "Geen series", "MessageNoSeries": "Geen series",
"MessageNoTags": "Geen tags", "MessageNoTags": "Geen tags",
"MessageNoTasksRunning": "Geen lopende taken", "MessageNoTasksRunning": "Geen lopende taken",
"MessageNotYetImplemented": "Nog niet geimplementeerd", "MessageNotYetImplemented": "Nog niet geimplementeerd",
"MessageNoUpdateNecessary": "Geen update noodzakelijk", "MessageNoUpdateNecessary": "Geen bijwerking noodzakelijk",
"MessageNoUpdatesWereNecessary": "Geen updates waren noodzakelijk", "MessageNoUpdatesWereNecessary": "Geen bijwerkingen waren noodzakelijk",
"MessageNoUserPlaylists": "Je hebt geen afspeellijsten", "MessageNoUserPlaylists": "Je hebt geen afspeellijsten",
"MessageOr": "of", "MessageOr": "of",
"MessagePauseChapter": "Pauzeer afspelen hoofdstuk", "MessagePauseChapter": "Pauzeer afspelen hoofdstuk",
@@ -549,7 +565,7 @@
"MessagePlaylistCreateFromCollection": "Afspeellijst aanmaken vanuit collectie", "MessagePlaylistCreateFromCollection": "Afspeellijst aanmaken vanuit collectie",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast heeft geen RSS-feed URL om te gebruiken voor matching", "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.", "MessageQuickMatchDescription": "Vul lege onderdeeldetails & cover met eerste matchresultaat van '{0}'. Overschrijft geen details tenzij 'Prefereer gematchte metadata' serverinstelling is ingeschakeld.",
"MessageRemoveAllItemsWarning": "WAARSCHUWING! Deze actie zal alle onderdelen in de bibliotheek verwijderen uit de database, inclusief enige updates of matches die je hebt gemaakt. Dit doet niets met je onderliggende bestanden. Weet je het zeker?", "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", "MessageRemoveChapter": "Verwijder hoofdstuk",
"MessageRemoveEpisodes": "Verwijder {0} aflevering(en)", "MessageRemoveEpisodes": "Verwijder {0} aflevering(en)",
"MessageRemoveFromPlayerQueue": "Verwijder uit afspeelwachtrij", "MessageRemoveFromPlayerQueue": "Verwijder uit afspeelwachtrij",
@@ -560,8 +576,8 @@
"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.", "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", "MessageSearchResultsFor": "Zoekresultaten voor",
"MessageServerCouldNotBeReached": "Server niet bereikbaar", "MessageServerCouldNotBeReached": "Server niet bereikbaar",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?", "MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?",
"MessageThinking": "Aan het denken...", "MessageThinking": "Aan het denken...",
"MessageUploaderItemFailed": "Uploaden mislukt", "MessageUploaderItemFailed": "Uploaden mislukt",
"MessageUploaderItemSuccess": "Uploaden gelukt!", "MessageUploaderItemSuccess": "Uploaden gelukt!",
@@ -569,8 +585,8 @@
"MessageValidCronExpression": "Geldige cron-uitdrukking", "MessageValidCronExpression": "Geldige cron-uitdrukking",
"MessageWatcherIsDisabledGlobally": "Watcher is globaal uitgeschakeld in serverinstellingen", "MessageWatcherIsDisabledGlobally": "Watcher is globaal uitgeschakeld in serverinstellingen",
"MessageXLibraryIsEmpty": "{0} bibliotheek is leeg!", "MessageXLibraryIsEmpty": "{0} bibliotheek is leeg!",
"MessageYourAudiobookDurationIsLonger": "Duur van jouw audioboek is langer dan de gevonden duur", "MessageYourAudiobookDurationIsLonger": "Duur van je audioboek is langer dan de gevonden duur",
"MessageYourAudiobookDurationIsShorter": "Duur van jouw audioboek is korter 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", "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.", "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", "NoteFolderPicker": "Opmerking: Reeds gemapte mappen worden niet getoond",
@@ -585,14 +601,14 @@
"PlaceholderNewPlaylist": "Nieuwe naam afspeellijst", "PlaceholderNewPlaylist": "Nieuwe naam afspeellijst",
"PlaceholderSearch": "Zoeken..", "PlaceholderSearch": "Zoeken..",
"PlaceholderSearchEpisode": "Aflevering zoeken..", "PlaceholderSearchEpisode": "Aflevering zoeken..",
"ToastAccountUpdateFailed": "Updaten account mislukt", "ToastAccountUpdateFailed": "Bijwerken account mislukt",
"ToastAccountUpdateSuccess": "Account geüpdatet", "ToastAccountUpdateSuccess": "Account bijgewerkt",
"ToastAuthorImageRemoveFailed": "Afbeelding verwijderen mislukt", "ToastAuthorImageRemoveFailed": "Afbeelding verwijderen mislukt",
"ToastAuthorImageRemoveSuccess": "Afbeelding auteur verwijderd", "ToastAuthorImageRemoveSuccess": "Afbeelding auteur verwijderd",
"ToastAuthorUpdateFailed": "Updaten auteur mislukt", "ToastAuthorUpdateFailed": "Bijwerken auteur mislukt",
"ToastAuthorUpdateMerged": "Auteur samengevoegd", "ToastAuthorUpdateMerged": "Auteur samengevoegd",
"ToastAuthorUpdateSuccess": "Auteur geüpdatet", "ToastAuthorUpdateSuccess": "Auteur bijgewerkt",
"ToastAuthorUpdateSuccessNoImageFound": "Auteur geüpdatet (geen afbeelding gevonden)", "ToastAuthorUpdateSuccessNoImageFound": "Auteur bijgewerkt (geen afbeelding gevonden)",
"ToastBackupCreateFailed": "Back-up maken mislukt", "ToastBackupCreateFailed": "Back-up maken mislukt",
"ToastBackupCreateSuccess": "Back-up gemaakt", "ToastBackupCreateSuccess": "Back-up gemaakt",
"ToastBackupDeleteFailed": "Verwijderen back-up mislukt", "ToastBackupDeleteFailed": "Verwijderen back-up mislukt",
@@ -600,27 +616,27 @@
"ToastBackupRestoreFailed": "Herstellen back-up mislukt", "ToastBackupRestoreFailed": "Herstellen back-up mislukt",
"ToastBackupUploadFailed": "Uploaden back-up mislukt", "ToastBackupUploadFailed": "Uploaden back-up mislukt",
"ToastBackupUploadSuccess": "Back-up geüpload", "ToastBackupUploadSuccess": "Back-up geüpload",
"ToastBatchUpdateFailed": "Bulk-update mislukt", "ToastBatchUpdateFailed": "Bulk-bijwerking mislukt",
"ToastBatchUpdateSuccess": "Bulk-update gelukt", "ToastBatchUpdateSuccess": "Bulk-bijwerking gelukt",
"ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt", "ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt",
"ToastBookmarkCreateSuccess": "boekwijzer toegevoegd", "ToastBookmarkCreateSuccess": "boekwijzer toegevoegd",
"ToastBookmarkRemoveFailed": "Verwijderen boekwijzer mislukt", "ToastBookmarkRemoveFailed": "Verwijderen boekwijzer mislukt",
"ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd", "ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd",
"ToastBookmarkUpdateFailed": "Updaten boekwijzer mislukt", "ToastBookmarkUpdateFailed": "Bijwerken boekwijzer mislukt",
"ToastBookmarkUpdateSuccess": "Boekwijzer geüpdatet", "ToastBookmarkUpdateSuccess": "Boekwijzer bijgewerkt",
"ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten", "ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten",
"ToastChaptersMustHaveTitles": "Hoofdstukken moeten titels hebben", "ToastChaptersMustHaveTitles": "Hoofdstukken moeten titels hebben",
"ToastCollectionItemsRemoveFailed": "Verwijderen onderdeel (of onderdelen) uit collectie mislukt", "ToastCollectionItemsRemoveFailed": "Verwijderen onderdeel (of onderdelen) uit collectie mislukt",
"ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie", "ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie",
"ToastCollectionRemoveFailed": "Verwijderen collectie mislukt", "ToastCollectionRemoveFailed": "Verwijderen collectie mislukt",
"ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionRemoveSuccess": "Collectie verwijderd",
"ToastCollectionUpdateFailed": "Updaten collectie mislukt", "ToastCollectionUpdateFailed": "Bijwerken collectie mislukt",
"ToastCollectionUpdateSuccess": "Collectie geüpdatet", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
"ToastItemCoverUpdateFailed": "Updaten cover onderdeel mislukt", "ToastItemCoverUpdateFailed": "Bijwerken cover onderdeel mislukt",
"ToastItemCoverUpdateSuccess": "Cover onderdeel geüpdatet", "ToastItemCoverUpdateSuccess": "Cover onderdeel bijgewerkt",
"ToastItemDetailsUpdateFailed": "Updaten details onderdeel mislukt", "ToastItemDetailsUpdateFailed": "Bijwerken details onderdeel mislukt",
"ToastItemDetailsUpdateSuccess": "Details onderdeel geüpdatet", "ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt",
"ToastItemDetailsUpdateUnneeded": "Geen updates nodig voor details onderdeel", "ToastItemDetailsUpdateUnneeded": "Geen bijwerking nodig voor details onderdeel",
"ToastItemMarkedAsFinishedFailed": "Markeren als Voltooid mislukt", "ToastItemMarkedAsFinishedFailed": "Markeren als Voltooid mislukt",
"ToastItemMarkedAsFinishedSuccess": "Onderdeel gemarkeerd als Voltooid", "ToastItemMarkedAsFinishedSuccess": "Onderdeel gemarkeerd als Voltooid",
"ToastItemMarkedAsNotFinishedFailed": "Markeren als Niet Voltooid mislukt", "ToastItemMarkedAsNotFinishedFailed": "Markeren als Niet Voltooid mislukt",
@@ -631,22 +647,24 @@
"ToastLibraryDeleteSuccess": "Bibliotheek verwijderd", "ToastLibraryDeleteSuccess": "Bibliotheek verwijderd",
"ToastLibraryScanFailedToStart": "Starten scan mislukt", "ToastLibraryScanFailedToStart": "Starten scan mislukt",
"ToastLibraryScanStarted": "Scannen bibliotheek gestart", "ToastLibraryScanStarted": "Scannen bibliotheek gestart",
"ToastLibraryUpdateFailed": "Updaten bibliotheek mislukt", "ToastLibraryUpdateFailed": "Bijwerken bibliotheek mislukt",
"ToastLibraryUpdateSuccess": "Bibliotheek \"{0}\" geüpdatet", "ToastLibraryUpdateSuccess": "Bibliotheek \"{0}\" bijgewerkt",
"ToastPlaylistCreateFailed": "Aanmaken afspeellijst mislukt", "ToastPlaylistCreateFailed": "Aanmaken afspeellijst mislukt",
"ToastPlaylistCreateSuccess": "Afspeellijst aangemaakt", "ToastPlaylistCreateSuccess": "Afspeellijst aangemaakt",
"ToastPlaylistRemoveFailed": "Verwijderen afspeellijst mislukt", "ToastPlaylistRemoveFailed": "Verwijderen afspeellijst mislukt",
"ToastPlaylistRemoveSuccess": "Afspeellijst verwijderd", "ToastPlaylistRemoveSuccess": "Afspeellijst verwijderd",
"ToastPlaylistUpdateFailed": "Afspeellijst updaten mislukt", "ToastPlaylistUpdateFailed": "Afspeellijst bijwerken mislukt",
"ToastPlaylistUpdateSuccess": "Afspeellijst geüpdatet", "ToastPlaylistUpdateSuccess": "Afspeellijst bijgewerkt",
"ToastPodcastCreateFailed": "Podcast aanmaken mislukt", "ToastPodcastCreateFailed": "Podcast aanmaken mislukt",
"ToastPodcastCreateSuccess": "Podcast aangemaakt", "ToastPodcastCreateSuccess": "Podcast aangemaakt",
"ToastRemoveItemFromCollectionFailed": "Onderdeel verwijderen uit collectie mislukt", "ToastRemoveItemFromCollectionFailed": "Onderdeel verwijderen uit collectie mislukt",
"ToastRemoveItemFromCollectionSuccess": "Onderdeel verwijderd uit collectie", "ToastRemoveItemFromCollectionSuccess": "Onderdeel verwijderd uit collectie",
"ToastRSSFeedCloseFailed": "Sluiten RSS-feed mislukt", "ToastRSSFeedCloseFailed": "Sluiten RSS-feed mislukt",
"ToastRSSFeedCloseSuccess": "RSS-feed gesloten", "ToastRSSFeedCloseSuccess": "RSS-feed gesloten",
"ToastSeriesUpdateFailed": "Serie update mislukt", "ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSeriesUpdateSuccess": "Serie update gelukt", "ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Bijwerken serie mislukt",
"ToastSeriesUpdateSuccess": "Bijwerken serie gelukt",
"ToastSessionDeleteFailed": "Verwijderen sessie mislukt", "ToastSessionDeleteFailed": "Verwijderen sessie mislukt",
"ToastSessionDeleteSuccess": "Sessie verwijderd", "ToastSessionDeleteSuccess": "Sessie verwijderd",
"ToastSocketConnected": "Socket verbonden", "ToastSocketConnected": "Socket verbonden",
+18
View File
@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Usuń wszystko", "ButtonRemoveAll": "Usuń wszystko",
"ButtonRemoveAllLibraryItems": "Usuń wszystkie elementy z biblioteki", "ButtonRemoveAllLibraryItems": "Usuń wszystkie elementy z biblioteki",
"ButtonRemoveFromContinueListening": "Usuń z listy odtwarzania", "ButtonRemoveFromContinueListening": "Usuń z listy odtwarzania",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Usuń serię z listy odtwarzania", "ButtonRemoveSeriesFromContinueSeries": "Usuń serię z listy odtwarzania",
"ButtonReScan": "Ponowne skanowanie", "ButtonReScan": "Ponowne skanowanie",
"ButtonReset": "Resetowanie", "ButtonReset": "Resetowanie",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Eksportuj jako plik M4B", "ButtonStartM4BEncode": "Eksportuj jako plik M4B",
"ButtonStartMetadataEmbed": "Osadź metadane", "ButtonStartMetadataEmbed": "Osadź metadane",
"ButtonSubmit": "Zaloguj", "ButtonSubmit": "Zaloguj",
"ButtonTest": "Test",
"ButtonUpload": "Wgraj", "ButtonUpload": "Wgraj",
"ButtonUploadBackup": "Wgraj kopię zapasową", "ButtonUploadBackup": "Wgraj kopię zapasową",
"ButtonUploadCover": "Wgraj okładkę", "ButtonUploadCover": "Wgraj okładkę",
@@ -96,7 +98,10 @@
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Szczegóły", "HeaderDetails": "Szczegóły",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Rozdziały", "HeaderEpisodes": "Rozdziały",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Pliki", "HeaderFiles": "Pliki",
"HeaderFindChapters": "Wyszukaj rozdziały", "HeaderFindChapters": "Wyszukaj rozdziały",
"HeaderIgnoredFiles": "Zignoruj pliki", "HeaderIgnoredFiles": "Zignoruj pliki",
@@ -197,6 +202,7 @@
"LabelComplete": "Ukończone", "LabelComplete": "Ukończone",
"LabelConfirmPassword": "Potwierdź hasło", "LabelConfirmPassword": "Potwierdź hasło",
"LabelContinueListening": "Kontynuuj odtwarzanie", "LabelContinueListening": "Kontynuuj odtwarzanie",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Kontynuuj serię", "LabelContinueSeries": "Kontynuuj serię",
"LabelCover": "Okładka", "LabelCover": "Okładka",
"LabelCoverImageURL": "URL okładki", "LabelCoverImageURL": "URL okładki",
@@ -216,7 +222,12 @@
"LabelDownload": "Pobierz", "LabelDownload": "Pobierz",
"LabelDuration": "Czas trwania", "LabelDuration": "Czas trwania",
"LabelDurationFound": "Znaleziona długość:", "LabelDurationFound": "Znaleziona długość:",
"LabelEbook": "Ebook",
"LabelEdit": "Edytuj", "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", "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Włącz", "LabelEnable": "Włącz",
"LabelEnd": "Zakończ", "LabelEnd": "Zakończ",
@@ -239,6 +250,7 @@
"LabelGenre": "Gatunek", "LabelGenre": "Gatunek",
"LabelGenres": "Gatunki", "LabelGenres": "Gatunki",
"LabelHardDeleteFile": "Usuń trwale plik", "LabelHardDeleteFile": "Usuń trwale plik",
"LabelHost": "Host",
"LabelHour": "Godzina", "LabelHour": "Godzina",
"LabelIcon": "Ikona", "LabelIcon": "Ikona",
"LabelIncludeInTracklist": "Dołącz do listy odtwarzania", "LabelIncludeInTracklist": "Dołącz do listy odtwarzania",
@@ -324,6 +336,7 @@
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasty", "LabelPodcasts": "Podcasty",
"LabelPodcastType": "Podcast Type", "LabelPodcastType": "Podcast Type",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)", "LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories", "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Postęp", "LabelProgress": "Postęp",
@@ -331,6 +344,7 @@
"LabelPubDate": "Data publikacji", "LabelPubDate": "Data publikacji",
"LabelPublisher": "Wydawca", "LabelPublisher": "Wydawca",
"LabelPublishYear": "Rok publikacji", "LabelPublishYear": "Rok publikacji",
"LabelReadAgain": "Read Again",
"LabelRecentlyAdded": "Niedawno dodany", "LabelRecentlyAdded": "Niedawno dodany",
"LabelRecentSeries": "Ostatnie serie", "LabelRecentSeries": "Ostatnie serie",
"LabelRecommended": "Recommended", "LabelRecommended": "Recommended",
@@ -347,6 +361,7 @@
"LabelSearchTitle": "Wyszukaj tytuł", "LabelSearchTitle": "Wyszukaj tytuł",
"LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN", "LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN",
"LabelSeason": "Sezon", "LabelSeason": "Sezon",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Kolejność", "LabelSequence": "Kolejność",
"LabelSeries": "Serie", "LabelSeries": "Serie",
"LabelSeriesName": "Nazwy serii", "LabelSeriesName": "Nazwy serii",
@@ -491,6 +506,7 @@
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "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.", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", "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", "MessageDownloadingEpisode": "Pobieranie odcinka",
"MessageDragFilesIntoTrackOrder": "przeciągnij pliki aby ustawić właściwą kolejność utworów", "MessageDragFilesIntoTrackOrder": "przeciągnij pliki aby ustawić właściwą kolejność utworów",
"MessageEmbedFinished": "Osadzanie zakończone!", "MessageEmbedFinished": "Osadzanie zakończone!",
@@ -645,6 +661,8 @@
"ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji", "ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji",
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się", "ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
"ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS 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", "ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success", "ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Nie udało się usunąć sesji", "ToastSessionDeleteFailed": "Nie udało się usunąć sesji",
+18
View File
@@ -55,6 +55,7 @@
"ButtonRemoveAll": "Удалить всё", "ButtonRemoveAll": "Удалить всё",
"ButtonRemoveAllLibraryItems": "Удалить все элементы библиотеки", "ButtonRemoveAllLibraryItems": "Удалить все элементы библиотеки",
"ButtonRemoveFromContinueListening": "Удалить из Продолжить слушать", "ButtonRemoveFromContinueListening": "Удалить из Продолжить слушать",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию", "ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию",
"ButtonReScan": "Пересканировать", "ButtonReScan": "Пересканировать",
"ButtonReset": "Сбросить", "ButtonReset": "Сбросить",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "Начать кодирование M4B", "ButtonStartM4BEncode": "Начать кодирование M4B",
"ButtonStartMetadataEmbed": "Начать встраивание метаданных", "ButtonStartMetadataEmbed": "Начать встраивание метаданных",
"ButtonSubmit": "Применить", "ButtonSubmit": "Применить",
"ButtonTest": "Test",
"ButtonUpload": "Загрузить", "ButtonUpload": "Загрузить",
"ButtonUploadBackup": "Загрузить бэкап", "ButtonUploadBackup": "Загрузить бэкап",
"ButtonUploadCover": "Загрузить обложку", "ButtonUploadCover": "Загрузить обложку",
@@ -96,7 +98,10 @@
"HeaderCurrentDownloads": "Текущие закачки", "HeaderCurrentDownloads": "Текущие закачки",
"HeaderDetails": "Подробности", "HeaderDetails": "Подробности",
"HeaderDownloadQueue": "Очередь скачивания", "HeaderDownloadQueue": "Очередь скачивания",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Эпизоды", "HeaderEpisodes": "Эпизоды",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "Файлы", "HeaderFiles": "Файлы",
"HeaderFindChapters": "Найти главы", "HeaderFindChapters": "Найти главы",
"HeaderIgnoredFiles": "Игнорируемые Файлы", "HeaderIgnoredFiles": "Игнорируемые Файлы",
@@ -197,6 +202,7 @@
"LabelComplete": "Завершить", "LabelComplete": "Завершить",
"LabelConfirmPassword": "Подтвердить пароль", "LabelConfirmPassword": "Подтвердить пароль",
"LabelContinueListening": "Продолжить слушать", "LabelContinueListening": "Продолжить слушать",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "Продолжить серию", "LabelContinueSeries": "Продолжить серию",
"LabelCover": "Обложка", "LabelCover": "Обложка",
"LabelCoverImageURL": "URL изображения обложки", "LabelCoverImageURL": "URL изображения обложки",
@@ -216,7 +222,12 @@
"LabelDownload": "Скачать", "LabelDownload": "Скачать",
"LabelDuration": "Длина", "LabelDuration": "Длина",
"LabelDurationFound": "Найденная длина:", "LabelDurationFound": "Найденная длина:",
"LabelEbook": "Ebook",
"LabelEdit": "Редактировать", "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", "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Включить", "LabelEnable": "Включить",
"LabelEnd": "Конец", "LabelEnd": "Конец",
@@ -239,6 +250,7 @@
"LabelGenre": "Жанр", "LabelGenre": "Жанр",
"LabelGenres": "Жанры", "LabelGenres": "Жанры",
"LabelHardDeleteFile": "Жесткое удаление файла", "LabelHardDeleteFile": "Жесткое удаление файла",
"LabelHost": "Host",
"LabelHour": "Часы", "LabelHour": "Часы",
"LabelIcon": "Иконка", "LabelIcon": "Иконка",
"LabelIncludeInTracklist": "Включать в список воспроизведения", "LabelIncludeInTracklist": "Включать в список воспроизведения",
@@ -324,6 +336,7 @@
"LabelPodcast": "Подкаст", "LabelPodcast": "Подкаст",
"LabelPodcasts": "Подкасты", "LabelPodcasts": "Подкасты",
"LabelPodcastType": "Тип подкаста", "LabelPodcastType": "Тип подкаста",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Игнорируемые префиксы (без учета регистра)", "LabelPrefixesToIgnore": "Игнорируемые префиксы (без учета регистра)",
"LabelPreventIndexing": "Запретить индексацию фида каталогами подкастов iTunes и Google", "LabelPreventIndexing": "Запретить индексацию фида каталогами подкастов iTunes и Google",
"LabelProgress": "Прогресс", "LabelProgress": "Прогресс",
@@ -331,6 +344,7 @@
"LabelPubDate": "Дата публикации", "LabelPubDate": "Дата публикации",
"LabelPublisher": "Издатель", "LabelPublisher": "Издатель",
"LabelPublishYear": "Год публикации", "LabelPublishYear": "Год публикации",
"LabelReadAgain": "Read Again",
"LabelRecentlyAdded": "Недавно добавленные", "LabelRecentlyAdded": "Недавно добавленные",
"LabelRecentSeries": "Последние серии", "LabelRecentSeries": "Последние серии",
"LabelRecommended": "Рекомендованное", "LabelRecommended": "Рекомендованное",
@@ -347,6 +361,7 @@
"LabelSearchTitle": "Поиск по названию", "LabelSearchTitle": "Поиск по названию",
"LabelSearchTitleOrASIN": "Поиск по названию или ASIN", "LabelSearchTitleOrASIN": "Поиск по названию или ASIN",
"LabelSeason": "Сезон", "LabelSeason": "Сезон",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Последовательность", "LabelSequence": "Последовательность",
"LabelSeries": "Серия", "LabelSeries": "Серия",
"LabelSeriesName": "Имя серии", "LabelSeriesName": "Имя серии",
@@ -491,6 +506,7 @@
"MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?", "MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?",
"MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.", "MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.",
"MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".", "MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "Эпизод скачивается", "MessageDownloadingEpisode": "Эпизод скачивается",
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков", "MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
"MessageEmbedFinished": "Встраивание завершено!", "MessageEmbedFinished": "Встраивание завершено!",
@@ -645,6 +661,8 @@
"ToastRemoveItemFromCollectionSuccess": "Элемент удален из коллекции", "ToastRemoveItemFromCollectionSuccess": "Элемент удален из коллекции",
"ToastRSSFeedCloseFailed": "Не удалось закрыть RSS-канал", "ToastRSSFeedCloseFailed": "Не удалось закрыть RSS-канал",
"ToastRSSFeedCloseSuccess": "RSS-канал закрыт", "ToastRSSFeedCloseSuccess": "RSS-канал закрыт",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "Не удалось обновить серию", "ToastSeriesUpdateFailed": "Не удалось обновить серию",
"ToastSeriesUpdateSuccess": "Успешное обновление серии", "ToastSeriesUpdateSuccess": "Успешное обновление серии",
"ToastSessionDeleteFailed": "Не удалось удалить сеанс", "ToastSessionDeleteFailed": "Не удалось удалить сеанс",
+18
View File
@@ -55,6 +55,7 @@
"ButtonRemoveAll": "移除所有", "ButtonRemoveAll": "移除所有",
"ButtonRemoveAllLibraryItems": "移除所有媒体库项目", "ButtonRemoveAllLibraryItems": "移除所有媒体库项目",
"ButtonRemoveFromContinueListening": "从继续收听中删除", "ButtonRemoveFromContinueListening": "从继续收听中删除",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除", "ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除",
"ButtonReScan": "重新扫描", "ButtonReScan": "重新扫描",
"ButtonReset": "重置", "ButtonReset": "重置",
@@ -73,6 +74,7 @@
"ButtonStartM4BEncode": "开始 M4B 编码", "ButtonStartM4BEncode": "开始 M4B 编码",
"ButtonStartMetadataEmbed": "开始嵌入元数据", "ButtonStartMetadataEmbed": "开始嵌入元数据",
"ButtonSubmit": "提交", "ButtonSubmit": "提交",
"ButtonTest": "Test",
"ButtonUpload": "上传", "ButtonUpload": "上传",
"ButtonUploadBackup": "上传备份", "ButtonUploadBackup": "上传备份",
"ButtonUploadCover": "上传封面", "ButtonUploadCover": "上传封面",
@@ -96,7 +98,10 @@
"HeaderCurrentDownloads": "当前下载", "HeaderCurrentDownloads": "当前下载",
"HeaderDetails": "详情", "HeaderDetails": "详情",
"HeaderDownloadQueue": "下载队列", "HeaderDownloadQueue": "下载队列",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "剧集", "HeaderEpisodes": "剧集",
"HeaderEReaderDevices": "E-Reader Devices",
"HeaderFiles": "文件", "HeaderFiles": "文件",
"HeaderFindChapters": "查找章节", "HeaderFindChapters": "查找章节",
"HeaderIgnoredFiles": "忽略的文件", "HeaderIgnoredFiles": "忽略的文件",
@@ -197,6 +202,7 @@
"LabelComplete": "已完成", "LabelComplete": "已完成",
"LabelConfirmPassword": "确认密码", "LabelConfirmPassword": "确认密码",
"LabelContinueListening": "继续收听", "LabelContinueListening": "继续收听",
"LabelContinueReading": "Continue Reading",
"LabelContinueSeries": "继续收听系列", "LabelContinueSeries": "继续收听系列",
"LabelCover": "封面", "LabelCover": "封面",
"LabelCoverImageURL": "封面图像 URL", "LabelCoverImageURL": "封面图像 URL",
@@ -216,7 +222,12 @@
"LabelDownload": "下载", "LabelDownload": "下载",
"LabelDuration": "持续时间", "LabelDuration": "持续时间",
"LabelDurationFound": "找到持续时间:", "LabelDurationFound": "找到持续时间:",
"LabelEbook": "Ebook",
"LabelEdit": "编辑", "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": "嵌入封面", "LabelEmbeddedCover": "嵌入封面",
"LabelEnable": "启用", "LabelEnable": "启用",
"LabelEnd": "结束", "LabelEnd": "结束",
@@ -239,6 +250,7 @@
"LabelGenre": "流派", "LabelGenre": "流派",
"LabelGenres": "流派", "LabelGenres": "流派",
"LabelHardDeleteFile": "完全删除文件", "LabelHardDeleteFile": "完全删除文件",
"LabelHost": "Host",
"LabelHour": "小时", "LabelHour": "小时",
"LabelIcon": "图标", "LabelIcon": "图标",
"LabelIncludeInTracklist": "包含在音轨列表中", "LabelIncludeInTracklist": "包含在音轨列表中",
@@ -324,6 +336,7 @@
"LabelPodcast": "播客", "LabelPodcast": "播客",
"LabelPodcasts": "播客", "LabelPodcasts": "播客",
"LabelPodcastType": "播客类型", "LabelPodcastType": "播客类型",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "忽略的前缀 (不区分大小写)", "LabelPrefixesToIgnore": "忽略的前缀 (不区分大小写)",
"LabelPreventIndexing": "防止 iTunes 和 Google 播客目录对你的源进行索引", "LabelPreventIndexing": "防止 iTunes 和 Google 播客目录对你的源进行索引",
"LabelProgress": "进度", "LabelProgress": "进度",
@@ -331,6 +344,7 @@
"LabelPubDate": "出版日期", "LabelPubDate": "出版日期",
"LabelPublisher": "出版商", "LabelPublisher": "出版商",
"LabelPublishYear": "发布年份", "LabelPublishYear": "发布年份",
"LabelReadAgain": "Read Again",
"LabelRecentlyAdded": "最近添加", "LabelRecentlyAdded": "最近添加",
"LabelRecentSeries": "最近添加系列", "LabelRecentSeries": "最近添加系列",
"LabelRecommended": "推荐内容", "LabelRecommended": "推荐内容",
@@ -347,6 +361,7 @@
"LabelSearchTitle": "搜索标题", "LabelSearchTitle": "搜索标题",
"LabelSearchTitleOrASIN": "搜索标题或 ASIN", "LabelSearchTitleOrASIN": "搜索标题或 ASIN",
"LabelSeason": "季", "LabelSeason": "季",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "序列", "LabelSequence": "序列",
"LabelSeries": "系列", "LabelSeries": "系列",
"LabelSeriesName": "系列名称", "LabelSeriesName": "系列名称",
@@ -491,6 +506,7 @@
"MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?", "MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?",
"MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.", "MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.",
"MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".", "MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "正在下载剧集", "MessageDownloadingEpisode": "正在下载剧集",
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序", "MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
"MessageEmbedFinished": "嵌入完成!", "MessageEmbedFinished": "嵌入完成!",
@@ -645,6 +661,8 @@
"ToastRemoveItemFromCollectionSuccess": "项目已从收藏中删除", "ToastRemoveItemFromCollectionSuccess": "项目已从收藏中删除",
"ToastRSSFeedCloseFailed": "关闭 RSS 源失败", "ToastRSSFeedCloseFailed": "关闭 RSS 源失败",
"ToastRSSFeedCloseSuccess": "RSS 源已关闭", "ToastRSSFeedCloseSuccess": "RSS 源已关闭",
"ToastSendEbookToDeviceFailed": "Failed to send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSeriesUpdateFailed": "更新系列失败", "ToastSeriesUpdateFailed": "更新系列失败",
"ToastSeriesUpdateSuccess": "系列已更新", "ToastSeriesUpdateSuccess": "系列已更新",
"ToastSessionDeleteFailed": "删除会话失败", "ToastSessionDeleteFailed": "删除会话失败",
+16 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.20", "version": "2.2.22",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.20", "version": "2.2.22",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
@@ -14,6 +14,7 @@
"graceful-fs": "^4.2.10", "graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1", "htmlparser2": "^8.0.1",
"node-tone": "^1.0.1", "node-tone": "^1.0.1",
"nodemailer": "^6.9.2",
"socket.io": "^4.5.4", "socket.io": "^4.5.4",
"xml2js": "^0.5.0" "xml2js": "^0.5.0"
}, },
@@ -835,6 +836,14 @@
"resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz", "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz",
"integrity": "sha512-wi7L0taDZMN6tM5l85TDKHsYzdhqJTtPNgvgpk2zHeZzPt6ZIUZ9vBLTJRRDpm0xzCvbsvFHjAaudeQjLHTE4w==" "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": { "node_modules/nodemon": {
"version": "2.0.20", "version": "2.0.20",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz", "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", "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz",
"integrity": "sha512-wi7L0taDZMN6tM5l85TDKHsYzdhqJTtPNgvgpk2zHeZzPt6ZIUZ9vBLTJRRDpm0xzCvbsvFHjAaudeQjLHTE4w==" "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": { "nodemon": {
"version": "2.0.20", "version": "2.0.20",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.20", "version": "2.2.22",
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -35,6 +35,7 @@
"graceful-fs": "^4.2.10", "graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1", "htmlparser2": "^8.0.1",
"node-tone": "^1.0.1", "node-tone": "^1.0.1",
"nodemailer": "^6.9.2",
"socket.io": "^4.5.4", "socket.io": "^4.5.4",
"xml2js": "^0.5.0" "xml2js": "^0.5.0"
}, },
+1
View File
@@ -120,6 +120,7 @@ class Auth {
user: user.toJSONForBrowser(), user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries), userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSONForBrowser(), serverSettings: this.db.serverSettings.toJSONForBrowser(),
ereaderDevices: this.db.emailSettings.getEReaderDevices(user),
Source: global.Source Source: global.Source
} }
} }
+11
View File
@@ -12,6 +12,7 @@ const Author = require('./objects/entities/Author')
const Series = require('./objects/entities/Series') const Series = require('./objects/entities/Series')
const ServerSettings = require('./objects/settings/ServerSettings') const ServerSettings = require('./objects/settings/ServerSettings')
const NotificationSettings = require('./objects/settings/NotificationSettings') const NotificationSettings = require('./objects/settings/NotificationSettings')
const EmailSettings = require('./objects/settings/EmailSettings')
const PlaybackSession = require('./objects/PlaybackSession') const PlaybackSession = require('./objects/PlaybackSession')
class Db { class Db {
@@ -49,6 +50,7 @@ class Db {
this.serverSettings = null this.serverSettings = null
this.notificationSettings = null this.notificationSettings = null
this.emailSettings = null
// Stores previous version only if upgraded // Stores previous version only if upgraded
this.previousVersion = null this.previousVersion = null
@@ -156,6 +158,10 @@ class Db {
this.notificationSettings = new NotificationSettings() this.notificationSettings = new NotificationSettings()
await this.insertEntity('settings', this.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() global.ServerSettings = this.serverSettings.toJSON()
} }
@@ -202,6 +208,11 @@ class Db {
if (notificationSettings) { if (notificationSettings) {
this.notificationSettings = new NotificationSettings(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) => { const p5 = this.collectionsDb.select(() => true).then((results) => {
+24 -2
View File
@@ -11,6 +11,7 @@ const { version } = require('../package.json')
const dbMigration = require('./utils/dbMigration') const dbMigration = require('./utils/dbMigration')
const filePerms = require('./utils/filePerms') const filePerms = require('./utils/filePerms')
const fileUtils = require('./utils/fileUtils') const fileUtils = require('./utils/fileUtils')
const globals = require('./utils/globals')
const Logger = require('./Logger') const Logger = require('./Logger')
const Auth = require('./Auth') const Auth = require('./Auth')
@@ -24,6 +25,7 @@ const HlsRouter = require('./routers/HlsRouter')
const StaticRouter = require('./routers/StaticRouter') const StaticRouter = require('./routers/StaticRouter')
const NotificationManager = require('./managers/NotificationManager') const NotificationManager = require('./managers/NotificationManager')
const EmailManager = require('./managers/EmailManager')
const CoverManager = require('./managers/CoverManager') const CoverManager = require('./managers/CoverManager')
const AbMergeManager = require('./managers/AbMergeManager') const AbMergeManager = require('./managers/AbMergeManager')
const CacheManager = require('./managers/CacheManager') const CacheManager = require('./managers/CacheManager')
@@ -65,6 +67,7 @@ class Server {
// Managers // Managers
this.taskManager = new TaskManager() this.taskManager = new TaskManager()
this.notificationManager = new NotificationManager(this.db) this.notificationManager = new NotificationManager(this.db)
this.emailManager = new EmailManager(this.db)
this.backupManager = new BackupManager(this.db) this.backupManager = new BackupManager(this.db)
this.logManager = new LogManager(this.db) this.logManager = new LogManager(this.db)
this.cacheManager = new CacheManager() this.cacheManager = new CacheManager()
@@ -75,7 +78,7 @@ class Server {
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager) this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
this.rssFeedManager = new RssFeedManager(this.db) 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) this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
// Routers // Routers
@@ -161,16 +164,35 @@ class Server {
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router) router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.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) router.use('/s', this.authMiddleware.bind(this), this.staticRouter.router)
// EBook static file routes // EBook static file routes
// TODO: Deprecated as of 2.2.21 edge
router.get('/ebook/:library/:folder/*', (req, res) => { router.get('/ebook/:library/:folder/*', (req, res) => {
const library = this.db.libraries.find(lib => lib.id === req.params.library) const library = this.db.libraries.find(lib => lib.id === req.params.library)
if (!library) return res.sendStatus(404) if (!library) return res.sendStatus(404)
const folder = library.folders.find(fol => fol.id === req.params.folder) const folder = library.folders.find(fol => fol.id === req.params.folder)
if (!folder) return res.status(404).send('Folder not found') 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) const fullPath = Path.join(folder.fullPath, remainingPath)
res.sendFile(fullPath) res.sendFile(fullPath)
}) })
+86
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()
+27 -4
View File
@@ -1,27 +1,50 @@
const Logger = require('../Logger')
const Path = require('path') const Path = require('path')
const Logger = require('../Logger')
const fs = require('../libs/fsExtra')
class FileSystemController { class FileSystemController {
constructor() { } constructor() { }
async getPaths(req, res) { 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 return Path.sep + dirname
}) })
// Do not include existing mapped library paths in response // Do not include existing mapped library paths in response
this.db.libraries.forEach(lib => { this.db.libraries.forEach(lib => {
lib.folders.forEach((folder) => { lib.folders.forEach((folder) => {
var dir = folder.fullPath let dir = folder.fullPath
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '') if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
excludedDirs.push(dir) excludedDirs.push(dir)
}) })
}) })
Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`)
res.json({ res.json({
directories: await this.getDirectories(global.appRoot, '/', excludedDirs) 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() module.exports = new FileSystemController()
+10 -2
View File
@@ -709,9 +709,11 @@ class LibraryController {
async getNarrators(req, res) { async getNarrators(req, res) {
const narrators = {} const narrators = {}
req.libraryItems.forEach((li) => { req.libraryItems.forEach((li) => {
if (li.media.metadata.narrators && li.media.metadata.narrators.length) { if (li.media.metadata.narrators?.length) {
li.media.metadata.narrators.forEach((n) => { li.media.metadata.narrators.forEach((n) => {
if (!narrators[n]) { if (typeof n !== 'string') {
Logger.error(`[LibraryController] getNarrators: Invalid narrator "${n}" on book "${li.media.metadata.title}"`)
} else if (!narrators[n]) {
narrators[n] = { narrators[n] = {
id: encodeURIComponent(Buffer.from(n).toString('base64')), id: encodeURIComponent(Buffer.from(n).toString('base64')),
name: n, name: n,
@@ -846,6 +848,12 @@ class LibraryController {
res.json(payload) res.json(payload)
} }
getOPMLFile(req, res) {
const opmlText = this.podcastManager.generateOPMLFileText(req.libraryItems)
res.type('application/xml')
res.send(opmlText)
}
middleware(req, res, next) { middleware(req, res, next) {
if (!req.user.checkCanAccessLibrary(req.params.id)) { if (!req.user.checkCanAccessLibrary(req.params.id)) {
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`) Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
+141 -23
View File
@@ -1,3 +1,4 @@
const Path = require('path')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
@@ -5,6 +6,7 @@ const SocketAuthority = require('../SocketAuthority')
const zipHelpers = require('../utils/zipHelpers') const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index') const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
const { ScanResult } = require('../utils/constants') const { ScanResult } = require('../utils/constants')
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
class LibraryItemController { class LibraryItemController {
constructor() { } constructor() { }
@@ -379,20 +381,23 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
var itemsUpdated = 0 let itemsUpdated = 0
var itemsUnmatched = 0 let itemsUnmatched = 0
var matchData = req.body const options = req.body.options || {}
var options = matchData.options || {} if (!req.body.libraryItemIds?.length) {
var items = matchData.libraryItemIds return res.sendStatus(400)
if (!items || !items.length) {
return res.sendStatus(500)
} }
const libraryItems = req.body.libraryItemIds.map(lid => this.db.getLibraryItem(lid)).filter(li => li)
if (!libraryItems?.length) {
return res.sendStatus(400)
}
res.sendStatus(200) res.sendStatus(200)
for (let i = 0; i < items.length; i++) { for (const libraryItem of libraryItems) {
var libraryItem = this.db.libraryItems.find(_li => _li.id === items[i]) const matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
if (matchResult.updated) { if (matchResult.updated) {
itemsUpdated++ itemsUpdated++
} else if (matchResult.warning) { } else if (matchResult.warning) {
@@ -400,7 +405,7 @@ class LibraryItemController {
} }
} }
var result = { const result = {
success: itemsUpdated > 0, success: itemsUpdated > 0,
updates: itemsUpdated, updates: itemsUpdated,
unmatched: itemsUnmatched unmatched: itemsUnmatched
@@ -408,6 +413,33 @@ class LibraryItemController {
SocketAuthority.clientEmitter(req.user.id, 'batch_quickmatch_complete', result) 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 // DELETE: api/items/all
async deleteAll(req, res) { async deleteAll(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
@@ -432,7 +464,7 @@ class LibraryItemController {
return res.sendStatus(500) return res.sendStatus(500)
} }
var result = await this.scanner.scanLibraryItemById(req.libraryItem.id) const result = await this.scanner.scanLibraryItemByRequest(req.libraryItem)
res.json({ res.json({
result: Object.keys(ScanResult).find(key => ScanResult[key] == result) result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
}) })
@@ -498,19 +530,45 @@ class LibraryItemController {
res.json(toneData) res.json(toneData)
} }
async deleteLibraryFile(req, res) { /**
const libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.ino) * GET api/items/:id/file/:fileid
if (!libraryFile) { *
Logger.error(`[LibraryItemController] Unable to delete library file. Not found. "${req.params.ino}"`) * @param {express.Request} req
return res.sendStatus(404) * @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) => { await fs.remove(libraryFile.metadata.path).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, 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 book has no more media files then mark it as missing
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) { if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
req.libraryItem.setMissing() req.libraryItem.setMissing()
@@ -522,15 +580,76 @@ class LibraryItemController {
res.sendStatus(200) 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
*
* @param {express.Request} req
* @param {express.Response} res
*/
async getEBookFile(req, res) {
const 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)
}
middleware(req, res, next) { middleware(req, res, next) {
const item = this.db.libraryItems.find(li => li.id === req.params.id) req.libraryItem = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404) if (!req.libraryItem?.media) return res.sendStatus(404)
// Check user can access this library item // Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) { if (!req.user.checkCanAccessLibraryItem(req.libraryItem)) {
return res.sendStatus(403) 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')) { if (req.path.includes('/play')) {
// allow POST requests using /play and /play/:episodeId // allow POST requests using /play and /play/:episodeId
} else if (req.method == 'DELETE' && !req.user.canDelete) { } else if (req.method == 'DELETE' && !req.user.canDelete) {
@@ -541,7 +660,6 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
req.libraryItem = item
next() next()
} }
} }
+1 -2
View File
@@ -48,8 +48,7 @@ class MeController {
// DELETE: api/me/progress/:id // DELETE: api/me/progress/:id
async removeMediaProgress(req, res) { async removeMediaProgress(req, res) {
var wasRemoved = req.user.removeMediaProgress(req.params.id) if (!req.user.removeMediaProgress(req.params.id)) {
if (!wasRemoved) {
return res.sendStatus(200) return res.sendStatus(200)
} }
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
+1 -1
View File
@@ -109,7 +109,7 @@ class PodcastController {
res.json({ podcast }) res.json({ podcast })
} }
async getOPMLFeeds(req, res) { async getFeedsFromOPMLText(req, res) {
if (!req.body.opmlText) { if (!req.body.opmlText) {
return res.sendStatus(400) return res.sendStatus(400)
} }
+8 -1
View File
@@ -11,7 +11,7 @@ class SeriesController {
// Add progress map with isFinished flag // Add progress map with isFinished flag
if (include.includes('progress')) { 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 libraryItemsFinished = libraryItemsInSeries.filter(li => {
const mediaProgress = req.user.getMediaProgress(li.id) const mediaProgress = req.user.getMediaProgress(li.id)
return mediaProgress && mediaProgress.isFinished return mediaProgress && mediaProgress.isFinished
@@ -55,6 +55,12 @@ class SeriesController {
const series = this.db.series.find(se => se.id === req.params.id) const series = this.db.series.find(se => se.id === req.params.id)
if (!series) return res.sendStatus(404) 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) { if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[SeriesController] User attempted to delete without permission`, req.user) Logger.warn(`[SeriesController] User attempted to delete without permission`, req.user)
return res.sendStatus(403) return res.sendStatus(403)
@@ -64,6 +70,7 @@ class SeriesController {
} }
req.series = series req.series = series
req.libraryItemsInSeries = libraryItemsInSeries
next() next()
} }
} }
+3 -1
View File
@@ -74,7 +74,9 @@ class SessionController {
// POST: api/session/:id/close // POST: api/session/:id/close
close(req, res) { 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 // DELETE: api/session/:id
+17 -5
View File
@@ -18,6 +18,8 @@ class BookFinder {
this.fantLab = new FantLab() this.fantLab = new FantLab()
this.audiobookCovers = new AudiobookCovers() 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 this.verbose = false
} }
@@ -183,7 +185,7 @@ class BookFinder {
var books = [] var books = []
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4 var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 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') { if (provider === 'google') {
books = await this.getGoogleBooksResults(title, author) books = await this.getGoogleBooksResults(title, author)
@@ -222,19 +224,29 @@ class BookFinder {
} }
async findCovers(provider, title, author, options = {}) { 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}`) Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
var covers = [] const covers = []
searchResults.forEach((result) => { searchResults.forEach((result) => {
if (result.covers && result.covers.length) { if (result.covers && result.covers.length) {
covers = covers.concat(result.covers) covers.push(...result.covers)
} }
if (result.cover) { if (result.cover) {
covers.push(result.cover) covers.push(result.cover)
} }
}) })
return covers return [...(new Set(covers))]
} }
findChapters(asin, region) { findChapters(asin, region) {
+2 -2
View File
@@ -46,7 +46,7 @@ class AbMergeManager {
toneJsonObject: null toneJsonObject: null
} }
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.` const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
task.setData('encode-m4b', 'Encoding M4b', taskDescription, taskData) task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData)
this.taskManager.addTask(task) this.taskManager.addTask(task)
Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`) Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)
@@ -112,7 +112,7 @@ class AbMergeManager {
let toneJsonPath = null let toneJsonPath = null
try { try {
toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json') toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1) await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1, 'audio/mp4')
} catch (error) { } catch (error) {
Logger.error(`[AbMergeManager] Write metadata.json failed`, error) Logger.error(`[AbMergeManager] Write metadata.json failed`, error)
toneJsonPath = null toneJsonPath = null
+9 -3
View File
@@ -34,7 +34,10 @@ class AudioMetadataMangaer {
} }
getToneMetadataObjectForApi(libraryItem) { getToneMetadataObjectForApi(libraryItem) {
return toneHelpers.getToneMetadataObject(libraryItem, libraryItem.media.chapters, libraryItem.media.tracks.length) const audioFiles = libraryItem.media.includedAudioFiles
let mimeType = audioFiles[0].mimeType
if (audioFiles.some(a => a.mimeType !== mimeType)) mimeType = null
return toneHelpers.getToneMetadataObject(libraryItem, libraryItem.media.chapters, libraryItem.media.tracks.length, mimeType)
} }
handleBatchEmbed(user, libraryItems, options = {}) { handleBatchEmbed(user, libraryItems, options = {}) {
@@ -56,6 +59,9 @@ class AudioMetadataMangaer {
// Only writing chapters for single file audiobooks // Only writing chapters for single file audiobooks
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters.map(c => ({ ...c })) : null const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters.map(c => ({ ...c })) : null
let mimeType = audioFiles[0].mimeType
if (audioFiles.some(a => a.mimeType !== mimeType)) mimeType = null
// Create task // Create task
const taskData = { const taskData = {
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
@@ -71,7 +77,7 @@ class AudioMetadataMangaer {
} }
)), )),
coverPath: libraryItem.media.coverPath, coverPath: libraryItem.media.coverPath,
metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length), metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length, mimeType),
itemCachePath, itemCachePath,
chapters, chapters,
options: { options: {
@@ -80,7 +86,7 @@ class AudioMetadataMangaer {
} }
} }
const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".` const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`
task.setData('embed-metadata', 'Embedding Metadata', taskDescription, taskData) task.setData('embed-metadata', 'Embedding Metadata', taskDescription, false, taskData)
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) { if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`) Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`)
+1 -1
View File
@@ -132,7 +132,7 @@ class CronManager {
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem) const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem)
if (!keepAutoDownloading) { // auto download was disabled if (!keepAutoDownloading) { // auto download was disabled
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItem.id) // Filter it out
} }
} }
+73
View File
@@ -0,0 +1,73 @@
const nodemailer = require('nodemailer')
const Logger = require("../Logger")
const SocketAuthority = require('../SocketAuthority')
class EmailManager {
constructor(db) {
this.db = db
}
getTransporter() {
return nodemailer.createTransport(this.db.emailSettings.getTransportObject())
}
async sendTest(res) {
Logger.info(`[EmailManager] Sending test email`)
const transporter = this.getTransporter()
const success = await transporter.verify().catch((error) => {
Logger.error(`[EmailManager] Failed to verify SMTP connection config`, error)
return false
})
if (!success) {
return res.status(400).send('Failed to verify SMTP connection configuration')
}
transporter.sendMail({
from: this.db.emailSettings.fromAddress,
to: this.db.emailSettings.fromAddress,
subject: 'Test email from Audiobookshelf',
text: 'Success!'
}).then((result) => {
Logger.info(`[EmailManager] Test email sent successfully`, result)
res.sendStatus(200)
}).catch((error) => {
Logger.error(`[EmailManager] Failed to send test email`, error)
res.status(400).send(error.message || 'Failed to send test email')
})
}
async sendEBookToDevice(ebookFile, device, res) {
Logger.info(`[EmailManager] Sending ebook "${ebookFile.metadata.filename}" to device "${device.name}"/"${device.email}"`)
const transporter = this.getTransporter()
const success = await transporter.verify().catch((error) => {
Logger.error(`[EmailManager] Failed to verify SMTP connection config`, error)
return false
})
if (!success) {
return res.status(400).send('Failed to verify SMTP connection configuration')
}
transporter.sendMail({
from: this.db.emailSettings.fromAddress,
to: device.email,
html: '<div dir="auto"></div>',
attachments: [
{
filename: ebookFile.metadata.filename,
path: ebookFile.metadata.path,
}
]
}).then((result) => {
Logger.info(`[EmailManager] Ebook sent to device successfully`, result)
res.sendStatus(200)
}).catch((error) => {
Logger.error(`[EmailManager] Failed to send ebook to device`, error)
res.status(400).send(error.message || 'Failed to send ebook to device')
})
}
}
module.exports = EmailManager
+5 -1
View File
@@ -130,6 +130,8 @@ class PlaybackSessionManager {
const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId) const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', { SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id, id: itemProgress.id,
sessionId: session.id,
deviceDescription: session.deviceDescription,
data: itemProgress.toJSON() data: itemProgress.toJSON()
}) })
} }
@@ -239,6 +241,8 @@ class PlaybackSessionManager {
const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId) const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', { SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id, id: itemProgress.id,
sessionId: session.id,
deviceDescription: session.deviceDescription,
data: itemProgress.toJSON() data: itemProgress.toJSON()
}) })
} }
@@ -306,7 +310,7 @@ class PlaybackSessionManager {
// See https://github.com/advplyr/audiobookshelf/issues/868 // See https://github.com/advplyr/audiobookshelf/issues/868
// Remove playback sessions with listening time too high // Remove playback sessions with listening time too high
async removeInvalidSessions() { async removeInvalidSessions() {
const selectFunc = (session) => isNaN(session.timeListening) || Number(session.timeListening) > 3600000000 const selectFunc = (session) => isNaN(session.timeListening) || Number(session.timeListening) > 36000000
const numSessionsRemoved = await this.db.removeEntities('session', selectFunc, true) const numSessionsRemoved = await this.db.removeEntities('session', selectFunc, true)
if (numSessionsRemoved) { if (numSessionsRemoved) {
Logger.info(`[PlaybackSessionManager] Removed ${numSessionsRemoved} invalid playback sessions`) Logger.info(`[PlaybackSessionManager] Removed ${numSessionsRemoved} invalid playback sessions`)
+16 -3
View File
@@ -8,6 +8,7 @@ const { removeFile, downloadFile } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms') const filePerms = require('../utils/filePerms')
const { levenshteinDistance } = require('../utils/index') const { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML') const opmlParser = require('../utils/parsers/parseOPML')
const opmlGenerator = require('../utils/generators/opmlGenerator')
const prober = require('../utils/prober') const prober = require('../utils/prober')
const ffmpegHelpers = require('../utils/ffmpegHelpers') const ffmpegHelpers = require('../utils/ffmpegHelpers')
@@ -78,12 +79,19 @@ class PodcastManager {
libraryId: podcastEpisodeDownload.libraryId, libraryId: podcastEpisodeDownload.libraryId,
libraryItemId: podcastEpisodeDownload.libraryItemId, libraryItemId: podcastEpisodeDownload.libraryItemId,
} }
task.setData('download-podcast-episode', 'Downloading Episode', taskDescription, taskData) task.setData('download-podcast-episode', 'Downloading Episode', taskDescription, false, taskData)
this.taskManager.addTask(task) this.taskManager.addTask(task)
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient()) SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
this.currentDownload = podcastEpisodeDownload this.currentDownload = podcastEpisodeDownload
// If this file already exists then append the episode id to the filename
// e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3"
// this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802)
if (await fs.pathExists(this.currentDownload.targetPath)) {
this.currentDownload.appendEpisodeId = true
}
// Ignores all added files to this dir // Ignores all added files to this dir
this.watcher.addIgnoreDir(this.currentDownload.libraryItem.path) this.watcher.addIgnoreDir(this.currentDownload.libraryItem.path)
@@ -140,8 +148,6 @@ class PodcastManager {
async scanAddPodcastEpisodeAudioFile() { async scanAddPodcastEpisodeAudioFile() {
const libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath) const libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
// TODO: Set meta tags on new audio file
const audioFile = await this.probeAudioFile(libraryFile) const audioFile = await this.probeAudioFile(libraryFile)
if (!audioFile) { if (!audioFile) {
return false return false
@@ -178,6 +184,9 @@ class PodcastManager {
libraryItem.updatedAt = Date.now() libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(libraryItem) await this.db.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
const podcastEpisodeExpanded = podcastEpisode.toJSONExpanded()
podcastEpisodeExpanded.libraryItem = libraryItem.toJSONExpanded()
SocketAuthority.emitter('episode_added', podcastEpisodeExpanded)
if (this.currentDownload.isAutoDownload) { // Notifications only for auto downloaded episodes if (this.currentDownload.isAutoDownload) { // Notifications only for auto downloaded episodes
this.notificationManager.onPodcastEpisodeDownloaded(libraryItem, podcastEpisode) this.notificationManager.onPodcastEpisodeDownloaded(libraryItem, podcastEpisode)
@@ -365,6 +374,10 @@ class PodcastManager {
} }
} }
generateOPMLFileText(libraryItems) {
return opmlGenerator.generate(libraryItems)
}
getDownloadQueueDetails(libraryId = null) { getDownloadQueueDetails(libraryId = null) {
let _currentDownload = this.currentDownload let _currentDownload = this.currentDownload
if (libraryId && _currentDownload?.libraryId !== libraryId) _currentDownload = null if (libraryId && _currentDownload?.libraryId !== libraryId) _currentDownload = null
+52 -9
View File
@@ -2,13 +2,14 @@ const fs = require('../libs/fsExtra')
const Path = require('path') const Path = require('path')
const { version } = require('../../package.json') const { version } = require('../../package.json')
const Logger = require('../Logger') const Logger = require('../Logger')
const abmetadataGenerator = require('../utils/abmetadataGenerator') const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
const LibraryFile = require('./files/LibraryFile') const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book') const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast') const Podcast = require('./mediaTypes/Podcast')
const Video = require('./mediaTypes/Video') const Video = require('./mediaTypes/Video')
const Music = require('./mediaTypes/Music') const Music = require('./mediaTypes/Music')
const { areEquivalent, copyValue, getId, cleanStringForSearch } = require('../utils/index') const { areEquivalent, copyValue, getId, cleanStringForSearch } = require('../utils/index')
const { filePathToPOSIX } = require('../utils/fileUtils')
class LibraryItem { class LibraryItem {
constructor(libraryItem = null) { constructor(libraryItem = null) {
@@ -368,7 +369,7 @@ class LibraryItem {
const fileFoundCheck = this.checkFileFound(lf, true) const fileFoundCheck = this.checkFileFound(lf, true)
if (fileFoundCheck === null) { if (fileFoundCheck === null) {
newLibraryFiles.push(lf) newLibraryFiles.push(lf)
} else if (fileFoundCheck && lf.metadata.format !== 'abs') { // Ignore abs file updates } else if (fileFoundCheck && lf.metadata.format !== 'abs' && lf.metadata.filename !== 'metadata.json') { // Ignore abs file updates
hasUpdated = true hasUpdated = true
existingLibraryFiles.push(lf) existingLibraryFiles.push(lf)
} else { } else {
@@ -499,14 +500,56 @@ class LibraryItem {
// Make sure metadata book dir exists // Make sure metadata book dir exists
await fs.ensureDir(metadataPath) await fs.ensureDir(metadataPath)
} }
metadataPath = Path.join(metadataPath, 'metadata.abs')
return abmetadataGenerator.generate(this, metadataPath).then((success) => { const metadataFileFormat = global.ServerSettings.metadataFileFormat
this.isSavingMetadata = false const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`)
if (!success) Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataPath}"`) if (metadataFileFormat === 'json') {
else Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataPath}"`) // Remove metadata.abs if it exists
return success if (await fs.pathExists(Path.join(metadataPath, `metadata.abs`))) {
}) Logger.debug(`[LibraryItem] Removing metadata.abs for item "${this.media.metadata.title}"`)
await fs.remove(Path.join(metadataPath, `metadata.abs`))
this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`)))
}
return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => {
this.isSavingMetadata = false
// Add metadata.json to libraryFiles array if it is new
if (global.ServerSettings.storeMetadataWithItem && !this.libraryFiles.some(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))) {
const newLibraryFile = new LibraryFile()
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
this.libraryFiles.push(newLibraryFile)
}
return true
}).catch((error) => {
this.isSavingMetadata = false
Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
return false
})
} else {
// Remove metadata.json if it exists
if (await fs.pathExists(Path.join(metadataPath, `metadata.json`))) {
Logger.debug(`[LibraryItem] Removing metadata.json for item "${this.media.metadata.title}"`)
await fs.remove(Path.join(metadataPath, `metadata.json`))
this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`)))
}
return abmetadataGenerator.generate(this, metadataFilePath).then(async (success) => {
this.isSavingMetadata = false
if (!success) Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataFilePath}"`)
else {
// Add metadata.abs to libraryFiles array if it is new
if (global.ServerSettings.storeMetadataWithItem && !this.libraryFiles.some(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))) {
const newLibraryFile = new LibraryFile()
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`)
this.libraryFiles.push(newLibraryFile)
}
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
}
return success
})
}
} }
removeLibraryFile(ino) { removeLibraryFile(ino) {
+1 -1
View File
@@ -177,7 +177,7 @@ class PlaybackSession {
this.episodeId = episodeId this.episodeId = episodeId
this.mediaType = libraryItem.mediaType this.mediaType = libraryItem.mediaType
this.mediaMetadata = libraryItem.media.metadata.clone() this.mediaMetadata = libraryItem.media.metadata.clone()
this.chapters = (libraryItem.media.chapters || []).map(c => ({ ...c })) // Only book mediaType has chapters this.chapters = libraryItem.media.getChapters(episodeId)
this.displayTitle = libraryItem.media.getPlaybackTitle(episodeId) this.displayTitle = libraryItem.media.getPlaybackTitle(episodeId)
this.displayAuthor = libraryItem.media.getPlaybackAuthor() this.displayAuthor = libraryItem.media.getPlaybackAuthor()
this.coverPath = libraryItem.media.coverPath this.coverPath = libraryItem.media.coverPath
+6 -1
View File
@@ -15,6 +15,8 @@ class PodcastEpisodeDownload {
this.isFinished = false this.isFinished = false
this.failed = false this.failed = false
this.appendEpisodeId = false
this.startedAt = null this.startedAt = null
this.createdAt = null this.createdAt = null
this.finishedAt = null this.finishedAt = null
@@ -29,6 +31,7 @@ class PodcastEpisodeDownload {
libraryId: this.libraryId || null, libraryId: this.libraryId || null,
isFinished: this.isFinished, isFinished: this.isFinished,
failed: this.failed, failed: this.failed,
appendEpisodeId: this.appendEpisodeId,
startedAt: this.startedAt, startedAt: this.startedAt,
createdAt: this.createdAt, createdAt: this.createdAt,
finishedAt: this.finishedAt, finishedAt: this.finishedAt,
@@ -52,7 +55,9 @@ class PodcastEpisodeDownload {
} }
get targetFilename() { get targetFilename() {
return sanitizeFilename(`${this.podcastEpisode.title}.${this.fileExtension}`) const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : ''
const filename = `${this.podcastEpisode.title}${appendage}.${this.fileExtension}`
return sanitizeFilename(filename)
} }
get targetPath() { get targetPath() {
return Path.join(this.libraryItem.path, this.targetFilename) return Path.join(this.libraryItem.path, this.targetFilename)
+1 -1
View File
@@ -10,7 +10,7 @@ const Ffmpeg = require('../libs/fluentFfmpeg')
const { secondsToTimestamp } = require('../utils/index') const { secondsToTimestamp } = require('../utils/index')
const { writeConcatFile } = require('../utils/ffmpegHelpers') const { writeConcatFile } = require('../utils/ffmpegHelpers')
const { AudioMimeType } = require('../utils/constants') const { AudioMimeType } = require('../utils/constants')
const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator') const hlsPlaylistGenerator = require('../utils/generators/hlsPlaylistGenerator')
const AudioTrack = require('./files/AudioTrack') const AudioTrack = require('./files/AudioTrack')
class Stream extends EventEmitter { class Stream extends EventEmitter {
+8 -2
View File
@@ -9,6 +9,7 @@ class Task {
this.title = null this.title = null
this.description = null this.description = null
this.error = null this.error = null
this.showSuccess = false // If true client side should keep the task visible after success
this.isFailed = false this.isFailed = false
this.isFinished = false this.isFinished = false
@@ -25,6 +26,7 @@ class Task {
title: this.title, title: this.title,
description: this.description, description: this.description,
error: this.error, error: this.error,
showSuccess: this.showSuccess,
isFailed: this.isFailed, isFailed: this.isFailed,
isFinished: this.isFinished, isFinished: this.isFinished,
startedAt: this.startedAt, startedAt: this.startedAt,
@@ -32,12 +34,13 @@ class Task {
} }
} }
setData(action, title, description, data = {}) { setData(action, title, description, showSuccess, data = {}) {
this.id = getId(action) this.id = getId(action)
this.action = action this.action = action
this.data = { ...data } this.data = { ...data }
this.title = title this.title = title
this.description = description this.description = description
this.showSuccess = showSuccess
this.startedAt = Date.now() this.startedAt = Date.now()
} }
@@ -48,7 +51,10 @@ class Task {
this.setFinished() this.setFinished()
} }
setFinished() { setFinished(newDescription = null) {
if (newDescription) {
this.description = newDescription
}
this.isFinished = true this.isFinished = true
this.finishedAt = Date.now() this.finishedAt = Date.now()
} }
+1 -1
View File
@@ -109,7 +109,7 @@ class PodcastEpisode {
} }
get size() { return this.audioFile.metadata.size } get size() { return this.audioFile.metadata.size }
get enclosureUrl() { get enclosureUrl() {
return this.enclosure ? this.enclosure.url : null return this.enclosure?.url || null
} }
get pubYear() { get pubYear() {
if (!this.publishedAt) return null if (!this.publishedAt) return null
+1
View File
@@ -31,6 +31,7 @@ class AudioTrack {
this.startOffset = startOffset this.startOffset = startOffset
this.duration = audioFile.duration this.duration = audioFile.duration
this.title = audioFile.metadata.filename || '' this.title = audioFile.metadata.filename || ''
// TODO: Switch to /api/items/:id/file/:fileid
this.contentUrl = Path.join(`${global.RouterBasePath}/s/item/${itemId}`, encodeUriPath(audioFile.metadata.relPath)) this.contentUrl = Path.join(`${global.RouterBasePath}/s/item/${itemId}`, encodeUriPath(audioFile.metadata.relPath))
this.mimeType = audioFile.mimeType this.mimeType = audioFile.mimeType
this.codec = audioFile.codec || null this.codec = audioFile.codec || null
+3 -3
View File
@@ -1,5 +1,5 @@
const Path = require('path') const Path = require('path')
const { getFileTimestampsWithIno } = require('../../utils/fileUtils') const { getFileTimestampsWithIno, filePathToPOSIX } = require('../../utils/fileUtils')
const globals = require('../../utils/globals') const globals = require('../../utils/globals')
const FileMetadata = require('../metadata/FileMetadata') const FileMetadata = require('../metadata/FileMetadata')
@@ -59,8 +59,8 @@ class LibraryFile {
var fileMetadata = new FileMetadata() var fileMetadata = new FileMetadata()
fileMetadata.setData(fileTsData) fileMetadata.setData(fileTsData)
fileMetadata.filename = Path.basename(relPath) fileMetadata.filename = Path.basename(relPath)
fileMetadata.path = path fileMetadata.path = filePathToPOSIX(path)
fileMetadata.relPath = relPath fileMetadata.relPath = filePathToPOSIX(relPath)
fileMetadata.ext = Path.extname(relPath) fileMetadata.ext = Path.extname(relPath)
this.ino = fileTsData.ino this.ino = fileTsData.ino
this.metadata = fileMetadata this.metadata = fileMetadata
+1
View File
@@ -28,6 +28,7 @@ class VideoTrack {
this.index = videoFile.index this.index = videoFile.index
this.duration = videoFile.duration this.duration = videoFile.duration
this.title = videoFile.metadata.filename || '' this.title = videoFile.metadata.filename || ''
// TODO: Switch to /api/items/:id/file/:fileid
this.contentUrl = Path.join(`${global.RouterBasePath}/s/item/${itemId}`, encodeUriPath(videoFile.metadata.relPath)) this.contentUrl = Path.join(`${global.RouterBasePath}/s/item/${itemId}`, encodeUriPath(videoFile.metadata.relPath))
this.mimeType = videoFile.mimeType this.mimeType = videoFile.mimeType
this.codec = videoFile.codec this.codec = videoFile.codec
+36 -10
View File
@@ -4,7 +4,7 @@ const BookMetadata = require('../metadata/BookMetadata')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index') const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata') const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata')
const { parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers') const { parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
const abmetadataGenerator = require('../../utils/abmetadataGenerator') const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator')
const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils') const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils')
const AudioFile = require('../files/AudioFile') const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack') const AudioTrack = require('../files/AudioTrack')
@@ -89,6 +89,14 @@ class Book {
} }
} }
toJSONForMetadataFile() {
return {
tags: [...this.tags],
chapters: this.chapters.map(c => ({ ...c })),
metadata: this.metadata.toJSONForMetadataFile()
}
}
get size() { get size() {
var total = 0 var total = 0
this.audioFiles.forEach((af) => total += af.metadata.size) this.audioFiles.forEach((af) => total += af.metadata.size)
@@ -134,6 +142,9 @@ class Book {
get numTracks() { get numTracks() {
return this.tracks.length return this.tracks.length
} }
get isEBookOnly() {
return this.ebookFile && !this.numTracks
}
update(payload) { update(payload) {
const json = this.toJSON() const json = this.toJSON()
@@ -229,7 +240,7 @@ class Book {
// Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found // Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) { async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
let metadataUpdatePayload = {} let metadataUpdatePayload = {}
let tagsUpdated = false let hasUpdated = false
const descTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'desc.txt') const descTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'desc.txt')
if (descTxt) { if (descTxt) {
@@ -248,17 +259,25 @@ class Book {
} }
} }
const metadataIsJSON = global.ServerSettings.metadataFileFormat === 'json'
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs') const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs')
if (metadataAbs) { const metadataJson = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.json')
Logger.debug(`[Book] Found metadata.abs file for "${this.metadata.title}"`)
const metadataText = await readTextFile(metadataAbs.metadata.path) const metadataFile = metadataIsJSON ? metadataJson : metadataAbs
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'book') if (metadataFile) {
Logger.debug(`[Book] Found ${metadataFile.metadata.filename} file for "${this.metadata.title}"`)
const metadataText = await readTextFile(metadataFile.metadata.path)
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'book', metadataIsJSON)
if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) { if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) {
Logger.debug(`[Book] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates) Logger.debug(`[Book] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates)
if (abmetadataUpdates.tags) { // Set media tags if updated if (abmetadataUpdates.tags) { // Set media tags if updated
this.tags = abmetadataUpdates.tags this.tags = abmetadataUpdates.tags
tagsUpdated = true hasUpdated = true
}
if (abmetadataUpdates.chapters) { // Set chapters if updated
this.chapters = abmetadataUpdates.chapters
hasUpdated = true
} }
if (abmetadataUpdates.metadata) { if (abmetadataUpdates.metadata) {
metadataUpdatePayload = { metadataUpdatePayload = {
@@ -267,6 +286,9 @@ class Book {
} }
} }
} }
} else if (metadataAbs || metadataJson) { // Has different metadata file format so mark as updated
Logger.debug(`[Book] Found different format metadata file ${(metadataAbs || metadataJson).metadata.filename}, expecting .${global.ServerSettings.metadataFileFormat} for "${this.metadata.title}"`)
hasUpdated = true
} }
const metadataOpf = textMetadataFiles.find(lf => lf.isOPFFile || lf.metadata.filename === 'metadata.xml') const metadataOpf = textMetadataFiles.find(lf => lf.isOPFFile || lf.metadata.filename === 'metadata.xml')
@@ -280,7 +302,7 @@ class Book {
if (key === 'tags') { // Add tags only if tags are empty if (key === 'tags') { // Add tags only if tags are empty
if (opfMetadata.tags.length && (!this.tags.length || opfMetadataOverrideDetails)) { if (opfMetadata.tags.length && (!this.tags.length || opfMetadataOverrideDetails)) {
this.tags = opfMetadata.tags this.tags = opfMetadata.tags
tagsUpdated = true hasUpdated = true
} }
} else if (key === 'genres') { // Add genres only if genres are empty } else if (key === 'genres') { // Add genres only if genres are empty
if (opfMetadata.genres.length && (!this.metadata.genres.length || opfMetadataOverrideDetails)) { if (opfMetadata.genres.length && (!this.metadata.genres.length || opfMetadataOverrideDetails)) {
@@ -312,9 +334,9 @@ class Book {
} }
if (Object.keys(metadataUpdatePayload).length) { if (Object.keys(metadataUpdatePayload).length) {
return this.metadata.update(metadataUpdatePayload) || tagsUpdated return this.metadata.update(metadataUpdatePayload) || hasUpdated
} }
return tagsUpdated return hasUpdated
} }
searchQuery(query) { searchQuery(query) {
@@ -509,5 +531,9 @@ class Book {
getPlaybackAuthor() { getPlaybackAuthor() {
return this.metadata.authorName return this.metadata.authorName
} }
getChapters() {
return this.chapters?.map(ch => ({ ...ch })) || []
}
} }
module.exports = Book module.exports = Book
+15 -3
View File
@@ -2,7 +2,7 @@ const Logger = require('../../Logger')
const PodcastEpisode = require('../entities/PodcastEpisode') const PodcastEpisode = require('../entities/PodcastEpisode')
const PodcastMetadata = require('../metadata/PodcastMetadata') const PodcastMetadata = require('../metadata/PodcastMetadata')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index') const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
const abmetadataGenerator = require('../../utils/abmetadataGenerator') const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator')
const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils') const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils')
const { createNewSortInstance } = require('../../libs/fastSort') const { createNewSortInstance } = require('../../libs/fastSort')
const naturalSort = createNewSortInstance({ const naturalSort = createNewSortInstance({
@@ -94,6 +94,13 @@ class Podcast {
} }
} }
toJSONForMetadataFile() {
return {
tags: [...this.tags],
metadata: this.metadata.toJSON()
}
}
get size() { get size() {
var total = 0 var total = 0
this.episodes.forEach((ep) => total += ep.size) this.episodes.forEach((ep) => total += ep.size)
@@ -199,10 +206,11 @@ class Podcast {
let metadataUpdatePayload = {} let metadataUpdatePayload = {}
let tagsUpdated = false let tagsUpdated = false
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs') const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs' || lf.metadata.filename === 'metadata.json')
if (metadataAbs) { if (metadataAbs) {
const isJSON = metadataAbs.metadata.filename === 'metadata.json'
const metadataText = await readTextFile(metadataAbs.metadata.path) const metadataText = await readTextFile(metadataAbs.metadata.path)
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'podcast') const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'podcast', isJSON)
if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) { if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) {
Logger.debug(`[Podcast] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates) Logger.debug(`[Podcast] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates)
@@ -331,5 +339,9 @@ class Podcast {
if (!audioFile?.metaTags) return false if (!audioFile?.metaTags) return false
return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails) return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails)
} }
getChapters(episodeId) {
return this.getEpisode(episodeId)?.chapters?.map(ch => ({ ...ch })) || []
}
} }
module.exports = Podcast module.exports = Podcast
+21 -8
View File
@@ -27,9 +27,9 @@ class BookMetadata {
construct(metadata) { construct(metadata) {
this.title = metadata.title this.title = metadata.title
this.subtitle = metadata.subtitle this.subtitle = metadata.subtitle
this.authors = (metadata.authors && metadata.authors.map) ? metadata.authors.map(a => ({ ...a })) : [] this.authors = (metadata.authors?.map) ? metadata.authors.map(a => ({ ...a })) : []
this.narrators = metadata.narrators ? [...metadata.narrators] : [] this.narrators = metadata.narrators ? [...metadata.narrators].filter(n => n) : []
this.series = (metadata.series && metadata.series.map) ? metadata.series.map(s => ({ ...s })) : [] this.series = (metadata.series?.map) ? metadata.series.map(s => ({ ...s })) : []
this.genres = metadata.genres ? [...metadata.genres] : [] this.genres = metadata.genres ? [...metadata.genres] : []
this.publishedYear = metadata.publishedYear || null this.publishedYear = metadata.publishedYear || null
this.publishedDate = metadata.publishedDate || null this.publishedDate = metadata.publishedDate || null
@@ -109,6 +109,16 @@ class BookMetadata {
} }
} }
toJSONForMetadataFile() {
const json = this.toJSON()
json.authors = json.authors.map(au => au.name)
json.series = json.series.map(se => {
if (!se.sequence) return se.name
return `${se.name} #${se.sequence}`
})
return json
}
clone() { clone() {
return new BookMetadata(this.toJSON()) return new BookMetadata(this.toJSON())
} }
@@ -191,8 +201,9 @@ class BookMetadata {
} }
update(payload) { update(payload) {
var json = this.toJSON() const json = this.toJSON()
var hasUpdates = false let hasUpdates = false
for (const key in json) { for (const key in json) {
if (payload[key] !== undefined) { if (payload[key] !== undefined) {
if (!areEquivalent(payload[key], json[key])) { if (!areEquivalent(payload[key], json[key])) {
@@ -230,7 +241,7 @@ class BookMetadata {
updateNarrator(oldNarratorName, newNarratorName) { updateNarrator(oldNarratorName, newNarratorName) {
if (!this.hasNarrator(oldNarratorName)) return false if (!this.hasNarrator(oldNarratorName)) return false
this.narrators = this.narrators.filter(n => n !== oldNarratorName) this.narrators = this.narrators.filter(n => n !== oldNarratorName)
if (!this.hasNarrator(newNarratorName)) { if (newNarratorName && !this.hasNarrator(newNarratorName)) {
this.narrators.push(newNarratorName) this.narrators.push(newNarratorName)
} }
return true return true
@@ -373,8 +384,10 @@ class BookMetadata {
const parsed = parseNameString.parse(authorsTag) const parsed = parseNameString.parse(authorsTag)
if (!parsed) return [] if (!parsed) return []
return (parsed.names || []).map((au) => { return (parsed.names || []).map((au) => {
const findAuthor = this.authors.find(_au => _au.name == au)
return { return {
id: `new-${Math.floor(Math.random() * 1000000)}`, id: findAuthor?.id || `new-${Math.floor(Math.random() * 1000000)}`,
name: au name: au
} }
}) })
@@ -411,7 +424,7 @@ class BookMetadata {
return this.narrators.filter(n => cleanStringForSearch(n).includes(query)) return this.narrators.filter(n => cleanStringForSearch(n).includes(query))
} }
searchQuery(query) { // Returns key if match is found searchQuery(query) { // Returns key if match is found
const keysToCheck = ['title', 'asin', 'isbn'] const keysToCheck = ['title', 'asin', 'isbn', 'subtitle']
for (const key of keysToCheck) { for (const key of keysToCheck) {
if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) { if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) {
return { return {
+101
View File
@@ -0,0 +1,101 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, isNullOrNaN } = require('../../utils')
// REF: https://nodemailer.com/smtp/
class EmailSettings {
constructor(settings = null) {
this.id = 'email-settings'
this.host = null
this.port = 465
this.secure = true
this.user = null
this.pass = null
this.fromAddress = null
// Array of { name:String, email:String }
this.ereaderDevices = []
if (settings) {
this.construct(settings)
}
}
construct(settings) {
this.host = settings.host
this.port = settings.port
this.secure = !!settings.secure
this.user = settings.user
this.pass = settings.pass
this.fromAddress = settings.fromAddress
this.ereaderDevices = settings.ereaderDevices?.map(d => ({ ...d })) || []
}
toJSON() {
return {
id: this.id,
host: this.host,
port: this.port,
secure: this.secure,
user: this.user,
pass: this.pass,
fromAddress: this.fromAddress,
ereaderDevices: this.ereaderDevices.map(d => ({ ...d }))
}
}
update(payload) {
if (!payload) return false
if (payload.port !== undefined) {
if (isNullOrNaN(payload.port)) payload.port = 465
else payload.port = Number(payload.port)
}
if (payload.secure !== undefined) payload.secure = !!payload.secure
if (payload.ereaderDevices !== undefined && !Array.isArray(payload.ereaderDevices)) payload.ereaderDevices = undefined
let hasUpdates = false
const json = this.toJSON()
for (const key in json) {
if (key === 'id') continue
if (payload[key] !== undefined && !areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
hasUpdates = true
}
}
return hasUpdates
}
getTransportObject() {
const payload = {
host: this.host,
secure: this.secure
}
if (this.port) payload.port = this.port
if (this.user && this.pass !== undefined) {
payload.auth = {
user: this.user,
pass: this.pass
}
}
return payload
}
getEReaderDevices(user) {
// Only accessible to admin or up
if (!user.isAdminOrUp) {
return []
}
return this.ereaderDevices.map(d => ({ ...d }))
}
getEReaderDevice(deviceName) {
return this.ereaderDevices.find(d => d.name === deviceName)
}
}
module.exports = EmailSettings
+13 -1
View File
@@ -1,5 +1,4 @@
const { BookshelfView } = require('../../utils/constants') const { BookshelfView } = require('../../utils/constants')
const { isNullOrNaN } = require('../../utils')
const Logger = require('../../Logger') const Logger = require('../../Logger')
class ServerSettings { class ServerSettings {
@@ -21,6 +20,7 @@ class ServerSettings {
// Metadata - choose to store inside users library item folder // Metadata - choose to store inside users library item folder
this.storeCoverWithItem = false this.storeCoverWithItem = false
this.storeMetadataWithItem = false this.storeMetadataWithItem = false
this.metadataFileFormat = 'json'
// Security/Rate limits // Security/Rate limits
this.rateLimitLoginRequests = 10 this.rateLimitLoginRequests = 10
@@ -77,6 +77,7 @@ class ServerSettings {
this.storeCoverWithItem = !!settings.storeCoverWithItem this.storeCoverWithItem = !!settings.storeCoverWithItem
this.storeMetadataWithItem = !!settings.storeMetadataWithItem this.storeMetadataWithItem = !!settings.storeMetadataWithItem
this.metadataFileFormat = settings.metadataFileFormat || 'json'
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10 this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
@@ -112,6 +113,16 @@ class ServerSettings {
if (settings.homeBookshelfView == undefined) { // homeBookshelfView was added in 2.1.3 if (settings.homeBookshelfView == undefined) { // homeBookshelfView was added in 2.1.3
this.homeBookshelfView = settings.bookshelfView this.homeBookshelfView = settings.bookshelfView
} }
if (settings.metadataFileFormat == undefined) { // metadataFileFormat was added in 2.2.21
// All users using old settings will stay abs until changed
this.metadataFileFormat = 'abs'
}
// Validation
if (!['abs', 'json'].includes(this.metadataFileFormat)) {
Logger.error(`[ServerSettings] construct: Invalid metadataFileFormat ${this.metadataFileFormat}`)
this.metadataFileFormat = 'json'
}
if (this.logLevel !== Logger.logLevel) { if (this.logLevel !== Logger.logLevel) {
Logger.setLogLevel(this.logLevel) Logger.setLogLevel(this.logLevel)
@@ -133,6 +144,7 @@ class ServerSettings {
scannerUseTone: this.scannerUseTone, scannerUseTone: this.scannerUseTone,
storeCoverWithItem: this.storeCoverWithItem, storeCoverWithItem: this.storeCoverWithItem,
storeMetadataWithItem: this.storeMetadataWithItem, storeMetadataWithItem: this.storeMetadataWithItem,
metadataFileFormat: this.metadataFileFormat,
rateLimitLoginRequests: this.rateLimitLoginRequests, rateLimitLoginRequests: this.rateLimitLoginRequests,
rateLimitLoginWindow: this.rateLimitLoginWindow, rateLimitLoginWindow: this.rateLimitLoginWindow,
backupSchedule: this.backupSchedule, backupSchedule: this.backupSchedule,
+4 -4
View File
@@ -10,7 +10,7 @@ class MediaProgress {
this.isFinished = false this.isFinished = false
this.hideFromContinueListening = false this.hideFromContinueListening = false
this.ebookLocation = null // current cfi tag this.ebookLocation = null // cfi tag for epub, page number for pdf
this.ebookProgress = null // 0 to 1 this.ebookProgress = null // 0 to 1
this.lastUpdate = null this.lastUpdate = null
@@ -46,18 +46,18 @@ class MediaProgress {
this.episodeId = progress.episodeId this.episodeId = progress.episodeId
this.duration = progress.duration || 0 this.duration = progress.duration || 0
this.progress = progress.progress this.progress = progress.progress
this.currentTime = progress.currentTime this.currentTime = progress.currentTime || 0
this.isFinished = !!progress.isFinished this.isFinished = !!progress.isFinished
this.hideFromContinueListening = !!progress.hideFromContinueListening this.hideFromContinueListening = !!progress.hideFromContinueListening
this.ebookLocation = progress.ebookLocation || null this.ebookLocation = progress.ebookLocation || null
this.ebookProgress = progress.ebookProgress this.ebookProgress = progress.ebookProgress || null
this.lastUpdate = progress.lastUpdate this.lastUpdate = progress.lastUpdate
this.startedAt = progress.startedAt this.startedAt = progress.startedAt
this.finishedAt = progress.finishedAt || null this.finishedAt = progress.finishedAt || null
} }
get inProgress() { get inProgress() {
return !this.isFinished && (this.progress > 0 || this.ebookLocation != null) return !this.isFinished && (this.progress > 0 || (this.ebookLocation != null && this.ebookProgress > 0))
} }
setData(libraryItemId, progress, episodeId = null) { setData(libraryItemId, progress, episodeId = null) {

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