mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-01 16:30:39 +02:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95ebe0f087 | |||
| 0a6aa43b07 | |||
| 806a8cf659 | |||
| 1a32fbfeec | |||
| 67396c16dd | |||
| b0684b6f1b | |||
| 661778c02c | |||
| 5c4241aefe | |||
| 3f6bc90824 | |||
| 4ade6e04a8 | |||
| 49d0835236 | |||
| d90bd92bcc | |||
| 41c016b8c7 | |||
| 5b4d3f71f9 | |||
| 256a9322ef | |||
| 793f82e445 | |||
| ab6da3914b | |||
| 0b53f0ebf3 | |||
| 76d668514e | |||
| 3c347bef7d | |||
| e837e5f780 | |||
| 26348ccc74 | |||
| 729a756e21 | |||
| 4dbddcf179 | |||
| f2fff34d4d | |||
| 59c5e2c1d9 | |||
| 067006f406 | |||
| 93d82b973e | |||
| a9a3423b58 | |||
| f4ee215ad8 | |||
| 48431b1c35 | |||
| ce961f90ba | |||
| 916d2f6bb3 | |||
| 01e7098f00 | |||
| e02fbac4cd | |||
| a8fce32e70 | |||
| d0637c1e3d | |||
| f6702d299d | |||
| 033b7ece28 | |||
| 5f5dce6d53 | |||
| 82c5c7518b | |||
| 7a60ffb3c4 | |||
| 2795f657b5 | |||
| 9ef5b5830e | |||
| 879adfa633 | |||
| b12a344776 | |||
| 50b1098797 | |||
| fdfaa7eba4 | |||
| 5525587513 | |||
| 1f20ed7640 | |||
| f741064843 | |||
| d5138e4c0a | |||
| 42a30c33db | |||
| e5d978f8e8 | |||
| ccc82520a9 | |||
| 22acf52a26 | |||
| 2ccd2786f4 | |||
| 0028136935 | |||
| 0edc46b771 | |||
| 2261f3d1c3 | |||
| 5c0e792782 | |||
| 644882e04f | |||
| 67f51c6de9 | |||
| 0c8fd6ab0e | |||
| 5452a57a14 | |||
| 19f020e7a6 | |||
| 825641f2a9 | |||
| 35ab4cb2fe | |||
| fd13607d89 | |||
| f79b4d44b9 | |||
| 91e30a6e84 | |||
| 8ab0f164a6 | |||
| 578bb03404 | |||
| 06582b5371 | |||
| 7f6baf35b7 | |||
| 6227d0baa1 | |||
| e334b585be | |||
| c83b3f19f7 | |||
| d31ec055f9 | |||
| 38c259a45e | |||
| b2ee24de98 | |||
| 9ba0e52bb7 | |||
| edc712e6f6 | |||
| 485888b2d9 | |||
| c2e90d4d83 | |||
| f5d89b8f52 | |||
| 378b40790a | |||
| be3d38392d | |||
| 27fef50983 | |||
| 167df85c1e | |||
| 009e16c9a4 | |||
| b40cc767b2 | |||
| 4f5f2d32be | |||
| 66be3e0281 | |||
| 987f188f00 | |||
| daca2bdf2a | |||
| 8894f52439 | |||
| 863f81e55a | |||
| d03d3735e5 | |||
| 3bb2df6e12 | |||
| 80c9efc618 | |||
| 3279901ab0 | |||
| d43d351721 | |||
| 8210eba439 | |||
| cbd7294b0b | |||
| 6064e8af87 | |||
| 8754f0c25f | |||
| f31700f668 | |||
| 9877b139f6 | |||
| 5643c846ee | |||
| 5a071babe9 | |||
| 3d85d0bce6 | |||
| 68afc2c718 | |||
| b3d9323f66 | |||
| effc63755b | |||
| b90934a72a | |||
| e01748eb2f | |||
| 430fbf5e46 | |||
| 27e6b9ce0d | |||
| fc614b9833 |
+2
-1
@@ -11,6 +11,7 @@ test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
library/
|
||||
|
||||
sw.*
|
||||
.DS_STORE
|
||||
.DS_STORE
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
||||
<div class="flex h-full items-center">
|
||||
<nuxt-link to="/">
|
||||
<img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
||||
<img src="/icon48.png" class="w-8 h-8 mr-8 sm:w-12 sm:h-12 sm:mr-4" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/">
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<ui-libraries-dropdown />
|
||||
|
||||
<controls-global-search v-if="currentLibrary" class="hidden md:block" />
|
||||
<controls-global-search v-if="currentLibrary" class="" />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||
@@ -20,11 +20,11 @@
|
||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
|
||||
</ui-tooltip>
|
||||
<div v-if="isChromecastInitialized" class="w-6 h-6 mr-2 cursor-pointer">
|
||||
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
|
||||
<google-cast-launcher></google-cast-launcher>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
||||
</nuxt-link>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 22px">
|
||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||
<p class="transform text-sm">{{ shelf.label }}</p>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
<div class="w-full h-20 md:h-10 relative">
|
||||
<div class="flex md:hidden h-10 items-center">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p>Home</p>
|
||||
<p class="text-sm">Home</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p>Library</p>
|
||||
<p class="text-sm">Library</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p>Series</p>
|
||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p class="text-sm">Series</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p class="text-sm">Collections</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p class="text-sm">Search</p>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
@@ -98,6 +104,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
@@ -129,6 +138,12 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isPodcastLibrary() {
|
||||
return this.currentLibraryMediaType === 'podcast'
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
@@ -156,6 +171,9 @@ export default {
|
||||
},
|
||||
isIssuesFilter() {
|
||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||
},
|
||||
isPodcastSearchPage() {
|
||||
return this.$route.name === 'library-library-podcast-search'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<span class="material-icons text-2xl">arrow_back</span>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||
<p>{{ route.title }}</p>
|
||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
@@ -66,7 +66,7 @@ export default {
|
||||
},
|
||||
{
|
||||
id: 'config-sessions',
|
||||
title: 'Sessions',
|
||||
title: 'Listening Sessions',
|
||||
path: '/config/sessions'
|
||||
},
|
||||
{
|
||||
@@ -76,7 +76,7 @@ export default {
|
||||
},
|
||||
{
|
||||
id: 'config-log',
|
||||
title: 'Log',
|
||||
title: 'Logs',
|
||||
path: '/config/log'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
<template>
|
||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||
<div id="videoDock" />
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</nuxt-link>
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-24'">
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
|
||||
<div>
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
|
||||
{{ title }}
|
||||
</nuxt-link>
|
||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
||||
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
||||
<p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p>
|
||||
<p 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">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
||||
<p v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">Unknown</p>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-xs">schedule</span>
|
||||
<p class="font-mono text-sm pl-2 pb-px">{{ totalDurationPretty }}</p>
|
||||
<p class="font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
||||
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
|
||||
</div>
|
||||
<player-ui
|
||||
ref="audioPlayer"
|
||||
|
||||
@@ -6,11 +6,18 @@
|
||||
</div>
|
||||
<div v-if="!isPodcast" class="px-4 flex-grow">
|
||||
<div class="flex items-center">
|
||||
<h1>{{ book.title }}</h1>
|
||||
<h1 class="text-base">{{ book.title }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<p>{{ book.publishedYear }}</p>
|
||||
</div>
|
||||
<p class="text-gray-400">{{ book.author }}</p>
|
||||
<p class="text-gray-300 text-sm">{{ book.author }}</p>
|
||||
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
|
||||
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
|
||||
<p class="leading-3 text-xs text-gray-400">
|
||||
{{ series.series }}<span v-if="series.volumeNumber"> #{{ series.volumeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full max-h-12 overflow-hidden">
|
||||
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
||||
</div>
|
||||
|
||||
@@ -62,21 +62,10 @@ export default {
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
if (this.matchKey === 'subtitle') return ''
|
||||
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
|
||||
if (matchSplit.length < 2) return ''
|
||||
|
||||
var html = ''
|
||||
var totalLenSoFar = 0
|
||||
for (let i = 0; i < matchSplit.length - 1; i++) {
|
||||
var indexOf = matchSplit[i].length
|
||||
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
|
||||
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
|
||||
totalLenSoFar += indexOf + this.search.length
|
||||
|
||||
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
|
||||
}
|
||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
||||
html += lastPart
|
||||
// This used to highlight the part of the search found
|
||||
// but with removing commas periods etc this is no longer plausible
|
||||
const html = this.matchText
|
||||
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
|
||||
@@ -144,6 +144,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
@@ -245,12 +248,14 @@ export default {
|
||||
return this.mediaMetadata.authorNameLF
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.recentEpisode) return this.recentEpisode.title
|
||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
|
||||
return this.mediaMetadata.titleIgnorePrefix
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
displayLineTwo() {
|
||||
if (this.recentEpisode) return this.title
|
||||
if (this.isPodcast) return this.author
|
||||
if (this.isAuthorBookshelfView) {
|
||||
return this.mediaMetadata.publishedYear || ''
|
||||
@@ -259,9 +264,9 @@ export default {
|
||||
return this.author
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat)
|
||||
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-80 ml-6 relative">
|
||||
<div class="sm:w-80 w-full sm:ml-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
</form>
|
||||
@@ -7,7 +7,7 @@
|
||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px 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 globalSearchMenu">
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" 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 globalSearchMenu">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li v-if="isTyping" class="py-2 px-2">
|
||||
<p>Thinking...</p>
|
||||
@@ -21,7 +21,7 @@
|
||||
<template v-else>
|
||||
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||
<template v-for="item in bookResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
</nuxt-link>
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
|
||||
<template v-for="item in podcastResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
</nuxt-link>
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
|
||||
<template v-for="item in authorResults">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
|
||||
<cards-author-search-card :author="item" />
|
||||
</nuxt-link>
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
|
||||
<template v-for="item in seriesResults">
|
||||
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
||||
<cards-series-search-card :series="item.series" :book-items="item.books" />
|
||||
</nuxt-link>
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
|
||||
<template v-for="item in tagResults">
|
||||
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
||||
<cards-tag-search-card :tag="item.name" />
|
||||
</nuxt-link>
|
||||
@@ -97,6 +97,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickOption() {
|
||||
this.clearResults()
|
||||
},
|
||||
submitSearch() {
|
||||
if (!this.search) return
|
||||
var search = this.search
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="relative ml-8" v-click-outside="clickOutside">
|
||||
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
|
||||
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
|
||||
<span class="font-mono uppercase text-gray-200">{{ playbackRate.toFixed(1) }}<span class="text-lg">⨯</span></span>
|
||||
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base sm:text-lg">⨯</span></span>
|
||||
</div>
|
||||
<div v-show="showMenu" class="absolute -top-20 left-0 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" style="left: -92px">
|
||||
<div class="absolute -bottom-2 left-0 right-0 w-full flex justify-center">
|
||||
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
|
||||
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="w-full py-1 px-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
||||
<p class="px-2 text-3xl">{{ playbackRate }}<span class="text-2xl">⨯</span></p>
|
||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">⨯</span></p>
|
||||
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,7 +40,9 @@ export default {
|
||||
showMenu: false,
|
||||
currentPlaybackRate: 0,
|
||||
MIN_SPEED: 0.5,
|
||||
MAX_SPEED: 3
|
||||
MAX_SPEED: 3,
|
||||
menuLeft: -92,
|
||||
arrowLeft: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -80,8 +82,22 @@ export default {
|
||||
var newPlaybackRate = this.playbackRate - 0.1
|
||||
this.playbackRate = Number(newPlaybackRate.toFixed(1))
|
||||
},
|
||||
updateMenuPositions() {
|
||||
if (!this.$refs.wrapper) return
|
||||
const boundingBox = this.$refs.wrapper.getBoundingClientRect()
|
||||
|
||||
if (boundingBox.left + 110 > window.innerWidth - 10) {
|
||||
this.menuLeft = window.innerWidth - 230 - boundingBox.left
|
||||
|
||||
this.arrowLeft = Math.abs(this.menuLeft) - 92
|
||||
} else {
|
||||
this.menuLeft = -92
|
||||
this.arrowLeft = 0
|
||||
}
|
||||
},
|
||||
setShowMenu(val) {
|
||||
if (val) {
|
||||
this.updateMenuPositions()
|
||||
this.currentPlaybackRate = this.playbackRate
|
||||
} else if (this.currentPlaybackRate !== this.playbackRate) {
|
||||
this.$emit('change', this.playbackRate)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="cursor-pointer" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||
<span class="material-icons text-3xl">{{ volumeIcon }}</span>
|
||||
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||
</div>
|
||||
<transition name="menux">
|
||||
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'">
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<template v-for="chap in chapters">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||
{{ chap.title }}
|
||||
<p class="chapter-title truncate text-sm md:text-base">
|
||||
{{ chap.title }}
|
||||
</p>
|
||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
|
||||
<span class="flex-grow" />
|
||||
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||
|
||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</div>
|
||||
@@ -70,4 +73,15 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#chapter-modal-wrapper .chapter-title {
|
||||
max-width: calc(100% - 120px);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
#chapter-modal-wrapper .chapter-title {
|
||||
max-width: calc(100% - 150px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="title" class="absolute top-7 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="relative flex items-center px-3">
|
||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
title: String,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selected: String // optional
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedOption(action) {
|
||||
this.$emit('action', action)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose">
|
||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
||||
<span class="material-icons text-2xl md:text-4xl">close</span>
|
||||
</div>
|
||||
<div ref="content" class="text-white">
|
||||
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
|
||||
<div class="bg-bg rounded-lg p-8" @click.stop>
|
||||
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
||||
<div class="flex">
|
||||
<div class="flex-grow p-1 min-w-80">
|
||||
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
|
||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
||||
</div>
|
||||
<div class="w-40 p-1">
|
||||
<div class="w-24 sm:w-28 md:w-40 p-1">
|
||||
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<div class="flex items-center">
|
||||
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
|
||||
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
<div class="absolute top-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||
<slot />
|
||||
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" label="Photo Path/URL" />
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
|
||||
</div>
|
||||
@@ -43,19 +46,13 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
// props: {
|
||||
// value: Boolean,
|
||||
// author: {
|
||||
// type: Object,
|
||||
// default: () => {}
|
||||
// }
|
||||
// },
|
||||
data() {
|
||||
return {
|
||||
authorCopy: {
|
||||
name: '',
|
||||
asin: '',
|
||||
description: ''
|
||||
description: '',
|
||||
imagePath: ''
|
||||
},
|
||||
processing: false
|
||||
}
|
||||
@@ -95,9 +92,10 @@ export default {
|
||||
this.authorCopy.name = this.author.name
|
||||
this.authorCopy.asin = this.author.asin
|
||||
this.authorCopy.description = this.author.description
|
||||
this.authorCopy.imagePath = this.author.imagePath
|
||||
},
|
||||
async submitForm() {
|
||||
var keysToCheck = ['name', 'asin', 'description']
|
||||
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
|
||||
var updatePayload = {}
|
||||
keysToCheck.forEach((key) => {
|
||||
if (this.authorCopy[key] !== this.author[key]) {
|
||||
@@ -167,4 +165,4 @@ export default {
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="75">
|
||||
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
||||
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="font-book text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||
<template v-for="tab in availableTabs">
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,8 @@ export default {
|
||||
return {
|
||||
processing: false,
|
||||
libraryItem: null,
|
||||
availableHeight: 0,
|
||||
marginTop: 0,
|
||||
tabs: [
|
||||
{
|
||||
id: 'details',
|
||||
@@ -133,8 +135,7 @@ export default {
|
||||
})
|
||||
},
|
||||
height() {
|
||||
var maxHeightAllowed = window.innerHeight - 150
|
||||
return Math.min(maxHeightAllowed, 650)
|
||||
return Math.min(this.availableHeight, 650)
|
||||
},
|
||||
tabName() {
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
@@ -246,15 +247,29 @@ export default {
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
window.addEventListener('orientationchange', this.orientationChange)
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||
},
|
||||
unregisterListeners() {
|
||||
window.removeEventListener('orientationchange', this.orientationChange)
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||
},
|
||||
orientationChange() {
|
||||
setTimeout(this.setHeight, 50)
|
||||
},
|
||||
setHeight() {
|
||||
const smAndBelow = window.innerWidth < 1024 && window.innerWidth > window.innerHeight
|
||||
|
||||
this.marginTop = smAndBelow ? 90 : 75
|
||||
const heightModifier = smAndBelow ? 95 : 150
|
||||
this.availableHeight = window.innerHeight - heightModifier
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
mounted() {
|
||||
this.setHeight()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
|
||||
<div class="flex">
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="relative">
|
||||
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<!-- book cover overlay -->
|
||||
@@ -11,14 +11,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow pl-6 pr-2">
|
||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
||||
<div class="flex items-center">
|
||||
<div v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input>
|
||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected"><span class="hidden md:inline-block">Upload Cover</span><span class="material-icons inline-block md:!hidden">upload</span></ui-file-input>
|
||||
</div>
|
||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
||||
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
|
||||
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-2 sm:ml-3 w-24">Update</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,23 +5,24 @@
|
||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
||||
<ui-icon-btn bg-color="error" icon="delete" class="md:hidden" :size="7" icon-font-size="1rem" @click.stop.prevent="removeItem" />
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
||||
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn @click="save" class="mx-2">Save</ui-btn>
|
||||
<ui-btn @click="save" class="mx-2 hidden md:block">Save</ui-btn>
|
||||
|
||||
<ui-btn @click="saveAndClose">Save & Close</ui-btn>
|
||||
<ui-btn @click="saveAndClose">Save<span class="hidden md:inline-block"> & Close</span></ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<!-- Merge to m4b -->
|
||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<div class="mt-2 md:mt-0">
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
@@ -69,7 +69,7 @@
|
||||
|
||||
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
|
||||
<span class="text-error">* <strong>Experimental</strong></span
|
||||
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
|
||||
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 30 minutes.
|
||||
</p>
|
||||
|
||||
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
|
||||
|
||||
@@ -428,7 +428,7 @@ export default {
|
||||
)
|
||||
updatePayload.metadata.authors = authorPayload
|
||||
} else if (key === 'narrator') {
|
||||
updatePayload.metadata.narrators = [this.selectedMatch[key]]
|
||||
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
} else if (key === 'genres') {
|
||||
updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
} else if (key === 'tags') {
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
|
||||
<span class="material-icons text-3xl">first_page</span>
|
||||
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-icons text-3xl">replay_10</span>
|
||||
<span class="material-icons text-2xl sm:text-3xl">replay_10</span>
|
||||
</div>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-icons text-3xl">forward_10</span>
|
||||
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
|
||||
<span class="material-icons text-3xl">last_page</span>
|
||||
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
|
||||
</div>
|
||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||
<controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||
@@ -40,7 +40,16 @@ export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
playbackRateInput: {
|
||||
get() {
|
||||
return this.playbackRate
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:playbackRate', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
playPause() {
|
||||
this.$emit('playPause')
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
||||
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
||||
</div>
|
||||
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
||||
<div class="w-full h-2 relative overflow-hidden" :class="useChapterTrack ? 'opacity-0' : ''">
|
||||
<template v-for="(tick, index) in chapterTicks">
|
||||
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
||||
</template>
|
||||
@@ -34,6 +34,10 @@ export default {
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -46,7 +50,8 @@ export default {
|
||||
trackOffsetLeft: 16, // Track is 16px from edge
|
||||
playedTrackWidth: 0,
|
||||
readyTrackWidth: 0,
|
||||
bufferTrackWidth: 0
|
||||
bufferTrackWidth: 0,
|
||||
useChapterTrack: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -57,14 +62,30 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
currentChapterDuration() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.end - this.currentChapter.start
|
||||
},
|
||||
currentChapterStart() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.start
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setUseChapterTrack(useChapterTrack) {
|
||||
this.useChapterTrack = useChapterTrack
|
||||
this.updateBufferTrack()
|
||||
this.updatePlayedTrackWidth()
|
||||
},
|
||||
clickTrack(e) {
|
||||
if (this.loading) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
var time = perc * this.duration
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
|
||||
const time = baseTime + (perc * duration);
|
||||
if (isNaN(time) || time === null) {
|
||||
console.error('Invalid time', perc, time)
|
||||
return
|
||||
@@ -76,7 +97,10 @@ export default {
|
||||
this.updateBufferTrack()
|
||||
},
|
||||
updateBufferTrack() {
|
||||
var bufferlen = (this.bufferTime / this.duration) * this.trackWidth
|
||||
const time = this.useChapterTrack ? Math.max(0, this.bufferTime - this.currentChapterStart) : this.bufferTime
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
|
||||
var bufferlen = (time / duration) * this.trackWidth
|
||||
bufferlen = Math.round(bufferlen)
|
||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||
@@ -97,8 +121,10 @@ export default {
|
||||
this.updatePlayedTrackWidth()
|
||||
},
|
||||
updatePlayedTrackWidth() {
|
||||
var perc = this.currentTime / this.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
|
||||
var ptWidth = Math.round((time / duration) * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
@@ -116,9 +142,11 @@ export default {
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
var time = (offsetX / this.trackWidth) * this.duration
|
||||
|
||||
console.log('Mousemove track', this.trackWidth, this.duration)
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
|
||||
const progressTime = (offsetX / this.trackWidth) * duration;
|
||||
const totalTime = baseTime + progressTime;
|
||||
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
var width = this.$refs.hoverTimestamp.clientWidth
|
||||
@@ -139,9 +167,9 @@ export default {
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(time)
|
||||
var hoverText = this.$secondsToTimestamp(progressTime)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
hoverText += ` - ${chapter.title}`
|
||||
}
|
||||
|
||||
@@ -1,40 +1,48 @@
|
||||
<template>
|
||||
<div class="w-full -mt-6">
|
||||
<div class="w-full relative mb-1">
|
||||
<div class="absolute -top-10 md:top-0 right-0 md:right-2 flex items-center h-full">
|
||||
<div class="absolute -top-10 md:top-0 right-0 lg:right-2 flex items-center h-full">
|
||||
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
|
||||
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
|
||||
|
||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||
<span v-if="!sleepTimerSet" class="material-icons" style="font-size: 1.7rem">snooze</span>
|
||||
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||
<span v-if="!sleepTimerSet" class="material-icons text-2xl sm:text-2.5xl">snooze</span>
|
||||
<div v-else class="flex items-center">
|
||||
<span class="material-icons text-lg text-warning">snooze</span>
|
||||
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||
<span class="material-icons text-2xl sm:text-2.5xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="chapters.length" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
<div v-if="chapters.length" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-2xl sm:text-3xl">format_list_bulleted</span>
|
||||
</div>
|
||||
|
||||
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? 'Use full track' : 'Use chapter track'">
|
||||
<div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
|
||||
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||
</div>
|
||||
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" @seek="seek" />
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" />
|
||||
|
||||
<div class="flex">
|
||||
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
<p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-sm text-gray-300 pt-0.5">{{ currentChapterName }}</p>
|
||||
<p class="text-xs sm:text-sm text-gray-300 pt-0.5">
|
||||
{{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400"> ({{ currentChapterIndex + 1 }} of {{ chapters.length }})</span>
|
||||
</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
@@ -66,7 +74,8 @@ export default {
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
duration: 0
|
||||
duration: 0,
|
||||
useChapterTrack: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -86,6 +95,10 @@ export default {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
timeRemaining() {
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = this.currentTime - this.currentChapter.start
|
||||
return (this.currentChapterDuration - currChapTime) / this.playbackRate
|
||||
}
|
||||
return (this.duration - this.currentTime) / this.playbackRate
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
@@ -95,8 +108,11 @@ export default {
|
||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||
},
|
||||
progressPercent() {
|
||||
if (!this.duration) return 0
|
||||
return Math.round((100 * this.currentTime) / this.duration)
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
const time = this.useChapterTrack ? Math.max(this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
|
||||
if (!duration) return 0
|
||||
return Math.round((100 * time) / duration)
|
||||
},
|
||||
currentChapter() {
|
||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||
@@ -104,6 +120,14 @@ export default {
|
||||
currentChapterName() {
|
||||
return this.currentChapter ? this.currentChapter.title : ''
|
||||
},
|
||||
currentChapterDuration() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.end - this.currentChapter.start
|
||||
},
|
||||
currentChapterStart() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.start
|
||||
},
|
||||
isFullscreen() {
|
||||
return this.$store.state.playerIsFullscreen
|
||||
},
|
||||
@@ -192,6 +216,16 @@ export default {
|
||||
this.seek(chapter.start)
|
||||
this.showChaptersModal = false
|
||||
},
|
||||
setUseChapterTrack() {
|
||||
var useChapterTrack = !this.useChapterTrack
|
||||
this.useChapterTrack = useChapterTrack
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(useChapterTrack)
|
||||
|
||||
this.$store.dispatch('user/updateUserSettings', { useChapterTrack }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
this.updateTimestamp()
|
||||
},
|
||||
seek(time) {
|
||||
this.$emit('seek', time)
|
||||
},
|
||||
@@ -239,10 +273,10 @@ export default {
|
||||
console.error('No timestamp el')
|
||||
return
|
||||
}
|
||||
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
var currTimeClean = this.$secondsToTimestamp(time)
|
||||
ts.innerText = currTimeClean
|
||||
},
|
||||
|
||||
setBufferTime(bufferTime) {
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||
},
|
||||
@@ -252,6 +286,8 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
this.$emit('setPlaybackRate', this.playbackRate)
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div id="heatmap" class="w-full">
|
||||
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
|
||||
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
|
||||
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||
<div class="border border-white border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
|
||||
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||
|
||||
|
||||
@@ -192,7 +192,11 @@ export default {
|
||||
}
|
||||
|
||||
#accounts tr:nth-child(even) {
|
||||
background-color: #3a3a3a;
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
#accounts tr:nth-child(odd) {
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
#accounts tr:hover {
|
||||
@@ -204,6 +208,6 @@ export default {
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #333;
|
||||
background-color: #272727
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||
<template v-for="library in libraryCopies">
|
||||
<div :key="library.id" class="item">
|
||||
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
@@ -7,18 +7,26 @@
|
||||
</svg>
|
||||
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-show="isHovering && !libraryScan" small color="success" @click.stop="scan">Scan</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan" class="hidden md:block" small color="success" @click.stop="scan">Scan</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2 hidden md:block" @click.stop="forceScan">Force Re-Scan</ui-btn>
|
||||
|
||||
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2 hidden md:block" @click.stop="matchAll">Match Books</ui-btn>
|
||||
|
||||
<span v-show="isHovering && !libraryScan && showEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||
<span v-show="!libraryScan && isHovering && showEdit && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
|
||||
<span v-if="isHovering && !libraryScan" class="!hidden md:!block material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||
<span v-if="!libraryScan && isHovering && !isDeleting" class="!hidden md:!block material-icons text-xl text-gray-300 ml-3 hover:text-gray-50 cursor-pointer" @click.stop="deleteClick">delete</span>
|
||||
|
||||
<!-- For mobile -->
|
||||
<span v-if="!libraryScan" class="!block md:!hidden material-icons text-xl text-gray-300 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||
<span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-2xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
|
||||
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
|
||||
<svg viewBox="0 0 24 24" class="w-6 h-6">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="material-icons text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
|
||||
|
||||
<!-- For mobile -->
|
||||
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,22 +38,19 @@ export default {
|
||||
default: () => {}
|
||||
},
|
||||
selected: Boolean,
|
||||
showEdit: Boolean,
|
||||
dragging: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mouseover: false,
|
||||
isDeleting: false
|
||||
isDeleting: false,
|
||||
showMobileMenu: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isHovering() {
|
||||
return this.mouseover && !this.dragging
|
||||
},
|
||||
isMain() {
|
||||
return this.library.id === 'main'
|
||||
},
|
||||
libraryScan() {
|
||||
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
||||
},
|
||||
@@ -54,9 +59,50 @@ export default {
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.mediaType === 'book'
|
||||
},
|
||||
menuTitle() {
|
||||
return this.library.name
|
||||
},
|
||||
mobileMenuItems() {
|
||||
const items = [
|
||||
{
|
||||
text: 'Scan',
|
||||
value: 'scan'
|
||||
},
|
||||
{
|
||||
text: 'Force Re-Scan',
|
||||
value: 'force-scan'
|
||||
}
|
||||
]
|
||||
if (this.isBookLibrary) {
|
||||
items.push({
|
||||
text: 'Match Books',
|
||||
value: 'match-books'
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
text: 'Delete',
|
||||
value: 'delete'
|
||||
})
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mobileMenuAction(action) {
|
||||
this.showMobileMenu = false
|
||||
if (action === 'scan') {
|
||||
this.scan()
|
||||
} else if (action === 'force-scan') {
|
||||
this.forceScan()
|
||||
} else if (action === 'match-books') {
|
||||
this.matchAll()
|
||||
} else if (action === 'delete') {
|
||||
this.deleteClick()
|
||||
}
|
||||
},
|
||||
showMenu() {
|
||||
this.showMobileMenu = true
|
||||
},
|
||||
matchAll() {
|
||||
this.$axios
|
||||
.$post(`/api/libraries/${this.library.id}/matchall`)
|
||||
@@ -97,7 +143,6 @@ export default {
|
||||
}
|
||||
},
|
||||
deleteClick() {
|
||||
if (this.isMain) return
|
||||
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
|
||||
this.isDeleting = true
|
||||
this.$axios
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn>
|
||||
<ui-btn @click="clickUpload" color="primary" class="hidden md:block" type="text"><slot /></ui-btn>
|
||||
<ui-icon-btn @click="clickUpload" icon="upload" class="block md:hidden" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
||||
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
|
||||
<div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="w-10 sm:w-36 relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center justify-center sm:justify-start">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" />
|
||||
<span class="hidden sm:block">{{ currentLibrary.name }}</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 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-36 bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<template v-for="library in librariesFiltered">
|
||||
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
|
||||
<div class="flex items-center px-3">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
|
||||
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
||||
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default {
|
||||
var tooltip = document.createElement('div')
|
||||
this.tooltipId = String(Math.floor(Math.random() * 10000))
|
||||
tooltip.id = this.tooltipId
|
||||
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
|
||||
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs text-center hidden sm:block'
|
||||
tooltip.style.zIndex = 100
|
||||
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
||||
tooltip.innerHTML = this.text
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<form class="w-full h-full px-2 md:px-4 py-6" @submit.prevent="submitForm">
|
||||
<div class="flex flex-wrap -mx-1">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<div class="flex-grow px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<div class="flex flex-wrap mt-2 -mx-1">
|
||||
<div class="w-full md:w-3/4 px-1">
|
||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<div class="flex-grow px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,35 +28,35 @@
|
||||
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<div class="flex flex-wrap mt-2 -mx-1">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<div class="flex-grow px-1 mt-2 md:mt-0">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<div class="flex flex-wrap mt-2 -mx-1">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<div class="flex flex-wrap mt-2 -mx-1">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
|
||||
@@ -110,13 +110,7 @@ export default {
|
||||
}
|
||||
console.log('Init Payload', payload)
|
||||
if (payload.session) {
|
||||
if (this.$refs.streamContainer) {
|
||||
this.$refs.streamContainer.sessionOpen(payload.session)
|
||||
} else {
|
||||
console.warn('Stream Container not mounted')
|
||||
}
|
||||
}
|
||||
if (payload.serverSettings) {
|
||||
this.$refs.streamContainer.sessionOpen(payload.session)
|
||||
}
|
||||
|
||||
// Start scans currently running
|
||||
@@ -243,9 +237,9 @@ export default {
|
||||
|
||||
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
||||
if (existingScan && !isNaN(existingScan.toastId)) {
|
||||
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, position: 'bottom-center', onClose: () => null } }, true)
|
||||
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, onClose: () => null } }, true)
|
||||
} else {
|
||||
this.$toast.success(message, { timeout: 5000, position: 'bottom-center' })
|
||||
this.$toast.success(message, { timeout: 5000 })
|
||||
}
|
||||
|
||||
this.$store.commit('scanners/remove', data)
|
||||
@@ -254,7 +248,7 @@ export default {
|
||||
this.$root.socket.emit('cancel_scan', id)
|
||||
},
|
||||
scanStart(data) {
|
||||
data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
|
||||
data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
|
||||
this.$store.commit('scanners/addUpdate', data)
|
||||
},
|
||||
scanProgress(data) {
|
||||
@@ -263,7 +257,7 @@ export default {
|
||||
data.toastId = existingScan.toastId
|
||||
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
|
||||
} else {
|
||||
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
|
||||
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
|
||||
}
|
||||
|
||||
this.$store.commit('scanners/addUpdate', data)
|
||||
|
||||
@@ -28,11 +28,11 @@ export default {
|
||||
var validOtherFiles = []
|
||||
var ignoredFiles = []
|
||||
files.forEach((file) => {
|
||||
var filetype = this.checkFileType(file.name)
|
||||
if (!filetype) ignoredFiles.push(file)
|
||||
// var filetype = this.checkFileType(file.name)
|
||||
if (!file.filetype) ignoredFiles.push(file)
|
||||
else {
|
||||
file.filetype = filetype
|
||||
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
|
||||
// file.filetype = filetype
|
||||
if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
|
||||
else validOtherFiles.push(file)
|
||||
}
|
||||
})
|
||||
@@ -82,12 +82,18 @@ export default {
|
||||
items: itemResults,
|
||||
ignoredFiles: ignoredFilesInRoot
|
||||
}
|
||||
} else {
|
||||
} else if (filetree.some((f) => f.filetype !== 'audio') || mediaType !== 'book') {
|
||||
// Single Book drop
|
||||
return {
|
||||
items: this.itemFromTreeItems(filetree, mediaType),
|
||||
ignoredFiles: []
|
||||
}
|
||||
} else {
|
||||
// Only audio files dropped so treat each one as an audiobook
|
||||
return {
|
||||
items: filetree.map((audioFile) => ({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] })),
|
||||
ignoredFiles: []
|
||||
}
|
||||
}
|
||||
},
|
||||
getFilesDropped(dataTransferItems) {
|
||||
@@ -95,11 +101,12 @@ export default {
|
||||
path: '/',
|
||||
items: []
|
||||
}
|
||||
function traverseFileTreePromise(item, currtreemap) {
|
||||
function traverseFileTreePromise(item, currtreemap, checkFileType) {
|
||||
return new Promise((resolve) => {
|
||||
if (item.isFile) {
|
||||
item.file((file) => {
|
||||
file.filepath = currtreemap.path + file.name //save full path
|
||||
file.filetype = checkFileType(file.name)
|
||||
currtreemap.items.push(file)
|
||||
resolve(file)
|
||||
})
|
||||
@@ -119,7 +126,7 @@ export default {
|
||||
dirReader.readEntries((entries) => {
|
||||
if (entries.length > 0) {
|
||||
for (let entr of entries) {
|
||||
entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
|
||||
entriesPromises.push(traverseFileTreePromise(entr, newtreemap, checkFileType))
|
||||
}
|
||||
readEntries()
|
||||
} else {
|
||||
@@ -135,7 +142,7 @@ export default {
|
||||
return new Promise((resolve, reject) => {
|
||||
let entriesPromises = []
|
||||
for (let it of dataTransferItems) {
|
||||
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap)
|
||||
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap, this.checkFileType)
|
||||
entriesPromises.push(filetree)
|
||||
}
|
||||
Promise.all(entriesPromises).then(() => {
|
||||
@@ -152,7 +159,9 @@ export default {
|
||||
...book
|
||||
}
|
||||
var firstBookFile = book.itemFiles[0]
|
||||
if (!firstBookFile.filepath) return audiobook // No path
|
||||
if (!firstBookFile.filepath) {
|
||||
return audiobook // No path
|
||||
}
|
||||
|
||||
var firstBookPath = Path.dirname(firstBookFile.filepath)
|
||||
|
||||
@@ -165,6 +174,9 @@ export default {
|
||||
if (dirs.length) {
|
||||
audiobook.author = dirs.pop()
|
||||
}
|
||||
} else {
|
||||
// Use file basename as title
|
||||
audiobook.title = Path.basename(firstBookFile.name, Path.extname(firstBookFile.name))
|
||||
}
|
||||
return audiobook
|
||||
},
|
||||
@@ -178,7 +190,12 @@ export default {
|
||||
if (!firstAudioFile.filepath) return podcast // No path
|
||||
var firstPath = Path.dirname(firstAudioFile.filepath)
|
||||
var dirs = firstPath.split('/').filter(d => !!d && d !== '.')
|
||||
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
|
||||
if (dirs.length) {
|
||||
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
|
||||
} else {
|
||||
podcast.title = Path.basename(firstAudioFile.name, Path.extname(firstAudioFile.name))
|
||||
}
|
||||
|
||||
return podcast
|
||||
},
|
||||
cleanItem(item, mediaType, index) {
|
||||
@@ -188,6 +205,7 @@ export default {
|
||||
async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
|
||||
var files = await this.getFilesDropped(dataTransferItems)
|
||||
if (!files || !files.length) return { error: 'No files found ' }
|
||||
|
||||
var itemData = this.fileTreeToItems(files, mediaType)
|
||||
if (!itemData.items.length && !itemData.ignoredFiles.length) {
|
||||
return { error: 'Invalid file drop' }
|
||||
@@ -218,9 +236,12 @@ export default {
|
||||
else {
|
||||
file.filetype = filetype
|
||||
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
|
||||
else file.filepath = file.name
|
||||
|
||||
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) {
|
||||
var dir = file.filepath ? Path.dirname(file.filepath) : ''
|
||||
if (dir === '.') dir = ''
|
||||
|
||||
if (!itemMap[dir]) {
|
||||
itemMap[dir] = {
|
||||
path: dir,
|
||||
@@ -246,8 +267,17 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
var items = []
|
||||
var index = 1
|
||||
var items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
|
||||
// If book media type and all files are audio files then treat each one as an audiobook
|
||||
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) {
|
||||
items = itemMap[''].itemFiles.map((audioFile) => {
|
||||
return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)
|
||||
})
|
||||
} else {
|
||||
items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
ignoredFiles: ignoredFiles
|
||||
|
||||
+22
-3
@@ -58,7 +58,8 @@ module.exports = {
|
||||
buildModules: [
|
||||
// https://go.nuxtjs.dev/tailwindcss
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@nuxtjs/pwa'
|
||||
'@nuxtjs/pwa',
|
||||
'@nuxt/postcss8'
|
||||
],
|
||||
|
||||
// Modules: https://go.nuxtjs.dev/config-modules
|
||||
@@ -119,11 +120,21 @@ module.exports = {
|
||||
sizes: "512x512"
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
enabled: false,
|
||||
}
|
||||
},
|
||||
|
||||
// Build Configuration: https://go.nuxtjs.dev/config-build
|
||||
build: {},
|
||||
build: {
|
||||
postcss: {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
watchers: {
|
||||
webpack: {
|
||||
aggregateTimeout: 300,
|
||||
@@ -133,5 +144,13 @@ module.exports = {
|
||||
server: {
|
||||
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
|
||||
host: '0.0.0.0'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Temporary workaround for @nuxt-community/tailwindcss-module.
|
||||
*
|
||||
* Reported: 2022-05-23
|
||||
* See: [Issue tracker](https://github.com/nuxt-community/tailwindcss-module/issues/480)
|
||||
*/
|
||||
devServerHandlers: [],
|
||||
}
|
||||
|
||||
Generated
+571
-395
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.23",
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -29,8 +29,10 @@
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/postcss8": "^1.1.3",
|
||||
"@nuxtjs/pwa": "^3.3.5",
|
||||
"@nuxtjs/tailwindcss": "^4.2.1",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"postcss": "^8.3.6"
|
||||
}
|
||||
}
|
||||
+179
-154
@@ -1,150 +1,190 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
||||
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-2">
|
||||
<div class="mb-2">
|
||||
<h1 class="text-xl">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
||||
<ui-tooltip :text="tooltips.storeCoverWithItem">
|
||||
<p class="pl-4 text-lg">
|
||||
Store covers with item
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="lg:flex">
|
||||
<div class="flex-1">
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">General</h2>
|
||||
</div>
|
||||
<div class="flex items-end py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
||||
<ui-tooltip :text="tooltips.storeCoverWithItem">
|
||||
<p class="pl-4">
|
||||
Store covers with item
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
||||
<ui-tooltip :text="tooltips.storeMetadataWithItem">
|
||||
<p class="pl-4 text-lg">
|
||||
Store metadata with item
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
||||
<ui-tooltip :text="tooltips.storeMetadataWithItem">
|
||||
<p class="pl-4">
|
||||
Store metadata with item
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
||||
<ui-tooltip :text="tooltips.coverAspectRatio">
|
||||
<p class="pl-4 text-lg">
|
||||
Use square book covers
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
||||
<ui-tooltip :text="tooltips.sortingIgnorePrefix">
|
||||
<p class="pl-4">
|
||||
Ignore prefixes when sorting
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
|
||||
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" label="Prefixes to Ignore (case insensitive)" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
||||
<ui-tooltip :text="tooltips.bookshelfView">
|
||||
<p class="pl-4 text-lg">
|
||||
Use alternative bookshelf view
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p class="pl-4">Chromecast support</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
||||
<ui-tooltip :text="tooltips.sortingIgnorePrefix">
|
||||
<p class="pl-4 text-lg">
|
||||
Ignore prefixes when sorting title and series
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
|
||||
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" label="Prefixes to Ignore (case insensitive)" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">Display</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p class="pl-4 text-lg">Enable Chromecast</p>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
||||
<ui-tooltip :text="tooltips.coverAspectRatio">
|
||||
<p class="pl-4">
|
||||
Square book covers
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2 mt-8">
|
||||
<h1 class="text-xl">Scanner Settings</h1>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
||||
<ui-tooltip :text="tooltips.bookshelfView">
|
||||
<p class="pl-4">
|
||||
Alternative bookshelf view
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerParseSubtitle">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner parse subtitles
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<p class="pr-4">Date Format</p>
|
||||
<ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-40" @input="(val) => updateSettingsKey('dateFormat', val)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerFindCovers">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner find covers
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">Scanner</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner prefer audio metadata
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerParseSubtitle">
|
||||
<p class="pl-4">
|
||||
Parse subtitles
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner prefer OPF metadata
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerFindCovers">
|
||||
<p class="pl-4">
|
||||
Find covers
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner prefer matched metadata
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferOverdriveMediaMarker">
|
||||
<p class="pl-4">
|
||||
Use Overdrive Media Markers for chapters
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerDisableWatcher">
|
||||
<p class="pl-4 text-lg">
|
||||
Disable Watcher
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
|
||||
<p class="pl-4">
|
||||
Prefer audio metadata
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2 mt-8">
|
||||
<h1 class="text-xl">Experimental Feature Settings</h1>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
|
||||
<p class="pl-4">
|
||||
Prefer OPF metadata
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
|
||||
<ui-tooltip :text="tooltips.enableEReader">
|
||||
<p class="pl-4 text-lg">
|
||||
Enable e-reader for all users
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
|
||||
<p class="pl-4">
|
||||
Prefer matched metadata
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerDisableWatcher">
|
||||
<p class="pl-4">
|
||||
Disable Watcher
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">Experimental Features</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||
<p class="pl-4">
|
||||
Experimental Features
|
||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</a>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
|
||||
<ui-tooltip :text="tooltips.enableEReader">
|
||||
<p class="pl-4">
|
||||
Enable e-reader for all users
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +192,7 @@
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click="purgeCache">Purge Cache</ui-btn>
|
||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block" :loading="isResettingLibraryItems" @click="resetLibraryItems">Remove All Library Items</ui-btn>
|
||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isResettingLibraryItems" @click="resetLibraryItems">Remove All Library Items</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<p class="pr-2 text-sm font-book text-yellow-400">
|
||||
Report bugs, request features, and contribute on
|
||||
@@ -188,30 +228,12 @@
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||
|
||||
<div class="py-12 mb-4 opacity-60 hover:opacity-100">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||
<p class="pl-4 text-lg">
|
||||
Experimental Features
|
||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</a>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<prompt-dialog v-model="showConfirmPurgeCache" :width="675">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-error text-lg font-semibold">Important Notice!</p>
|
||||
<p class="text-lg my-2 text-center">Purge cache will delete the entire directory at <span class="font-mono">/metadata/cache</span>.</p>
|
||||
<p class="text-error font-semibold">Important Notice!</p>
|
||||
<p class="my-2 text-center">Purge cache will delete the entire directory at <span class="font-mono">/metadata/cache</span>.</p>
|
||||
|
||||
<p class="text-lg text-center mb-8">Are you sure you want to remove the cache directory?</p>
|
||||
<p class="text-center mb-8">Are you sure you want to remove the cache directory?</p>
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn color="primary" @click="showConfirmPurgeCache = false">Nevermind</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
@@ -245,7 +267,8 @@ export default {
|
||||
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
|
||||
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
|
||||
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
|
||||
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle below just for you)'
|
||||
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle just for use by you)',
|
||||
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically'
|
||||
},
|
||||
showConfirmPurgeCache: false
|
||||
}
|
||||
@@ -271,12 +294,12 @@ export default {
|
||||
set(val) {
|
||||
this.$store.commit('setExperimentalFeatures', val)
|
||||
}
|
||||
},
|
||||
dateFormats() {
|
||||
return this.$store.state.globals.dateFormats
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateEnableChromecast(val) {
|
||||
this.updateServerSettings({ enableChromecast: val })
|
||||
},
|
||||
updateSortingPrefixes(val) {
|
||||
if (!val || !val.length) {
|
||||
this.$toast.error('Must have at least 1 prefix')
|
||||
@@ -314,10 +337,12 @@ export default {
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.updatingServerSettings = false
|
||||
this.$toast.success('Server settings updated')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
this.$toast.error('Failed to update server settings')
|
||||
})
|
||||
},
|
||||
initServerSettings() {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<p class="text-xl">Stats for library {{ currentLibraryName }}</p>
|
||||
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<h1 class="text-xl">Stats for library {{ currentLibraryName }}</h1>
|
||||
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
|
||||
|
||||
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-12">
|
||||
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-8">
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
|
||||
<p v-if="!top5Genres.length">No Genres</p>
|
||||
|
||||
+11
-14
@@ -1,24 +1,21 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div class="mb-4 flex flex-col sm:flex-row items-start sm:items-end">
|
||||
<p class="text-2xl mr-4 mb-2 sm:mb-0">Logger</p>
|
||||
<div class="w-full h-full">
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-xl">Logs</h1>
|
||||
</div>
|
||||
<div class="flex justify-between mb-2 place-items-end">
|
||||
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" />
|
||||
|
||||
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm mb-2 sm:mb-0" />
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<div class="w-full sm:w-44">
|
||||
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" />
|
||||
</div>
|
||||
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" />
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 550px; min-height: 550px">
|
||||
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 800px; min-height: 550px">
|
||||
<template v-for="(log, index) in logs">
|
||||
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
|
||||
<p class="text-gray-400 w-40 font-mono">{{ log.timestamp.split('.')[0].split('T').join(' ') }}</p>
|
||||
<p class="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
|
||||
<p class="text-gray-400 w-36 font-mono text-xs">{{ log.timestamp }}</p>
|
||||
<p class="font-semibold w-12 text-right text-sm" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
|
||||
<p class="px-4 logmessage">{{ log.message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8">
|
||||
<div class="py-2">
|
||||
<div class="flex items-center mb-1">
|
||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
|
||||
</div>
|
||||
<div v-if="listeningSessions.length" class="block max-w-full">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary bg-opacity-40">
|
||||
<th class="w-48 min-w-48 text-left">Item</th>
|
||||
<th class="w-20 min-w-20 text-left hidden md:table-cell">User</th>
|
||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
|
||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
|
||||
<th class="w-32 min-w-32">Listened</th>
|
||||
<th class="w-16 min-w-16">Last Time</th>
|
||||
<th class="flex-grow hidden sm:table-cell">Last Update</th>
|
||||
</tr>
|
||||
|
||||
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
||||
<td class="py-1 max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell">
|
||||
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="flex items-center justify-end py-1">
|
||||
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
|
||||
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-xl">Listening Sessions</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mb-2">
|
||||
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
|
||||
</div>
|
||||
|
||||
<div v-if="listeningSessions.length" class="block max-w-full">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary bg-opacity-40">
|
||||
<th class="w-48 min-w-48 text-left">Item</th>
|
||||
<th class="w-20 min-w-20 text-left hidden md:table-cell">User</th>
|
||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
|
||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
|
||||
<th class="w-32 min-w-32">Listened</th>
|
||||
<th class="w-16 min-w-16">Last Time</th>
|
||||
<th class="flex-grow hidden sm:table-cell">Last Update</th>
|
||||
</tr>
|
||||
|
||||
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
||||
<td class="py-1 max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell">
|
||||
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="flex items-center justify-end my-2">
|
||||
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
|
||||
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
|
||||
</div>
|
||||
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<h1 class="text-xl">Stats for {{ username }}</h1>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<div class="flex p-2">
|
||||
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
||||
@@ -46,7 +48,7 @@
|
||||
<template v-for="(item, index) in mostRecentListeningSessions">
|
||||
<div :key="item.id" class="w-full py-0.5">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="text-sm font-book text-white text-opacity-70 w-6 truncate">{{ index + 1 }}. </p>
|
||||
<p class="text-sm font-book text-white text-opacity-70 w-8">{{ index + 1 }}. </p>
|
||||
<div class="w-56">
|
||||
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
|
||||
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
|
||||
@@ -84,6 +86,9 @@ export default {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
username() {
|
||||
return this.user.username
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
|
||||
@@ -118,9 +118,9 @@
|
||||
<!-- Progress -->
|
||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, 'MM/dd/yyyy') }}</p>
|
||||
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
|
||||
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, 'MM/dd/yyyy') }}</p>
|
||||
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
|
||||
|
||||
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
||||
<span class="material-icons text-sm">close</span>
|
||||
@@ -226,6 +226,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
|
||||
@@ -2,25 +2,26 @@
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<app-book-shelf-toolbar page="podcast-search" />
|
||||
|
||||
<div class="w-full h-full overflow-y-auto p-12 relative">
|
||||
<div class="w-full h-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
|
||||
<div class="w-full max-w-4xl mx-auto flex">
|
||||
<form @submit.prevent="submit" class="flex flex-grow">
|
||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
|
||||
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
|
||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
|
||||
<ui-btn type="submit" :disabled="processing" class="hidden md:block">Submit</ui-btn>
|
||||
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>Submit</ui-btn>
|
||||
</form>
|
||||
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="mx-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
|
||||
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="ml-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-3xl mx-auto py-4">
|
||||
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
|
||||
<template v-for="podcast in results">
|
||||
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
|
||||
<div class="w-24 min-w-24 h-24 bg-primary">
|
||||
<div class="w-20 min-w-20 h-20 md:w-24 md:min-w-24 md:h-24 bg-primary">
|
||||
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
||||
</div>
|
||||
<div class="flex-grow pl-4 max-w-2xl">
|
||||
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-8 text-center">
|
||||
<p class="text-xs text-white text-opacity-50 font-mono"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
||||
<p class="text-xs text-white text-opacity-50 font-mono mb-4"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
||||
|
||||
<p class="text-sm text-white text-opacity-70">Folders with media files will be treated as separate library items. <span v-if="selectedLibraryMediaType === 'book'">If uploading only audio files then each audio file will be treated as a separate audiobook.</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Item list header -->
|
||||
|
||||
@@ -177,12 +177,13 @@ export default class PlayerHandler {
|
||||
}
|
||||
|
||||
prepareOpenSession(session, playbackRate) { // Session opened on init socket
|
||||
if (!this.player) this.switchPlayer() // Must set player first for open sessions
|
||||
|
||||
this.libraryItem = session.libraryItem
|
||||
this.isVideo = session.libraryItem.mediaType === 'video'
|
||||
this.playWhenReady = false
|
||||
this.initialPlaybackRate = playbackRate
|
||||
|
||||
if (!this.player) this.switchPlayer()
|
||||
this.prepareSession(session)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export default (ctx) => {
|
||||
var castContext = cast.framework.CastContext.getInstance()
|
||||
castContext.setOptions({
|
||||
receiverApplicationId: process.env.chromecastReceiver,
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
|
||||
autoJoinPolicy: chrome.cast ? chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED : null
|
||||
});
|
||||
|
||||
castContext.addEventListener(
|
||||
|
||||
@@ -169,7 +169,7 @@ Vue.prototype.$sanitizeSlug = (str) => {
|
||||
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))
|
||||
}
|
||||
|
||||
str = str.replace('.', '-') // replace a dot by a dash
|
||||
str = str.replace('.', '-') // replace a dot by a dash
|
||||
.replace(/[^a-z0-9 -_]/g, '') // remove invalid chars
|
||||
.replace(/\s+/g, '-') // collapse whitespace and replace by a dash
|
||||
.replace(/-+/g, '-') // collapse dashes
|
||||
@@ -204,7 +204,6 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function xmlToJson(xml) {
|
||||
const json = {};
|
||||
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
||||
|
||||
+15
-2
@@ -1,4 +1,3 @@
|
||||
|
||||
export const state = () => ({
|
||||
isMobile: false,
|
||||
isMobileLandscape: false,
|
||||
@@ -12,7 +11,21 @@ export const state = () => ({
|
||||
selectedCollection: null,
|
||||
selectedAuthor: null,
|
||||
isCasting: false, // Actively casting
|
||||
isChromecastInitialized: false // Script loaded
|
||||
isChromecastInitialized: false, // Script loaded
|
||||
dateFormats: [
|
||||
{
|
||||
text: 'MM/DD/YYYY',
|
||||
value: 'MM/dd/yyyy'
|
||||
},
|
||||
{
|
||||
text: 'DD/MM/YYYY',
|
||||
value: 'dd/MM/yyyy'
|
||||
},
|
||||
{
|
||||
text: 'YYYY-MM-DD',
|
||||
value: 'yyyy-MM-dd'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
|
||||
+19
-12
@@ -2,18 +2,23 @@ const defaultTheme = require('tailwindcss/defaultTheme')
|
||||
|
||||
module.exports = {
|
||||
purge: {
|
||||
options: {
|
||||
safelist: [
|
||||
'bg-success',
|
||||
'bg-red-600',
|
||||
'text-green-500',
|
||||
'py-1.5',
|
||||
'bg-info',
|
||||
'px-1.5'
|
||||
]
|
||||
}
|
||||
content: [
|
||||
'components/**/*.vue',
|
||||
'layouts/**/*.vue',
|
||||
'pages/**/*.vue',
|
||||
'templates/**/*.vue',
|
||||
'plugins/**/*.js',
|
||||
'nuxt.config.js'
|
||||
],
|
||||
safelist: [
|
||||
'bg-success',
|
||||
'bg-red-600',
|
||||
'text-green-500',
|
||||
'py-1.5',
|
||||
'bg-info',
|
||||
'px-1.5'
|
||||
],
|
||||
},
|
||||
darkMode: false,
|
||||
theme: {
|
||||
extend: {
|
||||
height: {
|
||||
@@ -31,6 +36,7 @@ module.exports = {
|
||||
'20': '5rem',
|
||||
'24': '6rem',
|
||||
'32': '8rem',
|
||||
'40': '10rem',
|
||||
'48': '12rem',
|
||||
'64': '16rem',
|
||||
'80': '20rem'
|
||||
@@ -79,7 +85,8 @@ module.exports = {
|
||||
book: ['Gentium Book Basic', 'serif']
|
||||
},
|
||||
fontSize: {
|
||||
xxs: '0.625rem'
|
||||
xxs: '0.625rem',
|
||||
'2.5xl': '1.6875rem'
|
||||
},
|
||||
zIndex: {
|
||||
'50': 50
|
||||
|
||||
Generated
+3
-3
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.23",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"archiver": "^5.3.0",
|
||||
"axios": "^0.26.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"command-line-args": "^5.2.0",
|
||||
"date-and-time": "^2.0.1",
|
||||
"date-and-time": "^2.3.1",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
"express-rate-limit": "^5.3.0",
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.23",
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,6 +11,9 @@
|
||||
"build-win": "npm run client && pkg -t node16-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
||||
"build-linux": "build/linuxpackager",
|
||||
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
|
||||
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
|
||||
"docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local",
|
||||
"docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local",
|
||||
"deploy": "node dist/autodeploy"
|
||||
},
|
||||
"bin": "prod.js",
|
||||
@@ -31,7 +34,7 @@
|
||||
"axios": "^0.26.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"command-line-args": "^5.2.0",
|
||||
"date-and-time": "^2.0.1",
|
||||
"date-and-time": "^2.3.1",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
"express-rate-limit": "^5.3.0",
|
||||
|
||||
+3
-1
@@ -20,7 +20,9 @@ class Auth {
|
||||
cors(req, res, next) {
|
||||
res.header('Access-Control-Allow-Origin', '*')
|
||||
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
||||
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
|
||||
res.header('Access-Control-Allow-Headers', '*')
|
||||
// TODO: Make sure allowing all headers is not a security concern. It is required for adding custom headers for SSO
|
||||
// res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
|
||||
res.header('Access-Control-Allow-Credentials', true)
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(200)
|
||||
|
||||
+2
-2
@@ -1,16 +1,16 @@
|
||||
const date = require('date-and-time')
|
||||
const { LogLevel } = require('./utils/constants')
|
||||
|
||||
class Logger {
|
||||
constructor() {
|
||||
this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
|
||||
// this.logFileLevel = LogLevel.INFO
|
||||
this.socketListeners = []
|
||||
|
||||
this.logManager = null
|
||||
}
|
||||
|
||||
get timestamp() {
|
||||
return (new Date()).toISOString()
|
||||
return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
get levelString() {
|
||||
|
||||
+2
-1
@@ -140,7 +140,7 @@ class Server {
|
||||
await this.purgeMetadata() // Remove metadata folders without library item
|
||||
await this.cacheManager.ensureCachePaths()
|
||||
await this.abMergeManager.ensureDownloadDirPath()
|
||||
|
||||
|
||||
await this.backupManager.init()
|
||||
await this.logManager.init()
|
||||
await this.rssFeedManager.init()
|
||||
@@ -451,6 +451,7 @@ class Server {
|
||||
} else {
|
||||
Logger.debug(`[Server] User Online ${client.user.username}`)
|
||||
}
|
||||
|
||||
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
|
||||
|
||||
user.lastSeen = Date.now()
|
||||
|
||||
@@ -63,15 +63,27 @@ class AuthorController {
|
||||
// If updating or removing cover image then clear cache
|
||||
if (payload.imagePath !== undefined && req.author.imagePath && payload.imagePath !== req.author.imagePath) {
|
||||
this.cacheManager.purgeImageCache(req.author.id)
|
||||
|
||||
if (!payload.imagePath) { // If removing image then remove file
|
||||
var currentImagePath = req.author.imagePath
|
||||
await this.coverManager.removeFile(currentImagePath)
|
||||
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
|
||||
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath)
|
||||
if (imageData) {
|
||||
req.author.imagePath = imageData.path
|
||||
req.author.relImagePath = imageData.relPath
|
||||
hasUpdated = hasUpdated || true;
|
||||
} else {
|
||||
req.author.imagePath = null
|
||||
req.author.relImagePath = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
||||
|
||||
var hasUpdated = req.author.update(payload)
|
||||
|
||||
if (hasUpdated) {
|
||||
if (authorNameUpdate) { // Update author name on all books
|
||||
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
||||
|
||||
@@ -57,6 +57,7 @@ class MeController {
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Item not found')
|
||||
}
|
||||
|
||||
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body)
|
||||
if (wasUpdated) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
|
||||
@@ -103,7 +103,7 @@ class PodcastController {
|
||||
Logger.error('Invalid podcast feed request response')
|
||||
return res.status(500).send('Bad response from feed request')
|
||||
}
|
||||
Logger.debug(`[PdocastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
|
||||
Logger.debug(`[PodcastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
|
||||
var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw)
|
||||
if (!payload) {
|
||||
return res.status(500).send('Invalid podcast RSS feed')
|
||||
|
||||
@@ -4,6 +4,7 @@ const Path = require('path')
|
||||
const Audnexus = require('../providers/Audnexus')
|
||||
|
||||
const { downloadFile } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
class AuthorFinder {
|
||||
constructor() {
|
||||
@@ -38,7 +39,11 @@ class AuthorFinder {
|
||||
async saveAuthorImage(authorId, url) {
|
||||
var authorDir = this.AuthorPath
|
||||
var relAuthorDir = Path.posix.join('/metadata', 'authors')
|
||||
await fs.ensureDir(authorDir)
|
||||
|
||||
if (!await fs.pathExists(authorDir)) {
|
||||
await fs.ensureDir(authorDir)
|
||||
await filePerms.setDefault(authorDir)
|
||||
}
|
||||
|
||||
var imageExtension = url.toLowerCase().split('.').pop()
|
||||
var ext = imageExtension === 'png' ? 'png' : 'jpg'
|
||||
|
||||
@@ -180,11 +180,11 @@ class BookFinder {
|
||||
Logger.debug(`Book Search: title: "${title}", author: "${author}", provider: ${provider}`)
|
||||
|
||||
if (provider === 'google') {
|
||||
return this.getGoogleBooksResults(title, author)
|
||||
books = await this.getGoogleBooksResults(title, author)
|
||||
} else if (provider === 'audible') {
|
||||
return this.getAudibleResults(title, author, asin)
|
||||
books = await this.getAudibleResults(title, author, asin)
|
||||
} else if (provider === 'itunes') {
|
||||
return this.getiTunesAudiobooksResults(title, author)
|
||||
books = await this.getiTunesAudiobooksResults(title, author)
|
||||
} else if (provider === 'libgen') {
|
||||
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
} else if (provider === 'openlibrary') {
|
||||
@@ -208,6 +208,18 @@ class BookFinder {
|
||||
}
|
||||
}
|
||||
|
||||
if (!books.length && !options.currentlyTryingCleaned) {
|
||||
var cleanedTitle = this.cleanTitleForCompares(title)
|
||||
var cleanedAuthor = this.cleanAuthorForCompares(author)
|
||||
if (cleanedTitle == title && cleanedAuthor == author) return books
|
||||
|
||||
Logger.debug(`Book Search, no matches.. checking cleaned title and author`)
|
||||
options.currentlyTryingCleaned = true
|
||||
return this.search(provider, cleanedTitle, cleanedAuthor, isbn, asin, options)
|
||||
}
|
||||
|
||||
if (["google", "audible", "itunes"].includes(provider)) return books
|
||||
|
||||
return books.sort((a, b) => {
|
||||
return a.totalDistance - b.totalDistance
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes
|
||||
const DEFAULT_TIMEOUT = 1000 * 60 * 20 // 20 minutes
|
||||
const DEFAULT_TIMEOUT = 1000 * 60 * 30 // 30 minutes
|
||||
class Download {
|
||||
constructor(download) {
|
||||
this.id = null
|
||||
|
||||
@@ -7,7 +7,7 @@ const LibraryFile = require('./files/LibraryFile')
|
||||
const Book = require('./mediaTypes/Book')
|
||||
const Podcast = require('./mediaTypes/Podcast')
|
||||
const Video = require('./mediaTypes/Video')
|
||||
const { areEquivalent, copyValue, getId } = require('../utils/index')
|
||||
const { areEquivalent, copyValue, getId, cleanStringForSearch } = require('../utils/index')
|
||||
|
||||
class LibraryItem {
|
||||
constructor(libraryItem = null) {
|
||||
@@ -451,7 +451,7 @@ class LibraryItem {
|
||||
}
|
||||
|
||||
searchQuery(query) {
|
||||
query = query.toLowerCase()
|
||||
query = cleanStringForSearch(query)
|
||||
return this.media.searchQuery(query)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
const Path = require('path')
|
||||
const Logger = require('../../Logger')
|
||||
const BookMetadata = require('../metadata/BookMetadata')
|
||||
const { areEquivalent, copyValue } = require('../../utils/index')
|
||||
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
|
||||
const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata')
|
||||
const { overdriveMediaMarkersExist, parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
|
||||
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
|
||||
const { readTextFile } = require('../../utils/fileUtils')
|
||||
const AudioFile = require('../files/AudioFile')
|
||||
@@ -303,7 +304,7 @@ class Book {
|
||||
|
||||
searchQuery(query) {
|
||||
var payload = {
|
||||
tags: this.tags.filter(t => t.toLowerCase().includes(query)),
|
||||
tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)),
|
||||
series: this.metadata.searchSeries(query),
|
||||
authors: this.metadata.searchAuthors(query),
|
||||
matchKey: null,
|
||||
@@ -360,10 +361,11 @@ class Book {
|
||||
this.rebuildTracks()
|
||||
}
|
||||
|
||||
rebuildTracks() {
|
||||
rebuildTracks(preferOverdriveMediaMarker) {
|
||||
Logger.debug(`[Book] Tracks being rebuilt...!`)
|
||||
this.audioFiles.sort((a, b) => a.index - b.index)
|
||||
this.missingParts = []
|
||||
this.setChapters()
|
||||
this.setChapters(preferOverdriveMediaMarker)
|
||||
this.checkUpdateMissingTracks()
|
||||
}
|
||||
|
||||
@@ -395,9 +397,16 @@ class Book {
|
||||
return wasUpdated
|
||||
}
|
||||
|
||||
setChapters() {
|
||||
setChapters(preferOverdriveMediaMarker = false) {
|
||||
// If 1 audio file without chapters, then no chapters will be set
|
||||
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
|
||||
|
||||
// If overdrive media markers are present and preferred, use those instead
|
||||
if (preferOverdriveMediaMarker && overdriveMediaMarkersExist(includedAudioFiles)) {
|
||||
Logger.info('[Book] Overdrive Media Markers and preference found! Using these for chapter definitions')
|
||||
return this.chapters = parseOverdriveMediaMarkersAsChapters(includedAudioFiles)
|
||||
}
|
||||
|
||||
if (includedAudioFiles.length === 1) {
|
||||
// 1 audio file with chapters
|
||||
if (includedAudioFiles[0].chapters) {
|
||||
|
||||
@@ -20,6 +20,7 @@ class AudioMetaTags {
|
||||
this.tagIsbn = null
|
||||
this.tagLanguage = null
|
||||
this.tagASIN = null
|
||||
this.tagOverdriveMediaMarker = null
|
||||
|
||||
if (metadata) {
|
||||
this.construct(metadata)
|
||||
@@ -58,6 +59,7 @@ class AudioMetaTags {
|
||||
this.tagIsbn = metadata.tagIsbn || null
|
||||
this.tagLanguage = metadata.tagLanguage || null
|
||||
this.tagASIN = metadata.tagASIN || null
|
||||
this.tagOverdriveMediaMarker = metadata.tagOverdriveMediaMarker || null
|
||||
}
|
||||
|
||||
// Data parsed in prober.js
|
||||
@@ -82,6 +84,7 @@ class AudioMetaTags {
|
||||
this.tagIsbn = payload.file_tag_isbn || null
|
||||
this.tagLanguage = payload.file_tag_language || null
|
||||
this.tagASIN = payload.file_tag_asin || null
|
||||
this.tagOverdriveMediaMarker = payload.file_tag_overdrive_media_marker || null
|
||||
}
|
||||
|
||||
updateData(payload) {
|
||||
@@ -105,7 +108,8 @@ class AudioMetaTags {
|
||||
tagEncodedBy: payload.file_tag_encodedby || null,
|
||||
tagIsbn: payload.file_tag_isbn || null,
|
||||
tagLanguage: payload.file_tag_language || null,
|
||||
tagASIN: payload.file_tag_asin || null
|
||||
tagASIN: payload.file_tag_asin || null,
|
||||
tagOverdriveMediaMarker: payload.file_tag_overdrive_media_marker || null,
|
||||
}
|
||||
|
||||
var hasUpdates = false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const Logger = require('../../Logger')
|
||||
const { areEquivalent, copyValue } = require('../../utils/index')
|
||||
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
|
||||
const parseNameString = require('../../utils/parsers/parseNameString')
|
||||
class BookMetadata {
|
||||
constructor(metadata) {
|
||||
@@ -262,6 +262,10 @@ class BookMetadata {
|
||||
{
|
||||
tag: 'tagASIN',
|
||||
key: 'asin'
|
||||
},
|
||||
{
|
||||
tag: 'tagOverdriveMediaMarker',
|
||||
key: 'overdriveMediaMarker'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -338,15 +342,15 @@ class BookMetadata {
|
||||
}
|
||||
|
||||
searchSeries(query) {
|
||||
return this.series.filter(se => se.name.toLowerCase().includes(query))
|
||||
return this.series.filter(se => cleanStringForSearch(se.name).includes(query))
|
||||
}
|
||||
searchAuthors(query) {
|
||||
return this.authors.filter(se => se.name.toLowerCase().includes(query))
|
||||
return this.authors.filter(au => cleanStringForSearch(au.name).includes(query))
|
||||
}
|
||||
searchQuery(query) { // Returns key if match is found
|
||||
var keysToCheck = ['title', 'asin', 'isbn']
|
||||
for (var key of keysToCheck) {
|
||||
if (this[key] && this[key].toLowerCase().includes(query)) {
|
||||
if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) {
|
||||
return {
|
||||
matchKey: key,
|
||||
matchText: this[key]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const Logger = require('../../Logger')
|
||||
const { areEquivalent, copyValue } = require('../../utils/index')
|
||||
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
|
||||
|
||||
class PodcastMetadata {
|
||||
constructor(metadata) {
|
||||
@@ -94,7 +94,7 @@ class PodcastMetadata {
|
||||
searchQuery(query) { // Returns key if match is found
|
||||
var keysToCheck = ['title', 'author', 'itunesId', 'itunesArtistId']
|
||||
for (var key of keysToCheck) {
|
||||
if (this[key] && String(this[key]).toLowerCase().includes(query)) {
|
||||
if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) {
|
||||
return {
|
||||
matchKey: key,
|
||||
matchText: this[key]
|
||||
|
||||
@@ -12,7 +12,8 @@ class ServerSettings {
|
||||
this.scannerPreferAudioMetadata = false
|
||||
this.scannerPreferOpfMetadata = false
|
||||
this.scannerPreferMatchedMetadata = false
|
||||
this.scannerDisableWatcher = false
|
||||
this.scannerDisableWatcher = false
|
||||
this.scannerPreferOverdriveMediaMarker = false
|
||||
|
||||
// Metadata - choose to store inside users library item folder
|
||||
this.storeCoverWithItem = false
|
||||
@@ -47,6 +48,7 @@ class ServerSettings {
|
||||
// Misc Flags
|
||||
this.chromecastEnabled = false
|
||||
this.enableEReader = false
|
||||
this.dateFormat = 'MM/dd/yyyy'
|
||||
|
||||
this.logLevel = Logger.logLevel
|
||||
|
||||
@@ -65,6 +67,7 @@ class ServerSettings {
|
||||
this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata
|
||||
this.scannerPreferMatchedMetadata = !!settings.scannerPreferMatchedMetadata
|
||||
this.scannerDisableWatcher = !!settings.scannerDisableWatcher
|
||||
this.scannerPreferOverdriveMediaMarker = !!settings.scannerPreferOverdriveMediaMarker
|
||||
|
||||
this.storeCoverWithItem = !!settings.storeCoverWithItem
|
||||
if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was old name of setting < v2
|
||||
@@ -93,6 +96,7 @@ class ServerSettings {
|
||||
this.sortingPrefixes = settings.sortingPrefixes || ['the', 'a']
|
||||
this.chromecastEnabled = !!settings.chromecastEnabled
|
||||
this.enableEReader = !!settings.enableEReader
|
||||
this.dateFormat = settings.dateFormat || 'MM/dd/yyyy'
|
||||
this.logLevel = settings.logLevel || Logger.logLevel
|
||||
this.version = settings.version || null
|
||||
|
||||
@@ -111,6 +115,7 @@ class ServerSettings {
|
||||
scannerPreferOpfMetadata: this.scannerPreferOpfMetadata,
|
||||
scannerPreferMatchedMetadata: this.scannerPreferMatchedMetadata,
|
||||
scannerDisableWatcher: this.scannerDisableWatcher,
|
||||
scannerPreferOverdriveMediaMarker: this.scannerPreferOverdriveMediaMarker,
|
||||
storeCoverWithItem: this.storeCoverWithItem,
|
||||
storeMetadataWithItem: this.storeMetadataWithItem,
|
||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||
@@ -127,6 +132,7 @@ class ServerSettings {
|
||||
sortingPrefixes: [...this.sortingPrefixes],
|
||||
chromecastEnabled: this.chromecastEnabled,
|
||||
enableEReader: this.enableEReader,
|
||||
dateFormat: this.dateFormat,
|
||||
logLevel: this.logLevel,
|
||||
version: this.version
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ class MediaProgress {
|
||||
|
||||
var timeRemaining = this.duration - this.currentTime
|
||||
// If time remaining is less than 5 seconds then mark as finished
|
||||
if ((this.progress >= 1 || (!isNaN(timeRemaining) && timeRemaining < 5))) {
|
||||
if ((this.progress >= 1 || (this.duration && !isNaN(timeRemaining) && timeRemaining < 5))) {
|
||||
this.isFinished = true
|
||||
this.finishedAt = Date.now()
|
||||
this.progress = 1
|
||||
|
||||
@@ -34,6 +34,7 @@ class LibraryScan {
|
||||
get forceRescan() { return !!this._scanOptions.forceRescan }
|
||||
get preferAudioMetadata() { return !!this._scanOptions.preferAudioMetadata }
|
||||
get preferOpfMetadata() { return !!this._scanOptions.preferOpfMetadata }
|
||||
get preferOverdriveMediaMarker() { return !!this._scanOptions.preferOverdriveMediaMarker }
|
||||
get findCovers() { return !!this._scanOptions.findCovers }
|
||||
get timestamp() {
|
||||
return (new Date()).toISOString()
|
||||
|
||||
@@ -195,7 +195,7 @@ class MediaFileScanner {
|
||||
}
|
||||
}
|
||||
|
||||
async scanMediaFiles(mediaLibraryFiles, scanData, libraryItem, preferAudioMetadata, libraryScan = null) {
|
||||
async scanMediaFiles(mediaLibraryFiles, scanData, libraryItem, preferAudioMetadata, preferOverdriveMediaMarker, libraryScan = null) {
|
||||
var hasUpdated = false
|
||||
|
||||
var mediaScanResult = await this.executeMediaFileScans(libraryItem.mediaType, mediaLibraryFiles, scanData)
|
||||
@@ -208,6 +208,7 @@ class MediaFileScanner {
|
||||
} else if (mediaScanResult.audioFiles.length) {
|
||||
if (libraryScan) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library Item "${scanData.path}" Audio file scan took ${mediaScanResult.elapsed}ms for ${mediaScanResult.audioFiles.length} with average time of ${mediaScanResult.averageScanDuration}ms`)
|
||||
Logger.debug(`Library Item "${scanData.path}" Audio file scan took ${mediaScanResult.elapsed}ms for ${mediaScanResult.audioFiles.length} with average time of ${mediaScanResult.averageScanDuration}ms`)
|
||||
}
|
||||
|
||||
var totalAudioFilesToInclude = mediaScanResult.audioFiles.length
|
||||
@@ -247,7 +248,7 @@ class MediaFileScanner {
|
||||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
libraryItem.media.rebuildTracks()
|
||||
libraryItem.media.rebuildTracks(preferOverdriveMediaMarker)
|
||||
}
|
||||
} else { // Podcast Media Type
|
||||
var existingAudioFiles = mediaScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino))
|
||||
|
||||
@@ -9,6 +9,7 @@ class ScanOptions {
|
||||
this.preferAudioMetadata = false
|
||||
this.preferOpfMetadata = false
|
||||
this.preferMatchedMetadata = false
|
||||
this.preferOverdriveMediaMarker = false
|
||||
|
||||
if (options) {
|
||||
this.construct(options)
|
||||
@@ -34,7 +35,8 @@ class ScanOptions {
|
||||
storeCoverWithItem: this.storeCoverWithItem,
|
||||
preferAudioMetadata: this.preferAudioMetadata,
|
||||
preferOpfMetadata: this.preferOpfMetadata,
|
||||
preferMatchedMetadata: this.preferMatchedMetadata
|
||||
preferMatchedMetadata: this.preferMatchedMetadata,
|
||||
preferOverdriveMediaMarker: this.preferOverdriveMediaMarker
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +49,7 @@ class ScanOptions {
|
||||
this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata
|
||||
this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata
|
||||
this.scannerPreferMatchedMetadata = serverSettings.scannerPreferMatchedMetadata
|
||||
this.preferOverdriveMediaMarker = serverSettings.scannerPreferOverdriveMediaMarker
|
||||
}
|
||||
}
|
||||
module.exports = ScanOptions
|
||||
@@ -80,7 +80,7 @@ class Scanner {
|
||||
// Scan all audio files
|
||||
if (libraryItem.hasAudioFiles) {
|
||||
var libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio')
|
||||
if (await MediaFileScanner.scanMediaFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata)) {
|
||||
if (await MediaFileScanner.scanMediaFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata, this.db.serverSettings.scannerPreferOverdriveMediaMarker)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ class Scanner {
|
||||
|
||||
async scanNewLibraryItemDataChunk(newLibraryItemsData, libraryScan) {
|
||||
var newLibraryItems = await Promise.all(newLibraryItemsData.map((lid) => {
|
||||
return this.scanNewLibraryItem(lid, libraryScan.libraryMediaType, libraryScan.preferAudioMetadata, libraryScan.preferOpfMetadata, libraryScan.findCovers, libraryScan)
|
||||
return this.scanNewLibraryItem(lid, libraryScan.libraryMediaType, libraryScan.preferAudioMetadata, libraryScan.preferOpfMetadata, libraryScan.findCovers, libraryScan.preferOverdriveMediaMarker, libraryScan)
|
||||
}))
|
||||
newLibraryItems = newLibraryItems.filter(li => li) // Filter out nulls
|
||||
|
||||
@@ -337,7 +337,7 @@ class Scanner {
|
||||
// forceRescan all existing audio files - will probe and update ID3 tag metadata
|
||||
var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio')
|
||||
if (libraryScan.scanOptions.forceRescan && existingAudioFiles.length) {
|
||||
if (await MediaFileScanner.scanMediaFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
|
||||
if (await MediaFileScanner.scanMediaFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan.preferOverdriveMediaMarker, libraryScan)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
@@ -345,7 +345,7 @@ class Scanner {
|
||||
var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio')
|
||||
var removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio')
|
||||
if (newAudioFiles.length || removedAudioFiles.length) {
|
||||
if (await MediaFileScanner.scanMediaFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
|
||||
if (await MediaFileScanner.scanMediaFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan.preferOverdriveMediaMarker, libraryScan)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
@@ -379,7 +379,7 @@ class Scanner {
|
||||
return hasUpdated ? libraryItem : null
|
||||
}
|
||||
|
||||
async scanNewLibraryItem(libraryItemData, libraryMediaType, preferAudioMetadata, preferOpfMetadata, findCovers, libraryScan = null) {
|
||||
async scanNewLibraryItem(libraryItemData, libraryMediaType, preferAudioMetadata, preferOpfMetadata, findCovers, preferOverdriveMediaMarker, libraryScan = null) {
|
||||
if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Scanning new library item "${libraryItemData.path}"`)
|
||||
else Logger.debug(`[Scanner] Scanning new item "${libraryItemData.path}"`)
|
||||
|
||||
@@ -388,7 +388,7 @@ class Scanner {
|
||||
|
||||
var mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video')
|
||||
if (mediaFiles.length) {
|
||||
await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan)
|
||||
await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItemData, libraryItem, preferAudioMetadata, preferOverdriveMediaMarker, libraryScan)
|
||||
}
|
||||
|
||||
await libraryItem.syncFiles(preferOpfMetadata)
|
||||
@@ -608,7 +608,7 @@ class Scanner {
|
||||
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings)
|
||||
if (!libraryItemData) return null
|
||||
var serverSettings = this.db.serverSettings
|
||||
return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers)
|
||||
return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers, serverSettings.scannerPreferOverdriveMediaMarker)
|
||||
}
|
||||
|
||||
async searchForCover(libraryItem, libraryScan = null) {
|
||||
|
||||
@@ -273,8 +273,8 @@ function parseAbMetadataText(text, mediaType) {
|
||||
} else if (!metadataMapper[keyValue[0].trim()]) {
|
||||
Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid ${mediaType} metadata key`)
|
||||
} else {
|
||||
var key = keyValue[0].trim()
|
||||
var value = keyValue[1].trim()
|
||||
var key = keyValue.shift().trim()
|
||||
var value = keyValue.join('=').trim()
|
||||
mediaMetadataDetails[key] = metadataMapper[key].from(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,4 +210,4 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,4 +129,10 @@ module.exports.encodeUriPath = (path) => {
|
||||
module.exports.toNumber = (val, fallback = 0) => {
|
||||
if (isNaN(val) || val === null) return fallback
|
||||
return Number(val)
|
||||
}
|
||||
|
||||
module.exports.cleanStringForSearch = (str) => {
|
||||
if (!str) return ''
|
||||
// Remove ' . ` " ,
|
||||
return str.toLowerCase().replace(/[\'\.\`\",]/g, '').trim()
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
const Logger = require('../../Logger')
|
||||
|
||||
// given a list of audio files, extract all of the Overdrive Media Markers metaTags, and return an array of them as XML
|
||||
function extractOverdriveMediaMarkers(includedAudioFiles) {
|
||||
Logger.debug('[parseOverdriveMediaMarkers] Extracting overdrive media markers')
|
||||
var markers = includedAudioFiles.map((af) => af.metaTags.tagOverdriveMediaMarker).filter(notUndefined => notUndefined !== undefined).filter(elem => { return elem !== null }) || []
|
||||
|
||||
return markers
|
||||
}
|
||||
|
||||
// given the array of Overdrive Media Markers from generateOverdriveMediaMarkers()
|
||||
// parse and clean them in to something a bit more usable
|
||||
function cleanOverdriveMediaMarkers(overdriveMediaMarkers) {
|
||||
Logger.debug('[parseOverdriveMediaMarkers] Cleaning up overdrive media markers')
|
||||
/*
|
||||
returns an array of arrays of objects. Each inner array corresponds to an audio track, with it's objects being a chapter:
|
||||
[
|
||||
[
|
||||
{
|
||||
"Name": "Chapter 1",
|
||||
"Time": "0:00.000"
|
||||
},
|
||||
{
|
||||
"Name": "Chapter 2",
|
||||
"Time": "15:51.000"
|
||||
},
|
||||
{ etc }
|
||||
]
|
||||
]
|
||||
*/
|
||||
|
||||
var parseString = require('xml2js').parseString; // function to convert xml to JSON
|
||||
var parsedOverdriveMediaMarkers = []
|
||||
|
||||
overdriveMediaMarkers.forEach(function (item, index) {
|
||||
var parsed_result
|
||||
parseString(item, function (err, result) {
|
||||
/*
|
||||
result.Markers.Marker is the result of parsing the XML for the MediaMarker tags for the MP3 file (Part##.mp3)
|
||||
it is shaped like this, and needs further cleaning below:
|
||||
[
|
||||
{
|
||||
"Name": [
|
||||
"Chapter 1: "
|
||||
],
|
||||
"Time": [
|
||||
"0:00.000"
|
||||
]
|
||||
},
|
||||
{
|
||||
ANOTHER CHAPTER
|
||||
},
|
||||
]
|
||||
*/
|
||||
|
||||
// The values for Name and Time in results.Markers.Marker are returned as Arrays from parseString and should be strings
|
||||
parsed_result = objectValuesArrayToString(result.Markers.Marker)
|
||||
})
|
||||
|
||||
parsedOverdriveMediaMarkers.push(parsed_result)
|
||||
})
|
||||
|
||||
return removeExtraChapters(parsedOverdriveMediaMarkers)
|
||||
}
|
||||
|
||||
// given an array of objects, convert any values that are arrays to strings
|
||||
function objectValuesArrayToString(arrayOfObjects) {
|
||||
Logger.debug('[parseOverdriveMediaMarkers] Converting Marker object values from arrays to strings')
|
||||
arrayOfObjects.forEach((item) => {
|
||||
Object.keys(item).forEach(key => {
|
||||
item[key] = item[key].toString()
|
||||
})
|
||||
})
|
||||
|
||||
return arrayOfObjects
|
||||
}
|
||||
|
||||
// Overdrive sometimes has weird chapters and subchapters defined
|
||||
// These aren't necessary, so lets remove them
|
||||
function removeExtraChapters(parsedOverdriveMediaMarkers) {
|
||||
Logger.debug('[parseOverdriveMediaMarkers] Removing any unnecessary chapters')
|
||||
const weirdChapterFilterRegex = /([(]\d|[cC]ontinued)/
|
||||
var cleaned = []
|
||||
parsedOverdriveMediaMarkers.forEach(function (item) {
|
||||
cleaned.push(item.filter(chapter => !weirdChapterFilterRegex.test(chapter.Name)))
|
||||
})
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// Given a set of chapters from generateParsedChapters, add the end time to each one
|
||||
function addChapterEndTimes(chapters, totalAudioDuration) {
|
||||
Logger.debug('[parseOverdriveMediaMarkers] Adding chapter end times')
|
||||
chapters.forEach((chapter, chapter_index) => {
|
||||
if (chapter_index < chapters.length - 1) {
|
||||
chapter.end = chapters[chapter_index + 1].start
|
||||
} else {
|
||||
chapter.end = totalAudioDuration
|
||||
}
|
||||
})
|
||||
|
||||
return chapters
|
||||
}
|
||||
|
||||
// The function that actually generates the Chapters object that we update ABS with
|
||||
function generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers) {
|
||||
Logger.debug('[parseOverdriveMediaMarkers] Generating new chapters for ABS')
|
||||
// logic ported over from benonymity's OverdriveChapterizer:
|
||||
// https://github.com/benonymity/OverdriveChapterizer/blob/main/chapters.py
|
||||
var parsedChapters = []
|
||||
var length = 0.0
|
||||
var index = 0
|
||||
var time = 0.0
|
||||
|
||||
// cleanedOverdriveMediaMarkers is an array of array of objects, where the inner array matches to the included audio files tracks
|
||||
// this allows us to leverage the individual track durations when calculating the start times of chapters in tracks after the first (using length)
|
||||
includedAudioFiles.forEach((track, track_index) => {
|
||||
cleanedOverdriveMediaMarkers[track_index].forEach((chapter) => {
|
||||
time = chapter.Time.split(":")
|
||||
time = length + parseFloat(time[0]) * 60 + parseFloat(time[1])
|
||||
var newChapterData = {
|
||||
id: index++,
|
||||
start: time,
|
||||
title: chapter.Name
|
||||
}
|
||||
parsedChapters.push(newChapterData)
|
||||
})
|
||||
length += track.duration
|
||||
})
|
||||
|
||||
parsedChapters = addChapterEndTimes(parsedChapters, length) // we need all the start times sorted out before we can add the end times
|
||||
|
||||
return parsedChapters
|
||||
}
|
||||
|
||||
module.exports.overdriveMediaMarkersExist = (includedAudioFiles) => {
|
||||
return extractOverdriveMediaMarkers(includedAudioFiles).length > 1
|
||||
}
|
||||
|
||||
module.exports.parseOverdriveMediaMarkersAsChapters = (includedAudioFiles) => {
|
||||
Logger.info('[parseOverdriveMediaMarkers] Parsing of Overdrive Media Markers started')
|
||||
|
||||
var overdriveMediaMarkers = extractOverdriveMediaMarkers(includedAudioFiles)
|
||||
var cleanedOverdriveMediaMarkers = cleanOverdriveMediaMarkers(overdriveMediaMarkers)
|
||||
var parsedChapters = generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers)
|
||||
|
||||
return parsedChapters
|
||||
}
|
||||
@@ -192,6 +192,7 @@ function parseTags(format, verbose) {
|
||||
file_tag_movement: tryGrabTags(format, 'movement', 'mvin'),
|
||||
file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'),
|
||||
file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2'),
|
||||
file_tag_overdrive_media_marker: tryGrabTags(format, 'OverDrive MediaMarkers'),
|
||||
}
|
||||
for (const key in tags) {
|
||||
if (!tags[key]) {
|
||||
|
||||
Reference in New Issue
Block a user