Compare commits

...

23 Commits

Author SHA1 Message Date
advplyr d05e9ebfdd Merge pull request #131 from jarrodCoombes/master
Updated Readme to include nginx config.
2021-10-23 20:32:07 -05:00
advplyr 92c2c53c09 Add: track manager sort by filename #128, Add: Support publish year wrapped in parenthesis, Change: Parse out CD Num from filename, Change: Show First Last author everywhere when Last, First is used in folder name, Fix: Re-Scan updates parsed track nums 2021-10-23 20:31:48 -05:00
JRC 80f8a784c8 Fixed minor formatting issue. 2021-10-23 17:13:29 -07:00
JRC de9c0ef034 Updated the readme to include the nginx settings 2021-10-23 17:12:32 -07:00
advplyr b32b99418a Add: map series and series-part meta tags #129 2021-10-23 17:32:42 -05:00
advplyr 98c1ee01fd Add: add hotkeys to modals, player, and ereader #121, Fix: audio player track not resizing on window resize #123, Add: sortable columns on manage tracks page #128 2021-10-23 16:49:34 -05:00
advplyr 97a065030e Change: ffmpeg stream avoid negative timestamp #116 2021-10-23 13:42:07 -05:00
advplyr ee1dc92898 Fix: hls fmp4 init.mp4 filename to stream flac files 2021-10-23 10:57:01 -05:00
advplyr 74d2987310 Merge pull request #124 from zv0n/master
Change: Dockerfile install ffmpeg from source to support more architectures
2021-10-23 10:52:27 -05:00
advplyr c20aaf3cb2 Fix: genre tag map check empty array and check metadata on rescan #114 2021-10-23 06:50:13 -05:00
zvon 994eb2862e Change: support more architectures in Dockerfile 2021-10-23 13:18:58 +02:00
advplyr ff1eeda468 Change: config page to multiple pages, Add: user permissions for accessible libraries #120, Add: map genre metadata tag #114, Add: experimental audio player keyboard controls #121, Add: view user audiobook progress list 2021-10-22 20:08:02 -05:00
advplyr 7d9ed75a28 Change: audio player default volume to 100% #118, Change: username case insensitive #117, Fix: allowing multiple users of the same name, Added: experimental scan audio tracks show raw tags #114 2021-10-20 18:54:05 -05:00
advplyr 09aed354b3 Fix: icon font size 2021-10-18 19:53:33 -05:00
advplyr e3425acd75 Fix icon size issue, clean up reader interface, extract metadata from comics #113 2021-10-17 18:05:43 -05:00
advplyr 03e39640be Fix series displayed on global search menu book 2021-10-17 12:18:26 -05:00
advplyr 48f0e039e5 New search page, updated search menu includes tags #112 2021-10-17 11:29:52 -05:00
advplyr aa872948d5 Fonts saved locally #111, Fix select cover from search 2021-10-17 07:09:40 -05:00
advplyr 88e2bac3f5 Support cbr and cbz comics and comic reader #109 2021-10-16 20:33:49 -05:00
advplyr 18c1d8f1a3 Add: user settings for mobile sort & filter 2021-10-16 15:49:34 -05:00
advplyr b9dee8704f add db init logs for checking root user #81, add track number check on edit page #108 2021-10-16 07:49:12 -05:00
advplyr 03963aa9a1 change color of book read icon #105, basic .pdf reader #107, fix: cover path updating properly #102, step forward/backward from book edit modal #100, add all files tab to edit modal #99, select input auto submit on blur #98 2021-10-15 20:31:00 -05:00
advplyr 315592efe5 Backup manager to only zip specific directories in config #89 2021-10-14 19:22:59 -05:00
100 changed files with 2926 additions and 1127 deletions
+3 -6
View File
@@ -1,18 +1,15 @@
### STAGE 0: FFMPEG ###
FROM jrottenberg/ffmpeg:4.1-alpine AS ffmpeg
### STAGE 1: Build client ###
### STAGE 0: Build client ###
FROM node:12-alpine AS build
WORKDIR /client
COPY /client /client
RUN npm install
RUN npm run generate
### STAGE 2: Build server ###
### STAGE 1: Build server ###
FROM node:12-alpine
RUN apk update && apk add --no-cache --update ffmpeg
ENV NODE_ENV=production
COPY --from=build /client/dist /client/dist
COPY --from=ffmpeg / /
COPY index.js index.js
COPY package.json package.json
COPY server server
+3 -2
View File
@@ -1,5 +1,6 @@
@import url('./transitions.css');
@import url('./draggable.css');
@import './fonts.css';
@import './transitions.css';
@import './draggable.css';
.page {
width: 100%;
+44
View File
@@ -0,0 +1,44 @@
/* fallback */
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(/fonts/material-icons.woff2) format('woff2');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons:not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl) {
font-size: 1.5rem;
}
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+59 -3
View File
@@ -15,7 +15,7 @@
</div>
<div class="absolute right-32 top-0 bottom-0">
<controls-volume-control v-model="volume" @input="updateVolume" />
<controls-volume-control ref="volumeControl" v-model="volume" @input="updateVolume" />
</div>
<div class="flex my-2">
<div class="flex-grow" />
@@ -91,7 +91,7 @@ export default {
return {
hlsInstance: null,
staleHlsInstance: null,
volume: 0.5,
volume: 1,
playbackRate: 1,
trackWidth: 0,
isPaused: true,
@@ -451,7 +451,8 @@ export default {
})
},
showChapters() {
this.showChaptersModal = true
if (!this.chapters.length) return
this.showChaptersModal = !this.showChaptersModal
},
play() {
if (!this.$refs.audio) {
@@ -486,6 +487,9 @@ export default {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.audioEl = this.$refs.audio
this.setTrackWidth()
},
setTrackWidth() {
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
@@ -496,14 +500,66 @@ export default {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
this.updatePlaybackRate(settings.playbackRate)
}
},
volumeUp() {
if (this.volume >= 1) return
this.volume = Math.min(1, this.volume + 0.1)
this.updateVolume(this.volume)
},
volumeDown() {
if (this.volume <= 0) return
this.volume = Math.max(0, this.volume - 0.1)
this.updateVolume(this.volume)
},
toggleMute() {
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
this.$refs.volumeControl.toggleMute()
}
},
increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
},
decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
},
closePlayer() {
if (this.loading) return
this.$emit('close')
},
hotkey(action) {
if (action === 'Space') this.playPauseClick()
else if (action === 'ArrowRight') this.forward10()
else if (action === 'ArrowLeft') this.backward10()
else if (action === 'ArrowUp') this.volumeUp()
else if (action === 'ArrowDown') this.volumeDown()
else if (action === 'KeyM') this.toggleMute()
else if (action === 'KeyL') this.showChapters()
else if (action === 'Shift-ArrowUp') this.increasePlaybackRate()
else if (action === 'Shift-ArrowDown') this.decreasePlaybackRate()
else if (action === 'Escape') this.closePlayer()
},
windowResize() {
this.setTrackWidth()
}
},
mounted() {
window.addEventListener('resize', this.windowResize)
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init()
this.$eventBus.$on('player-hotkey', this.hotkey)
},
beforeDestroy() {
window.removeEventListener('resize', this.windowResize)
this.$store.commit('user/removeSettingsListener', 'audioplayer')
this.$eventBus.$off('player-hotkey', this.hotkey)
}
}
</script>
-10
View File
@@ -8,13 +8,6 @@
</a>
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
<!-- <div class="bg-black bg-opacity-20 rounded-md py-1.5 px-3 flex items-center text-white text-opacity-70 cursor-pointer hover:bg-opacity-10 hover:text-opacity-90" @click="clickLibrary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
<p class="text-sm leading-3 font-sans pl-2">{{ libraryName }}</p>
</div> -->
<ui-libraries-dropdown />
<controls-global-search />
@@ -133,9 +126,6 @@ export default {
}
},
methods: {
clickLibrary() {
this.$store.commit('libraries/setShowModal', true)
},
async back() {
var popped = await this.$store.dispatch('popRoute')
var backTo = popped || '/'
+65 -6
View File
@@ -1,7 +1,7 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
<!-- Cover size widget -->
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-30">
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
@@ -16,6 +16,14 @@
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
</div>
</div>
<div v-else-if="page === 'search'" id="bookshelf-categorized" class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in categorizedShelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
</template>
<div v-show="!categorizedShelves.length" class="w-full py-16 text-center text-xl">
<div class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
</div>
</div>
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in shelves">
<div :key="index" class="w-full bookshelfRow relative">
@@ -23,7 +31,7 @@
<template v-for="entity in shelf">
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @edit="editBook" />
</template>
</div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
@@ -45,8 +53,8 @@ export default {
page: String,
selectedSeries: String,
searchResults: {
type: Array,
default: () => []
type: Object,
default: () => {}
},
searchQuery: String
},
@@ -74,7 +82,7 @@ export default {
},
searchResults() {
this.$nextTick(() => {
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
// this.$store.commit('audiobooks/setSearchResults', this.searchResults)
this.setBookshelfEntities()
})
},
@@ -94,6 +102,9 @@ export default {
audiobooks() {
return this.$store.state.audiobooks.audiobooks
},
sizeMultiplier() {
return this.bookCoverWidth / 120
},
bookCoverWidth() {
return this.availableSizes[this.selectedSizeIndex]
},
@@ -122,11 +133,54 @@ export default {
showGroups() {
return this.page !== '' && this.page !== 'search' && !this.selectedSeries
},
categorizedShelves() {
if (this.page !== 'search') return []
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
const shelves = []
if (audiobookSearchResults.length) {
shelves.push({
label: 'Books',
books: audiobookSearchResults.map((absr) => absr.audiobook)
})
}
if (this.searchResults.series && this.searchResults.series.length) {
var seriesGroups = this.searchResults.series.map((seriesResult) => {
return {
type: 'series',
name: seriesResult.series || '',
books: seriesResult.audiobooks || []
}
})
shelves.push({
label: 'Series',
series: seriesGroups
})
}
if (this.searchResults.tags && this.searchResults.tags.length) {
var tagGroups = this.searchResults.tags.map((tagResult) => {
return {
type: 'tags',
name: tagResult.tag || '',
books: tagResult.audiobooks || []
}
})
shelves.push({
label: 'Tags',
series: tagGroups
})
}
return shelves
},
entities() {
if (this.page === '') {
return this.$store.getters['audiobooks/getFilteredAndSorted']()
} else if (this.page === 'search') {
return this.searchResults || []
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
return audiobookSearchResults.map((absr) => absr.audiobook)
} else {
var seriesGroups = this.$store.getters['audiobooks/getSeriesGroups']()
if (this.selectedSeries) {
@@ -138,6 +192,11 @@ export default {
}
},
methods: {
editBook(audiobook) {
var bookIds = this.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', audiobook)
},
clickGroup(group) {
this.$emit('update:selectedSeries', group.name)
},
@@ -51,9 +51,6 @@ export default {
sizeMultiplier() {
return this.bookCoverWidth / 120
},
signSizeMultiplier() {
return (1 - this.sizeMultiplier) / 2 + this.sizeMultiplier
},
paddingX() {
return 16 * this.sizeMultiplier
},
+19 -5
View File
@@ -2,9 +2,21 @@
<div class="relative">
<div ref="shelf" class="w-full max-w-full bookshelfRowCategorized relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: 2.5 * sizeMultiplier + 'rem' }" @scroll="scrolled">
<div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }">
<div class="flex items-center -mb-2">
<div v-if="shelf.books" class="flex items-center -mb-2">
<template v-for="entity in shelf.books">
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" />
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" @edit="editBook" />
</template>
</div>
<div v-else-if="shelf.series" class="flex items-center -mb-2">
<template v-for="entity in shelf.series">
<cards-group-card :key="entity.name" :width="bookCoverWidth" :group="entity" @click="$emit('clickSeries', entity)" />
</template>
</div>
<div v-else-if="shelf.tags" class="flex items-center -mb-2">
<template v-for="entity in shelf.tags">
<nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`">
<cards-group-card :width="bookCoverWidth" :group="entity" />
</nuxt-link>
</template>
</div>
</div>
@@ -53,6 +65,11 @@ export default {
}
},
methods: {
editBook(audiobook) {
var bookIds = this.shelf.books.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', audiobook)
},
scrolled() {
clearTimeout(this.scrollTimer)
this.scrollTimer = setTimeout(() => {
@@ -62,7 +79,6 @@ export default {
},
scrollLeft() {
if (!this.$refs.shelf) {
console.error('No Shelf', this.index)
return
}
this.isScrolling = true
@@ -70,7 +86,6 @@ export default {
},
scrollRight() {
if (!this.$refs.shelf) {
console.error('No Shelf', this.index)
return
}
this.isScrolling = true
@@ -84,7 +99,6 @@ export default {
},
checkCanScroll() {
if (!this.$refs.shelf) {
console.error('No Shelf', this.index)
return
}
var clientWidth = this.$refs.shelf.clientWidth
+4 -3
View File
@@ -41,8 +41,8 @@ export default {
isHome: Boolean,
selectedSeries: String,
searchResults: {
type: Array,
default: () => []
type: Object,
default: () => {}
},
searchQuery: String
},
@@ -60,7 +60,8 @@ export default {
if (this.page === '') {
return this.$store.getters['audiobooks/getFiltered']().length
} else if (this.page === 'search') {
return (this.searchResults || []).length
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
return audiobookSearchResults.length
} else {
var groups = this.$store.getters['audiobooks/getSeriesGroups']()
if (this.selectedSeries) {
+56
View File
@@ -0,0 +1,56 @@
<template>
<div class="w-44 fixed left-0 top-16 z-40 h-full bg-bg bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-4">
<nuxt-link to="/config" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Settings</p>
<div v-show="routeName === 'config'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/libraries" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-libraries' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Libraries</p>
<div v-show="routeName === 'config-libraries'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/users" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-users' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Users</p>
<div v-show="routeName === 'config-users'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/backups" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-backups' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Backups</p>
<div v-show="routeName === 'config-backups'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/log" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-log' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Log</p>
<div v-show="routeName === 'config-log'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute bottom-20 left-0 flex flex-col justify-center">
<p class="font-mono text-sm">v{{ $config.version }}</p>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
</div>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {
routeName() {
return this.$route.name
},
versionData() {
return this.$store.state.versionData || {}
},
hasUpdate() {
return !!this.versionData.hasUpdate
},
latestVersion() {
return this.versionData.latestVersion
},
githubTagUrl() {
return this.versionData.githubTagUrl
}
},
methods: {},
mounted() {}
}
</script>
-327
View File
@@ -1,327 +0,0 @@
<template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-50 bg-white text-black">
<div class="absolute top-4 right-4 z-10">
<span class="material-icons cursor-pointer text-4xl" @click="show = false">close</span>
</div>
<!-- <div v-if="chapters.length" class="absolute top-0 left-0 w-52">
<select v-model="selectedChapter" class="w-52" @change="changedChapter">
<option v-for="chapter in chapters" :key="chapter.href" :value="chapter.href">{{ chapter.label }}</option>
</select>
</div> -->
<div class="absolute top-4 left-4 font-book">
<h1 class="text-2xl mb-1">{{ title || abTitle }}</h1>
<p v-if="author || abAuthor">by {{ author || abAuthor }}</p>
</div>
<!-- EPUB -->
<div v-if="epubEbook" class="h-full flex items-center">
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden">
<span v-show="hasPrev" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @mousedown.prevent @click="pageLeft">chevron_left</span>
</div>
<div id="frame" class="w-full" style="height: 650px">
<div id="viewer" class="spreads"></div>
<div class="px-16 flex justify-center" style="height: 50px">
<p class="px-4">{{ progress }}%</p>
</div>
</div>
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden">
<span v-show="hasNext" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @mousedown.prevent @click="pageRight">chevron_right</span>
</div>
</div>
<!-- MOBI/AZW3 -->
<div v-else class="h-full max-h-full w-full">
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20">
<iframe title="html-viewer" width="100%"> Loading </iframe>
</div>
</div>
</div>
</template>
<script>
import ePub from 'epubjs'
import MobiParser from '@/assets/ebooks/mobi.js'
import HtmlParser from '@/assets/ebooks/htmlParser.js'
import defaultCss from '@/assets/ebooks/basic.js'
export default {
data() {
return {
scale: 1,
book: null,
rendition: null,
chapters: [],
title: '',
author: '',
progress: 0,
hasNext: true,
hasPrev: false
}
},
watch: {
show(newVal) {
if (newVal) {
this.init()
} else {
this.close()
}
}
},
computed: {
show: {
get() {
return this.$store.state.showEReader
},
set(val) {
this.$store.commit('setShowEReader', val)
}
},
abTitle() {
return this.selectedAudiobook.book.title
},
abAuthor() {
return this.selectedAudiobook.book.author
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook
},
libraryId() {
return this.selectedAudiobook.libraryId
},
folderId() {
return this.selectedAudiobook.folderId
},
ebooks() {
return this.selectedAudiobook.ebooks || []
},
epubEbook() {
return this.ebooks.find((eb) => eb.ext === '.epub')
},
epubPath() {
return this.epubEbook ? this.epubEbook.path : null
},
mobiEbook() {
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
},
mobiPath() {
return this.mobiEbook ? this.mobiEbook.path : null
},
mobiUrl() {
if (!this.mobiPath) return null
return `/ebook/${this.libraryId}/${this.folderId}/${this.mobiPath}`
},
url() {
if (!this.epubPath) return null
return `/ebook/${this.libraryId}/${this.folderId}/${this.epubPath}`
},
userToken() {
return this.$store.getters['user/getToken']
}
},
methods: {
changedChapter() {
if (this.rendition) {
this.rendition.display(this.selectedChapter)
}
},
pageLeft() {
if (this.rendition) {
this.rendition.prev()
}
},
pageRight() {
if (this.rendition) {
this.rendition.next()
}
},
keyUp(e) {
if (!this.rendition) {
console.error('No rendition')
return
}
if ((e.keyCode || e.which) == 37) {
this.rendition.prev()
} else if ((e.keyCode || e.which) == 39) {
this.rendition.next()
} else if ((e.keyCode || e.which) == 27) {
this.show = false
}
},
registerListeners() {
document.addEventListener('keyup', this.keyUp)
},
unregisterListeners() {
document.removeEventListener('keyup', this.keyUp)
},
init() {
this.registerListeners()
if (this.epubEbook) {
this.initEpub()
} else if (this.mobiEbook) {
this.initMobi()
}
},
addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0]
if (!iframe) return
let doc = iframe.contentDocument
if (!doc) return
let style = doc.createElement('style')
style.id = 'default-style'
style.textContent = defaultCss
doc.head.appendChild(style)
},
handleIFrameHeight(iFrame) {
const isElement = (obj) => !!(obj && obj.nodeType === 1)
var body = iFrame.contentWindow.document.body,
html = iFrame.contentWindow.document.documentElement
iFrame.height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight) * 2
setTimeout(() => {
let lastchild = body.lastElementChild
let lastEle = body.lastChild
let itemAs = body.querySelectorAll('a')
let itemPs = body.querySelectorAll('p')
let lastItemA = itemAs[itemAs.length - 1]
let lastItemP = itemPs[itemPs.length - 1]
let lastItem
if (isElement(lastItemA) && isElement(lastItemP)) {
if (lastItemA.clientHeight + lastItemA.offsetTop > lastItemP.clientHeight + lastItemP.offsetTop) {
lastItem = lastItemA
} else {
lastItem = lastItemP
}
}
if (!lastchild && !lastItem && !lastEle) return
if (lastEle.nodeType === 3 && !lastchild && !lastItem) return
let nodeHeight = 0
if (lastEle.nodeType === 3 && document.createRange) {
let range = document.createRange()
range.selectNodeContents(lastEle)
if (range.getBoundingClientRect) {
let rect = range.getBoundingClientRect()
if (rect) {
nodeHeight = rect.bottom - rect.top
}
}
}
var lastChildHeight = isElement(lastchild) ? lastchild.clientHeight + lastchild.offsetTop : 0
var lastEleHeight = isElement(lastEle) ? lastEle.clientHeight + lastEle.offsetTop : 0
var lastItemHeight = isElement(lastItem) ? lastItem.clientHeight + lastItem.offsetTop : 0
iFrame.height = Math.max(lastChildHeight, lastEleHeight, lastItemHeight) + 100 + nodeHeight
}, 500)
},
async initMobi() {
// Fetch mobi file as blob
var buff = await this.$axios.$get(this.mobiUrl, {
responseType: 'blob'
})
var reader = new FileReader()
reader.onload = async (event) => {
var file_content = event.target.result
let mobiFile = new MobiParser(file_content)
let content = await mobiFile.render()
let htmlParser = new HtmlParser(new DOMParser().parseFromString(content.outerHTML, 'text/html'))
var anchoredDoc = htmlParser.getAnchoredDoc()
let iFrame = document.getElementsByTagName('iframe')[0]
iFrame.contentDocument.body.innerHTML = anchoredDoc.documentElement.outerHTML
// Add css
let style = iFrame.contentDocument.createElement('style')
style.id = 'default-style'
style.textContent = defaultCss
iFrame.contentDocument.head.appendChild(style)
this.handleIFrameHeight(iFrame)
}
reader.readAsArrayBuffer(buff)
},
initEpub() {
// var book = ePub(this.url, {
// requestHeaders: {
// Authorization: `Bearer ${this.userToken}`
// }
// })
var book = ePub(this.url)
this.book = book
this.rendition = book.renderTo('viewer', {
width: window.innerWidth - 200,
height: 600,
ignoreClass: 'annotator-hl',
manager: 'continuous',
spread: 'always'
})
var displayed = this.rendition.display()
book.ready
.then(() => {
console.log('Book ready')
return book.locations.generate(1600)
})
.then((locations) => {
// console.log('Loaded locations', locations)
// Wait for book to be rendered to get current page
displayed.then(() => {
// Get the current CFI
var currentLocation = this.rendition.currentLocation()
if (!currentLocation.start) {
console.error('No Start', currentLocation)
} else {
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
// console.log('current page', currentPage)
}
})
})
book.loaded.navigation.then((toc) => {
var _chapters = []
toc.forEach((chapter) => {
_chapters.push(chapter)
})
this.chapters = _chapters
})
book.loaded.metadata.then((metadata) => {
this.author = metadata.creator
this.title = metadata.title
})
this.rendition.on('keyup', this.keyUp)
this.rendition.on('relocated', (location) => {
var percent = book.locations.percentageFromCfi(location.start.cfi)
var percentage = Math.floor(percent * 100)
this.progress = percentage
this.hasNext = !location.atEnd
this.hasPrev = !location.atStart
})
},
close() {
this.unregisterListeners()
}
},
mounted() {
if (this.show) this.init()
},
beforeDestroy() {
this.unregisterListeners()
}
}
</script>
<style>
/* @import url(@/assets/calibre/basic.css); */
.ebook-viewer {
height: calc(100% - 96px);
}
</style>
+1 -1
View File
@@ -14,7 +14,7 @@
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
</div>
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" @close="cancelStream" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
</div>
</template>
@@ -1,9 +1,16 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<cards-book-cover :audiobook="audiobook" :width="50" />
<div class="flex-grow px-2 searchCardContent h-full">
<p class="truncate text-sm">{{ title }}</p>
<p class="text-xs text-gray-200 truncate">by {{ author }}</p>
<div class="flex-grow px-2 audiobookSearchCardContent">
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags'" class="m-0 p-0 truncate" v-html="matchHtml" />
</div>
</div>
</template>
@@ -14,7 +21,10 @@ export default {
audiobook: {
type: Object,
default: () => {}
}
},
search: String,
matchKey: String,
matchText: String
},
data() {
return {}
@@ -26,8 +36,35 @@ export default {
title() {
return this.book ? this.book.title : 'No Title'
},
author() {
return this.book ? this.book.author : 'Unknown'
subtitle() {
return this.book ? this.book.subtitle : ''
},
authorFL() {
return this.book ? this.book.authorFL : 'Unknown'
},
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
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authorFL') return `by ${html}`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
return `${html}`
}
},
methods: {},
@@ -36,9 +73,9 @@ export default {
</script>
<style>
.searchCardContent {
.audiobookSearchCardContent {
width: calc(100% - 80px);
height: calc(50px * 1.5);
height: 75px;
display: flex;
flex-direction: column;
justify-content: center;
+2 -2
View File
@@ -1,6 +1,6 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<img src="https://rpgplanner.com/wp-content/uploads/2020/06/no-photo-available.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" />
<img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" />
<div class="flex-grow px-2 authorSearchCardContent h-full">
<p class="truncate text-sm">{{ author }}</p>
</div>
@@ -22,7 +22,7 @@ export default {
</script>
<style>
.searchCardContent {
.authorSearchCardContent {
width: calc(100% - 80px);
height: 40px;
display: flex;
+6 -2
View File
@@ -164,11 +164,14 @@ export default {
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isIncomplete
},
isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks
return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks && !this.isStreaming
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
@@ -228,7 +231,8 @@ export default {
this.$root.socket.emit('open_stream', this.audiobookId)
},
editClick() {
this.$store.commit('showEditModal', this.audiobook)
// this.$store.commit('showEditModal', this.audiobook)
this.$emit('edit', this.audiobook)
},
clickCard(e) {
if (this.isSelectionMode) {
+11 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="relative">
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series?${groupType}=${groupEncode}`" class="cursor-pointer">
<nuxt-link :to="groupTo" class="cursor-pointer">
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: height + 'px', width: height + 'px' }">
<cards-group-cover ref="groupcover" :name="groupName" :book-items="bookItems" :width="height" :height="height" />
@@ -54,6 +54,16 @@ export default {
_group() {
return this.group || {}
},
groupType() {
return this._group.type
},
groupTo() {
if (this.groupType === 'series') {
return `/library/${this.currentLibraryId}/bookshelf/series?series=${this.groupEncode}`
} else {
return `/library/${this.currentLibraryId}/bookshelf?filter=tags.${this.groupEncode}`
}
},
height() {
return this.width * 1.6
},
+34
View File
@@ -0,0 +1,34 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<div class="w-10 h-10 flex items-center justify-center">
<span class="material-icons text-2xl text-gray-200">local_offer</span>
</div>
<div class="flex-grow px-2 tagSearchCardContent h-full">
<p class="truncate text-sm">{{ tag }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
tag: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.tagSearchCardContent {
width: calc(100% - 40px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>
+32 -13
View File
@@ -23,7 +23,7 @@
<template v-for="item in audiobookResults">
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/audiobook/${item.audiobook.id}`">
<cards-audiobook-search-card :audiobook="item.audiobook" />
<cards-audiobook-search-card :audiobook="item.audiobook" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link>
</li>
</template>
@@ -39,12 +39,21 @@
<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" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickedOption(item.series)">
<li :key="item.series" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series?series=${$encode(item.series)}`">
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
</nuxt-link>
</li>
</template>
<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.tag" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.tag)}`">
<cards-tag-search-card :tag="item.tag" />
</nuxt-link>
</li>
</template>
</template>
</ul>
</div>
@@ -64,6 +73,7 @@ export default {
audiobookResults: [],
authorResults: [],
seriesResults: [],
tagResults: [],
searchTimeout: null,
lastSearch: null
}
@@ -76,19 +86,27 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
totalResults() {
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length
}
},
methods: {
submitSearch() {
if (!this.search) return
this.$router.push(`/library/${this.currentLibraryId}/bookshelf/search?query=${this.search}`)
var search = this.search
this.clearResults()
this.$router.push(`/library/${this.currentLibraryId}/bookshelf/search?query=${search}`)
},
clearResults() {
this.search = null
this.lastSearch = null
this.audiobookResults = []
this.authorResults = []
this.seriesResults = []
this.tagResults = []
this.showMenu = false
this.isFetching = false
this.isTyping = false
clearTimeout(this.searchTimeout)
this.$nextTick(() => {
if (this.$refs.input) {
this.$refs.input.blur()
@@ -117,9 +135,14 @@ export default {
console.error('Search error', error)
return []
})
// Search was canceled
if (!this.isFetching) return
this.audiobookResults = searchResults.audiobooks || []
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []
this.tagResults = searchResults.tags || []
this.isFetching = false
if (!this.showMenu) {
@@ -135,19 +158,15 @@ export default {
}
this.isTyping = true
this.searchTimeout = setTimeout(() => {
// Canceled search
if (!this.isTyping) return
this.isTyping = false
this.runSearch(val)
}, 750)
},
clickClear() {
if (this.search) {
this.search = null
this.lastSearch = null
this.audiobookResults = []
this.authorResults = []
this.seriesResults = []
this.showMenu = false
}
this.clearResults()
}
},
mounted() {}
@@ -114,6 +114,9 @@ export default {
this.volume = this.lastValue || 0.5
}
},
toggleMute() {
this.clickVolumeIcon()
},
clickVolumeTrack(e) {
var vol = e.offsetX / this.trackWidth
vol = Math.min(Math.max(vol, 0), 1)
+46 -8
View File
@@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :width="800" :height="'unset'" :processing="processing">
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
@@ -64,6 +64,19 @@
<ui-toggle-switch v-model="newUser.permissions.upload" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Access All Libraries</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
</div>
</div>
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
</div>
</div>
<div class="flex pt-4">
@@ -116,14 +129,31 @@ export default {
},
isEditingRoot() {
return this.account && this.account.type === 'root'
},
libraries() {
return this.$store.state.libraries.libraries
},
libraryItems() {
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
}
},
methods: {
accessAllLibrariesToggled(val) {
if (!val && !this.newUser.librariesAccessible.length) {
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
} else if (val && this.newUser.librariesAccessible.length) {
this.newUser.librariesAccessible = []
}
},
submitForm() {
if (!this.newUser.username) {
this.$toast.error('Enter a username')
return
}
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {
this.$toast.error('Must select at least one library')
return
}
if (this.isNew) {
this.submitCreateAccount()
@@ -139,6 +169,7 @@ export default {
if (account.type === 'root' && !account.isActive) return
this.processing = true
console.log('Calling update', account)
this.$axios
.$patch(`/api/user/${this.account.id}`, account)
.then((data) => {
@@ -146,14 +177,16 @@ export default {
if (data.error) {
this.$toast.error(`Failed to update account: ${data.error}`)
} else {
console.log('Account updated', data.user)
this.$toast.success('Account updated')
this.show = false
}
})
.catch((error) => {
console.error('Failed to update account', error)
this.processing = false
this.$toast.error('Failed to update account')
console.error('Failed to update account', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || 'Failed to update account')
})
},
submitCreateAccount() {
@@ -176,9 +209,10 @@ export default {
}
})
.catch((error) => {
console.error('Failed to create account', error)
this.processing = false
this.$toast.error('Failed to create account')
console.error('Failed to create account', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || 'Failed to create account')
})
},
toggleActive() {
@@ -195,12 +229,14 @@ export default {
init() {
this.isNew = !this.account
if (this.account) {
var librariesAccessible = this.account.librariesAccessible || []
this.newUser = {
username: this.account.username,
password: this.account.password,
type: this.account.type,
isActive: this.account.isActive,
permissions: { ...this.account.permissions }
permissions: { ...this.account.permissions },
librariesAccessible: [...librariesAccessible]
}
} else {
this.newUser = {
@@ -212,8 +248,10 @@ export default {
download: true,
update: false,
delete: false,
upload: false
}
upload: false,
accessAllLibraries: true
},
librariesAccessible: []
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :width="500" :height="'unset'">
<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">
<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-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">
@@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :width="700" :height="'unset'" :processing="processing">
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
+74 -2
View File
@@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :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="75">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
@@ -10,6 +10,14 @@
<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" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div>
</div>
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
</div>
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<keep-alive>
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
@@ -51,6 +59,11 @@ export default {
title: 'Chapters',
component: 'modals-edit-tabs-chapters'
},
{
id: 'files',
title: 'Files',
component: 'modals-edit-tabs-files'
},
{
id: 'download',
title: 'Download',
@@ -68,6 +81,7 @@ export default {
this.show = false
return
}
if (!availableTabIds.includes(this.selectedTab)) {
this.selectedTab = availableTabIds[0]
}
@@ -79,6 +93,9 @@ export default {
this.fetchOnShow = false
this.audiobook = null
this.init()
this.registerListeners()
} else {
this.unregisterListeners()
}
}
}
@@ -137,9 +154,45 @@ export default {
},
title() {
return this.book.title || 'No Title'
},
bookshelfBookIds() {
return this.$store.state.bookshelfBookIds || []
},
currentBookshelfIndex() {
if (!this.bookshelfBookIds.length) return 0
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId)
},
canGoPrev() {
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
},
canGoNext() {
return this.bookshelfBookIds.length && this.currentBookshelfIndex < this.bookshelfBookIds.length - 1
}
},
methods: {
goPrevBook() {
if (this.currentBookshelfIndex - 1 < 0) return
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
var prevBook = this.$store.getters['audiobooks/getAudiobook'](prevBookId)
if (prevBook) {
this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab })
this.$nextTick(this.init)
} else {
console.error('Book not found', prevBookId)
}
},
goNextBook() {
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
var nextBook = this.$store.getters['audiobooks/getAudiobook'](nextBookId)
if (nextBook) {
this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab })
this.$nextTick(this.init)
} else {
console.error('Book not found', nextBookId)
}
},
selectTab(tab) {
this.selectedTab = tab
},
@@ -155,14 +208,33 @@ export default {
},
async fetchFull() {
try {
this.processing = true
this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`)
this.processing = false
} catch (error) {
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
this.processing = false
this.show = false
}
},
hotkey(action) {
if (action === 'ArrowRight') {
this.goNextBook()
} else if (action === 'ArrowLeft') {
this.goPrevBook()
}
},
registerListeners() {
this.$eventBus.$on('modal-hotkey', this.hotkey)
},
unregisterListeners() {
this.$eventBus.$off('modal-hotkey', this.hotkey)
}
},
mounted() {}
mounted() {},
beforeDestroy() {
this.unregisterListeners()
}
}
</script>
@@ -1,99 +0,0 @@
<template>
<modals-modal v-model="show" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div v-if="show" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 200px; max-height: 80vh">
<div v-if="!showAddLibrary" class="w-full h-full flex flex-col justify-center px-4">
<div class="flex items-center mb-4">
<p>{{ libraries.length }} Libraries</p>
<!-- <div class="flex-grow" />
<ui-btn @click="addLibraryClick">Add Library</ui-btn> -->
</div>
<template v-for="library in libraries">
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="false" @edit="editLibrary" @delete="deleteLibrary" @click="clickLibrary" />
</template>
</div>
<modals-libraries-edit-library v-else :library="selectedLibrary" :show="showAddLibrary" :processing.sync="processing" @back="showAddLibrary = false" @close="showAddLibrary = false" />
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
selectedLibrary: null,
processing: false,
showAddLibrary: false
}
},
computed: {
show: {
get() {
return this.$store.state.libraries.showModal
},
set(val) {
this.$store.commit('libraries/setShowModal', val)
}
},
title() {
return 'Libraries'
},
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
currentLibraryId() {
return this.currentLibrary ? this.currentLibrary.id : null
},
libraries() {
return this.$store.state.libraries.libraries
}
},
watch: {
show(newVal) {
if (newVal) this.showAddLibrary = false
}
},
methods: {
async clickLibrary(library) {
await this.$store.dispatch('libraries/fetch', library.id)
this.$router.push(`/library/${library.id}`)
this.show = false
},
editLibrary(library) {
this.selectedLibrary = library
this.showAddLibrary = true
},
addLibraryClick() {
this.selectedLibrary = null
this.showAddLibrary = true
},
deleteLibrary(library) {
if (confirm(`Are you sure you want to delete library "${library.name}"?\n(no files will be deleted but book data will be lost)`)) {
console.log('Delete library', library)
this.processing = true
this.$axios
.$delete(`/api/library/${library.id}`)
.then(() => {
console.log('Library delete success')
this.$toast.success(`Library "${library.name}" deleted`)
this.processing = false
})
.catch((error) => {
console.error('Failed to delete library', error)
var errMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errMsg)
this.processing = false
})
}
}
},
mounted() {},
beforeDestroy() {}
}
</script>
+16 -1
View File
@@ -2,7 +2,7 @@
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
<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="show = false">
<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>
<slot name="outer" />
@@ -18,6 +18,7 @@
<script>
export default {
props: {
name: String,
value: Boolean,
processing: Boolean,
persistent: {
@@ -73,23 +74,37 @@ export default {
}
},
methods: {
clickClose() {
this.show = false
},
clickBg(vm, ev) {
if (this.processing && this.persistent) return
if (vm.srcElement.classList.contains('modal-bg')) {
this.show = false
}
},
hotkey(action) {
if (action === 'Escape') {
this.show = false
}
},
setShow() {
document.body.appendChild(this.el)
setTimeout(() => {
this.content.style.transform = 'scale(1)'
}, 10)
document.documentElement.classList.add('modal-open')
this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$store.commit('setOpenModal', this.name)
},
setHide() {
this.content.style.transform = 'scale(0)'
this.el.remove()
document.documentElement.classList.remove('modal-open')
this.$eventBus.$off('modal-hotkey', this.hotkey)
this.$store.commit('setOpenModal', null)
}
},
mounted() {
+22 -8
View File
@@ -31,7 +31,7 @@
<div v-if="showLocalCovers" class="flex items-center justify-center">
<template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover.localPath)">
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" style="width: 60px">
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
</div>
@@ -56,7 +56,7 @@
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
<p v-if="!coversFound.length">No Covers Found</p>
<template v-for="cover in coversFound">
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
<cards-preview-cover :src="cover" :width="80" show-open-new-tab />
</div>
</template>
@@ -79,8 +79,6 @@
</template>
<script>
import Path from 'path'
export default {
props: {
processing: Boolean,
@@ -190,13 +188,13 @@ export default {
},
init() {
this.showLocalCovers = false
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.authorFL)) {
this.coversFound = []
this.hasSearched = false
}
this.imageUrl = this.book.cover || ''
this.searchTitle = this.book.title || ''
this.searchAuthor = this.book.author || ''
this.searchAuthor = this.book.authorFL || ''
},
removeCover() {
if (!this.book.cover) {
@@ -265,8 +263,24 @@ export default {
this.isProcessing = false
this.hasSearched = true
},
setCover(cover) {
this.updateCover(cover)
setCover(coverFile) {
this.isProcessing = true
this.$axios
.$patch(`/api/audiobook/${this.audiobook.id}/coverfile`, coverFile)
.then((data) => {
console.log('response data', data)
if (data && typeof data === 'string') {
this.$toast.success(data)
}
this.isProcessing = false
})
.catch((error) => {
console.error('Failed to update', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
this.isProcessing = false
})
}
}
}
@@ -60,8 +60,8 @@
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="ml-4">
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="ml-4">
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>
<div class="flex-grow" />
@@ -214,8 +214,6 @@ export default {
this.details.volumeNumber = this.book.volumeNumber
this.details.publishYear = this.book.publishYear
console.log('INIT', this.details)
this.newTags = this.audiobook.tags || []
},
resetProgress() {
@@ -0,0 +1,21 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<tables-all-files-table :audiobook="audiobook" />
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {},
methods: {}
}
</script>
@@ -53,7 +53,6 @@ export default {
data() {
return {
tracks: null,
audioFiles: null,
showFullPath: false
}
},
@@ -104,7 +103,6 @@ export default {
},
methods: {
init() {
this.audioFiles = this.audiobook.audioFiles
this.tracks = this.audiobook.tracks
}
}
+238
View File
@@ -0,0 +1,238 @@
<template>
<div class="w-full h-full">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
<p class="text-sm truncate">{{ file }}</p>
</div>
</div>
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-10 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong
>: {{ comicMetadata[key] }}
</p>
</div>
</div>
<div v-if="comicMetadata" class="absolute top-0 right-52 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
<span class="material-icons text-xl">more</span>
</div>
<div class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 156px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
<span class="material-icons text-xl">menu</span>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
</div>
<div class="overflow-hidden m-auto comicwrapper relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div>
</div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div>
</div>
<div class="h-full flex justify-center">
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
</div>
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
<ui-loading-indicator />
</div>
</div>
<!-- <div v-show="loading" class="w-screen h-screen absolute top-0 left-0 bg-black bg-opacity-20 flex items-center justify-center">
<ui-loading-indicator />
</div> -->
</div>
</template>
<script>
import Path from 'path'
import { Archive } from 'libarchive.js/main.js'
Archive.init({
workerUrl: '/libarchive/worker-bundle.js'
})
export default {
props: {
url: String
},
data() {
return {
loading: false,
pages: null,
filesObject: null,
mainImg: null,
page: 0,
numPages: 0,
showPageMenu: false,
showInfoMenu: false,
loadTimeout: null,
loadedFirstPage: false,
comicMetadata: null
}
},
watch: {
url: {
immediate: true,
handler() {
this.extract()
}
}
},
computed: {
comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
},
canGoNext() {
return this.page < this.numPages - 1
},
canGoPrev() {
return this.page > 0
}
},
methods: {
clickOutside() {
if (this.showPageMenu) this.showPageMenu = false
if (this.showInfoMenu) this.showInfoMenu = false
},
next() {
if (!this.canGoNext) return
this.setPage(this.page + 1)
},
prev() {
if (!this.canGoPrev) return
this.setPage(this.page - 1)
},
setPage(index) {
if (index < 0 || index > this.numPages - 1) {
return
}
var filename = this.pages[index]
this.page = index
return this.extractFile(filename)
},
setLoadTimeout() {
this.loadTimeout = setTimeout(() => {
this.loading = true
}, 150)
},
extractFile(filename) {
return new Promise(async (resolve) => {
this.setLoadTimeout()
var file = await this.filesObject[filename].extract()
var reader = new FileReader()
reader.onload = (e) => {
this.mainImg = e.target.result
this.loading = false
resolve()
}
reader.onerror = (e) => {
console.error(e)
this.$toast.error('Read page file failed')
this.loading = false
resolve()
}
reader.readAsDataURL(file)
clearTimeout(this.loadTimeout)
})
},
async extract() {
this.loading = true
console.log('Extracting', this.url)
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
})
const archive = await Archive.open(buff)
this.filesObject = await archive.getFilesObject()
var filenames = Object.keys(this.filesObject)
this.parseFilenames(filenames)
var xmlFile = filenames.find((f) => (Path.extname(f) || '').toLowerCase() === '.xml')
if (xmlFile) await this.extractXmlFile(xmlFile)
this.numPages = this.pages.length
if (this.pages.length) {
this.loading = false
await this.setPage(0)
this.loadedFirstPage = true
} else {
this.$toast.error('Unable to extract pages')
this.loading = false
}
},
async extractXmlFile(filename) {
console.log('extracting xml filename', filename)
try {
var file = await this.filesObject[filename].extract()
var reader = new FileReader()
reader.onload = (e) => {
this.comicMetadata = this.$xmlToJson(e.target.result)
console.log('Metadata', this.comicMetadata)
}
reader.onerror = (e) => {
console.error(e)
}
reader.readAsText(file)
} catch (error) {
console.error(error)
}
},
parseImageFilename(filename) {
var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d{1,4}/g)
if (!numbersinpath || !numbersinpath.length) {
return {
index: -1,
filename
}
} else {
return {
index: Number(numbersinpath[numbersinpath.length - 1]),
filename
}
}
},
parseFilenames(filenames) {
const acceptableImages = ['.jpeg', '.jpg', '.png']
var imageFiles = filenames.filter((f) => {
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
})
var imageFileObjs = imageFiles.map((img) => {
return this.parseImageFilename(img)
})
var imagesWithNum = imageFileObjs.filter((i) => i.index >= 0)
var orderedImages = imagesWithNum.sort((a, b) => a.index - b.index).map((i) => i.filename)
var noNumImages = imageFileObjs.filter((i) => i.index < 0)
orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename))
this.pages = orderedImages
}
},
mounted() {},
beforeDestroy() {}
}
</script>
<style scoped>
.pagemenu {
max-height: calc(100vh - 60px);
}
.comicimg {
height: calc(100vh - 40px);
margin: auto;
}
.comicwrapper {
width: 100vw;
height: calc(100vh - 40px);
margin-top: 20px;
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<template>
<div class="h-full w-full">
<div class="h-full flex items-center">
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center">
<span v-show="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
</div>
<div id="frame" class="w-full" style="height: 650px">
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
<div class="py-4 flex justify-center" style="height: 50px">
<p>{{ progress }}%</p>
</div>
</div>
<div style="width: 100px; max-width: 100px" class="h-full flex items-center justify-center overflow-x-hidden">
<span v-show="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
</div>
</div>
</div>
</template>
<script>
import ePub from 'epubjs'
export default {
props: {
url: String
},
data() {
return {
book: null,
rendition: null,
chapters: [],
title: '',
author: '',
progress: 0,
hasNext: true,
hasPrev: false
}
},
computed: {},
methods: {
changedChapter() {
if (this.rendition) {
this.rendition.display(this.selectedChapter)
}
},
prev() {
if (this.rendition) {
this.rendition.prev()
}
},
next() {
if (this.rendition) {
this.rendition.next()
}
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
}
},
initEpub() {
// var book = ePub(this.url, {
// requestHeaders: {
// Authorization: `Bearer ${this.userToken}`
// }
// })
var book = ePub(this.url)
this.book = book
this.rendition = book.renderTo('viewer', {
width: window.innerWidth - 200,
height: 600,
ignoreClass: 'annotator-hl',
manager: 'continuous',
spread: 'always'
})
var displayed = this.rendition.display()
book.ready
.then(() => {
console.log('Book ready')
return book.locations.generate(1600)
})
.then((locations) => {
// console.log('Loaded locations', locations)
// Wait for book to be rendered to get current page
displayed.then(() => {
// Get the current CFI
var currentLocation = this.rendition.currentLocation()
if (!currentLocation.start) {
console.error('No Start', currentLocation)
} else {
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
// console.log('current page', currentPage)
}
})
})
book.loaded.navigation.then((toc) => {
var _chapters = []
toc.forEach((chapter) => {
_chapters.push(chapter)
})
this.chapters = _chapters
})
book.loaded.metadata.then((metadata) => {
this.author = metadata.creator
this.title = metadata.title
})
this.rendition.on('keyup', this.keyUp)
this.rendition.on('relocated', (location) => {
var percent = book.locations.percentageFromCfi(location.start.cfi)
this.progress = Math.floor(percent * 100)
this.hasNext = !location.atEnd
this.hasPrev = !location.atStart
})
}
},
mounted() {
this.initEpub()
}
}
</script>
+118
View File
@@ -0,0 +1,118 @@
<template>
<div class="w-full h-full">
<div class="h-full max-h-full w-full">
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<iframe title="html-viewer" width="100%"> Loading </iframe>
</div>
</div>
</div>
</template>
<script>
import MobiParser from '@/assets/ebooks/mobi.js'
import HtmlParser from '@/assets/ebooks/htmlParser.js'
import defaultCss from '@/assets/ebooks/basic.js'
export default {
props: {
url: String
},
data() {
return {}
},
computed: {},
methods: {
addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0]
if (!iframe) return
let doc = iframe.contentDocument
if (!doc) return
let style = doc.createElement('style')
style.id = 'default-style'
style.textContent = defaultCss
doc.head.appendChild(style)
},
handleIFrameHeight(iFrame) {
const isElement = (obj) => !!(obj && obj.nodeType === 1)
var body = iFrame.contentWindow.document.body,
html = iFrame.contentWindow.document.documentElement
iFrame.height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight) * 2
setTimeout(() => {
let lastchild = body.lastElementChild
let lastEle = body.lastChild
let itemAs = body.querySelectorAll('a')
let itemPs = body.querySelectorAll('p')
let lastItemA = itemAs[itemAs.length - 1]
let lastItemP = itemPs[itemPs.length - 1]
let lastItem
if (isElement(lastItemA) && isElement(lastItemP)) {
if (lastItemA.clientHeight + lastItemA.offsetTop > lastItemP.clientHeight + lastItemP.offsetTop) {
lastItem = lastItemA
} else {
lastItem = lastItemP
}
}
if (!lastchild && !lastItem && !lastEle) return
if (lastEle.nodeType === 3 && !lastchild && !lastItem) return
let nodeHeight = 0
if (lastEle.nodeType === 3 && document.createRange) {
let range = document.createRange()
range.selectNodeContents(lastEle)
if (range.getBoundingClientRect) {
let rect = range.getBoundingClientRect()
if (rect) {
nodeHeight = rect.bottom - rect.top
}
}
}
var lastChildHeight = isElement(lastchild) ? lastchild.clientHeight + lastchild.offsetTop : 0
var lastEleHeight = isElement(lastEle) ? lastEle.clientHeight + lastEle.offsetTop : 0
var lastItemHeight = isElement(lastItem) ? lastItem.clientHeight + lastItem.offsetTop : 0
iFrame.height = Math.max(lastChildHeight, lastEleHeight, lastItemHeight) + 100 + nodeHeight
}, 500)
},
async initMobi() {
// Fetch mobi file as blob
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
})
var reader = new FileReader()
reader.onload = async (event) => {
var file_content = event.target.result
let mobiFile = new MobiParser(file_content)
let content = await mobiFile.render()
let htmlParser = new HtmlParser(new DOMParser().parseFromString(content.outerHTML, 'text/html'))
var anchoredDoc = htmlParser.getAnchoredDoc()
let iFrame = document.getElementsByTagName('iframe')[0]
iFrame.contentDocument.body.innerHTML = anchoredDoc.documentElement.outerHTML
// Add css
let style = iFrame.contentDocument.createElement('style')
style.id = 'default-style'
style.textContent = defaultCss
iFrame.contentDocument.head.appendChild(style)
this.handleIFrameHeight(iFrame)
}
reader.readAsArrayBuffer(buff)
}
},
mounted() {
this.initMobi()
}
}
</script>
<style>
.ebook-viewer {
height: calc(100% - 96px);
}
</style>
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="w-full h-full pt-20 relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2">
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div>
</div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center">
<p class="font-mono">{{ page }} / {{ numPages }}</p>
</div>
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
<div class="flex items-center justify-center">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
</div>
</div>
</div>
<!-- <div class="text-center py-2 text-lg">
<p>{{ page }} / {{ numPages }}</p>
</div> -->
</div>
</template>
<script>
import pdf from 'vue-pdf'
export default {
components: {
pdf
},
props: {
url: String
},
data() {
return {
rotate: 0,
loadedRatio: 0,
page: 1,
numPages: 0
}
},
computed: {
pdfWidth() {
return this.pdfHeight * 0.6667
},
pdfHeight() {
return window.innerHeight - 120
},
canGoNext() {
return this.page < this.numPages
},
canGoPrev() {
return this.page > 1
}
},
methods: {
numPagesLoaded(e) {
this.numPages = e
},
prev() {
if (this.page <= 1) return
this.page--
},
next() {
if (this.page >= this.numPages) return
this.page++
},
error(err) {
console.error(err)
}
},
mounted() {}
}
</script>
+159
View File
@@ -0,0 +1,159 @@
<template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-50 bg-primary text-white">
<div class="absolute top-4 right-4 z-20">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
</div>
<div class="absolute top-4 left-4 font-book">
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
<p v-if="abAuthor">by {{ abAuthor }}</p>
</div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" />
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
</div>
</template>
<script>
export default {
data() {
return {
ebookType: '',
ebookUrl: ''
}
},
watch: {
show(newVal) {
if (newVal) {
this.init()
}
}
},
computed: {
show: {
get() {
return this.$store.state.showEReader
},
set(val) {
this.$store.commit('setShowEReader', val)
}
},
componentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null
},
abTitle() {
return this.selectedAudiobook.book.title
},
abAuthor() {
return this.selectedAudiobook.book.author
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook
},
libraryId() {
return this.selectedAudiobook.libraryId
},
folderId() {
return this.selectedAudiobook.folderId
},
ebooks() {
return this.selectedAudiobook.ebooks || []
},
epubEbook() {
return this.ebooks.find((eb) => eb.ext === '.epub')
},
mobiEbook() {
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
},
pdfEbook() {
return this.ebooks.find((eb) => eb.ext === '.pdf')
},
comicEbook() {
return this.ebooks.find((eb) => eb.ext === '.cbz' || eb.ext === '.cbr')
},
userToken() {
return this.$store.getters['user/getToken']
},
selectedAudiobookFile() {
return this.$store.state.selectedAudiobookFile
}
},
methods: {
getEbookUrl(path) {
return `/ebook/${this.libraryId}/${this.folderId}/${path}`
},
hotkey(action) {
console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return
if (action === 'ArrowRight') {
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
} else if (action === 'ArrowLeft') {
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
} else if (action === 'Escape') {
this.close()
}
},
registerListeners() {
this.$eventBus.$on('reader-hotkey', this.hotkey)
},
unregisterListeners() {
this.$eventBus.$off('reader-hotkey', this.hotkey)
},
init() {
this.registerListeners()
if (this.selectedAudiobookFile) {
this.ebookUrl = this.getEbookUrl(this.selectedAudiobookFile.path)
if (this.selectedAudiobookFile.ext === '.pdf') {
this.ebookType = 'pdf'
} else if (this.selectedAudiobookFile.ext === '.mobi' || this.selectedAudiobookFile.ext === '.azw3') {
this.ebookType = 'mobi'
// this.initMobi()
} else if (this.selectedAudiobookFile.ext === '.epub') {
this.ebookType = 'epub'
// this.initEpub()
} else if (this.selectedAudiobookFile.ext === '.cbr' || this.selectedAudiobookFile.ext === '.cbz') {
this.ebookType = 'comic'
}
} else if (this.epubEbook) {
this.ebookType = 'epub'
this.ebookUrl = this.getEbookUrl(this.epubEbook.path)
// this.initEpub()
} else if (this.mobiEbook) {
this.ebookType = 'mobi'
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
// this.initMobi()
} else if (this.pdfEbook) {
this.ebookType = 'pdf'
this.ebookUrl = this.getEbookUrl(this.pdfEbook.path)
} else if (this.comicEbook) {
this.ebookType = 'comic'
this.ebookUrl = this.getEbookUrl(this.comicEbook.path)
}
},
close() {
this.unregisterListeners()
this.show = false
}
},
mounted() {
if (this.show) this.init()
},
beforeDestroy() {
this.unregisterListeners()
}
}
</script>
<style>
/* @import url(@/assets/calibre/basic.css); */
.ebook-viewer {
height: calc(100% - 96px);
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer">
<p class="pr-4">Files</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ allFiles.length }}</span>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
</div>
<div class="w-full">
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left px-4">Path</th>
<th class="text-left px-4 w-24">Filetype</th>
<th v-if="userCanDownload" class="text-center w-20">Download</th>
</tr>
<template v-for="file in allFiles">
<tr :key="file.path">
<td class="font-book pl-2">
{{ showFullPath ? file.fullPath : file.path }}
</td>
<td class="text-xs">
<p>{{ file.filetype }}</p>
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
showFullPath: false
}
},
computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userToken() {
return this.$store.getters['user/getToken']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
otherFiles() {
return this.audiobook.otherFiles || []
},
audioFiles() {
return this.audiobook.audioFiles || []
},
audioFilesCleaned() {
return this.audioFiles.map((af) => {
return {
path: af.path,
fullPath: af.fullPath,
relativePath: this.getRelativePath(af.path),
filetype: 'audio'
}
})
},
otherFilesCleaned() {
return this.otherFiles.map((af) => {
return {
path: af.path,
fullPath: af.fullPath,
relativePath: this.getRelativePath(af.path),
filetype: af.filetype
}
})
},
allFiles() {
return this.audioFilesCleaned.concat(this.otherFilesCleaned)
}
},
methods: {
getRelativePath(path) {
var filePath = path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return filePath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
},
mounted() {}
}
</script>
+7 -1
View File
@@ -102,11 +102,17 @@ export default {
this.libraryCopies = this.libraries.map((lib) => {
return { ...lib }
})
},
librariesUpdated() {
this.init()
}
},
mounted() {
this.$store.commit('libraries/addListener', { id: 'libraries-table', meth: this.librariesUpdated })
this.init()
},
beforeDestroy() {}
beforeDestroy() {
this.$store.commit('libraries/removeListener', 'libraries-table')
}
}
</script>
+12 -3
View File
@@ -20,7 +20,7 @@
<tr class="font-book">
<th class="text-left px-4">Path</th>
<th class="text-left px-4 w-24">Filetype</th>
<th v-if="userCanDownload" class="text-center w-20">Download</th>
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
</tr>
<template v-for="file in otherFilesCleaned">
<tr :key="file.path">
@@ -28,9 +28,12 @@
{{ showFullPath ? file.fullPath : file.path }}
</td>
<td class="text-xs">
<p>{{ file.filetype }}</p>
<div class="flex items-center">
<span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span>
<p>{{ file.filetype }}</p>
</div>
</td>
<td v-if="userCanDownload" class="text-center">
<td v-if="userCanDownload && !isMissing" class="text-center">
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
@@ -83,11 +86,17 @@ export default {
userToken() {
return this.$store.getters['user/getToken']
},
isMissing() {
return this.audiobook.isMissing
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
}
},
methods: {
readEbookClick(file) {
this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
},
clickBar() {
this.showFiles = !this.showFiles
}
+25 -30
View File
@@ -17,17 +17,11 @@
<th class="w-32">Created</th>
<th class="w-32"></th>
</tr>
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
<tr v-for="user in users" :key="user.id" class="cursor-pointer" :class="user.isActive ? '' : 'bg-error bg-opacity-20'" @click="$router.push(`/config/users/${user.id}`)">
<td>
<div class="flex items-center">
<span v-if="usersOnline[user.id]" class="w-3 h-3 text-sm mr-2 text-success animate-pulse"
><svg viewBox="0 0 24 24">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /></svg
></span>
<svg v-else class="w-3 h-3 mr-2 text-white text-opacity-20" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>
{{ user.username }} <span v-show="$isDev" class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
<widgets-online-indicator :value="!!usersOnline[user.id]" />
<span class="pl-2">{{ user.username }}</span> <span v-show="$isDev" class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
</div>
</td>
<td class="text-sm">{{ user.type }}</td>
@@ -49,10 +43,15 @@
{{ $formatDate(user.createdAt, 'MMM d, yyyy') }}
</ui-tooltip>
</td>
<td>
<td class="py-0">
<div class="w-full flex justify-center">
<span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click="editUser(user)">edit</span>
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
<!-- <span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click.stop="editUser(user)">edit</span> -->
<div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
<span class="material-icons text-base">edit</span>
</div>
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
<span class="material-icons text-base">delete</span>
</div>
</div>
</td>
</tr>
@@ -81,21 +80,8 @@ export default {
return this.$store.state.streamAudiobook
},
usersOnline() {
var _users = this.$store.state.users.users
var currUserStream = null
if (this.userStream) {
currUserStream = {
audiobook: this.userStream
}
}
var usermap = {
[this.currentUserId]: {
online: true,
stream: currUserStream
}
}
_users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
var usermap = {}
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
return usermap
}
},
@@ -104,7 +90,9 @@ export default {
var abs = Object.values(audiobooks)
if (abs.length) {
abs = abs.sort((a, b) => a.lastUpdate - b.lastUpdate)
return abs[0] && abs[0].audiobookTitle ? abs[0].audiobookTitle : null
// Book object is attached on request
if (abs[0].book) return abs[0].book.title
return abs[0].audiobookTitle ? abs[0].audiobookTitle : null
}
return null
},
@@ -141,7 +129,9 @@ export default {
this.$axios
.$get('/api/users')
.then((users) => {
this.users = users
this.users = users.sort((a, b) => {
return a.createdAt - b.createdAt
})
})
.catch((error) => {
console.error('Failed', error)
@@ -192,16 +182,21 @@ export default {
#accounts {
table-layout: fixed;
border-collapse: collapse;
border: 1px solid #474747;
width: 100%;
}
#accounts td,
#accounts th {
border: 1px solid #2e2e2e;
/* border: 1px solid #2e2e2e; */
padding: 8px 8px;
text-align: left;
}
#accounts td.py-0 {
padding: 0px 8px;
}
#accounts tr:nth-child(even) {
background-color: #3a3a3a;
}
-3
View File
@@ -116,9 +116,6 @@ export default {
this.textInput = null
this.currentSearch = null
this.input = item
// this.input = this.textInput ? this.textInput.trim() : null
console.log('Clicked option', item)
if (this.$refs.input) this.$refs.input.blur()
}
},
+13 -4
View File
@@ -1,6 +1,6 @@
<template>
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutside">
<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-10 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">
<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>
@@ -9,7 +9,7 @@
<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">
<template v-for="library in libraries">
<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">
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
@@ -43,8 +43,17 @@ export default {
libraries() {
return this.$store.getters['libraries/getSortedLibraries']()
},
libraryItems() {
return this.libraries.map((lib) => ({ value: lib.id, text: lib.name }))
canUserAccessAllLibraries() {
return this.$store.getters['user/getUserCanAccessAllLibraries']
},
userLibrariesAccessible() {
return this.$store.getters['user/getLibrariesAccessible']
},
librariesFiltered() {
if (this.canUserAccessAllLibraries) return this.libraries
return this.libraries.filter((lib) => {
return this.userLibrariesAccessible.includes(lib.id)
})
}
},
methods: {
+4
View File
@@ -127,6 +127,7 @@ export default {
return
}
this.isFocused = false
if (this.textInput) this.submitForm()
}, 50)
},
focus() {
@@ -145,6 +146,7 @@ export default {
var newSelected = null
if (this.selected.includes(itemValue)) {
newSelected = this.selected.filter((s) => s !== itemValue)
this.$emit('removedItem', itemValue)
} else {
newSelected = this.selected.concat([itemValue])
}
@@ -164,6 +166,7 @@ export default {
removeItem(item) {
var remaining = this.selected.filter((i) => i !== item)
this.$emit('input', remaining)
this.$emit('removedItem', item)
this.$nextTick(() => {
this.recalcMenuPos()
})
@@ -171,6 +174,7 @@ export default {
insertNewItem(item) {
this.selected.push(item)
this.$emit('input', this.selected)
this.$emit('newItem', item)
this.textInput = null
this.currentSearch = null
this.$nextTick(() => {
@@ -0,0 +1,120 @@
<template>
<div class="w-full" v-click-outside="closeMenu">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<div ref="wrapper" class="relative">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-pointer" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selectedItems" :key="item.value" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap 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 class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.value)">close</span>
</div>
{{ item.text }}
</div>
</div>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="selected.includes(item.value)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">checkmark</span>
</span>
</li>
</template>
<li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
<div class="flex items-center justify-center">
<span class="font-normal">No items</span>
</div>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: Array,
default: () => []
},
items: {
type: Array,
default: () => []
},
label: String
},
data() {
return {
showMenu: false,
menu: null
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectedItems() {
return (this.value || []).map((v) => {
return this.items.find((i) => i.value === v) || {}
})
}
},
methods: {
recalcMenuPos() {
if (!this.menu) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
unmountMountMenu() {
if (!this.$refs.menu) return
this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.remove()
document.body.appendChild(this.menu)
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
clickedOption(e, item) {
if (e) {
e.stopPropagation()
e.preventDefault()
}
var newSelected = null
if (this.selected.includes(item.value)) {
newSelected = this.selected.filter((s) => s !== item.value)
} else {
newSelected = this.selected.concat([item.value])
}
this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
closeMenu() {
this.showMenu = false
},
clickWrapper() {
this.showMenu = !this.showMenu
},
removeItem(itemValue) {
var remaining = this.selected.filter((i) => i !== itemValue)
this.$emit('input', remaining)
this.$nextTick(() => {
this.recalcMenuPos()
})
}
},
mounted() {}
}
</script>
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn">
<div class="w-5 h-5 text-white relative">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
@@ -0,0 +1,26 @@
<template>
<div class="w-3 h-3">
<div v-if="value" class="w-full h-full text-sm mr-2 text-success animate-pulse">
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>
</div>
<svg v-else class="w-full h-full mr-2 text-white text-opacity-20" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>
</div>
</template>
<script>
export default {
props: {
value: Boolean
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
-64
View File
@@ -1,64 +0,0 @@
<template>
<div v-show="isScanning" class="fixed bottom-4 left-0 right-0 mx-auto z-20 max-w-lg">
<div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning">
<p class="text-lg font-sans" v-html="text" />
</div>
<div v-show="!hasCanceled" class="absolute right-0 top-3 bottom-0 px-2">
<ui-btn color="red-600" small :padding-x="1" @click="cancelScan">Cancel</ui-btn>
</div>
</div>
</template>
<script>
export default {
data() {
return {
hasCanceled: false
}
},
watch: {
isScanning(newVal) {
if (newVal) {
this.hasCanceled = false
}
}
},
computed: {
text() {
var scanText = this.isScanningFiles ? 'Scanning...' : 'Scanning Covers...'
return `${scanText} <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
},
isScanning() {
return this.isScanningFiles || this.isScanningCovers
},
isScanningFiles() {
return this.$store.state.isScanning
},
isScanningCovers() {
return this.$store.state.isScanningCovers
},
scanProgressKey() {
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
},
scanProgress() {
return this.$store.state[this.scanProgressKey]
},
scanPercent() {
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
},
scanNum() {
return this.scanProgress ? this.scanProgress.done : 0
},
scanTotal() {
return this.scanProgress ? this.scanProgress.total : 0
}
},
methods: {
cancelScan() {
this.hasCanceled = true
this.$root.socket.emit('cancel_scan')
}
},
mounted() {}
}
</script>
+64 -3
View File
@@ -5,10 +5,9 @@
<Nuxt />
<app-stream-container ref="streamContainer" />
<modals-libraries-modal />
<modals-edit-modal />
<app-reader />
<!-- <widgets-scan-alert /> -->
<readers-reader />
</div>
</template>
@@ -76,6 +75,12 @@ export default {
if (payload.backups && payload.backups.length) {
this.$store.commit('setBackups', payload.backups)
}
if (payload.usersOnline) {
this.$store.commit('users/resetUsers')
payload.usersOnline.forEach((user) => {
this.$store.commit('users/updateUser', user)
})
}
},
streamOpen(stream) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
@@ -320,9 +325,62 @@ export default {
} else {
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
}
},
checkActiveElementIsInput() {
var activeElement = document.activeElement
var inputs = ['input', 'select', 'button', 'textarea']
return activeElement && inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1
},
keyUp(e) {
var keyCode = e.keyCode || e.which
if (!this.$keynames[keyCode]) {
// Unused hotkey
return
}
var keyName = this.$keynames[keyCode]
var name = keyName
if (e.shiftKey) name = 'Shift-' + keyName
if (process.env.NODE_ENV !== 'production') {
console.log('Hotkey command', name)
}
// Input is focused then ignore key press
if (this.checkActiveElementIsInput()) {
return
}
// Modal is open
if (this.$store.state.openModal) {
this.$eventBus.$emit('modal-hotkey', name)
return
}
// EReader is open
if (this.$store.state.showEReader) {
this.$eventBus.$emit('reader-hotkey', name)
return
}
// Batch selecting
if (this.$store.getters['getNumAudiobooksSelected']) {
// ESCAPE key cancels batch selection
if (name === 'Escape') {
this.$store.commit('setSelectedAudiobooks', [])
}
return
}
// Playing audiobook
if (this.$store.state.streamAudiobook) {
this.$eventBus.$emit('player-hotkey', name)
}
}
},
mounted() {
document.addEventListener('keyup', this.keyUp)
this.initializeSocket()
this.$store.dispatch('libraries/load')
@@ -343,6 +401,9 @@ export default {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
},
beforeDestroy() {
document.removeEventListener('keyup', this.keyUp)
}
}
</script>
+1 -1
View File
@@ -12,7 +12,7 @@ export default function (context) {
}
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id')) {
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && from.name !== 'config' && from.name !== 'config-log' && from.name !== 'upload' && from.name !== 'account') {
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && !from.name.startsWith('config') && from.name !== 'upload' && from.name !== 'account') {
var _history = [...store.state.routeHistory]
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
_history.push(from.fullPath)
+4 -5
View File
@@ -19,7 +19,7 @@ module.exports = {
// Global page headers: https://go.nuxtjs.dev/config-head
head: {
title: 'AudioBookshelf',
title: 'Audiobookshelf',
htmlAttrs: {
lang: 'en'
},
@@ -35,8 +35,8 @@ module.exports = {
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fira+Mono&family=Ubuntu+Mono&family=Gentium+Book+Basic&&family=Source+Sans+Pro:wght@300;400;600' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Source+Sans+Pro:wght@300;400;600' },
// { rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
]
},
@@ -98,8 +98,7 @@ module.exports = {
},
// Build Configuration: https://go.nuxtjs.dev/config-build
build: {
},
build: {},
watchers: {
webpack: {
aggregateTimeout: 300,
+85 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.4.6",
"version": "1.4.10",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -3415,6 +3415,11 @@
"@babel/helper-define-polyfill-provider": "^0.2.2"
}
},
"babel-plugin-syntax-dynamic-import": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz",
"integrity": "sha1-jWomIpyDdFqZgqRBBRVyyqF5sdo="
},
"backo2": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
@@ -7380,6 +7385,11 @@
"launch-editor": "^2.2.1"
}
},
"libarchive.js": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libarchive.js/-/libarchive.js-1.3.0.tgz",
"integrity": "sha512-EkQfRXt9DhWwj6BnEA2TNpOf4jTnzSTUPGgE+iFxcdNqjktY8GitbDeHnx8qZA0/IukNyyBUR3oQKRdYkO+HFg=="
},
"lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
@@ -8494,6 +8504,11 @@
"sha.js": "^2.4.8"
}
},
"pdfjs-dist": {
"version": "2.6.347",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.6.347.tgz",
"integrity": "sha512-QC+h7hG2su9v/nU1wEI3SnpPIrqJODL7GTDFvR74ANKGq1AFJW16PH8VWnhpiTi9YcLSFV9xLeWSgq+ckHLdVQ=="
},
"picomatch": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
@@ -11239,6 +11254,37 @@
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
},
"raw-loader": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
"integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
"requires": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
},
"dependencies": {
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"schema-utils": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
"integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
"requires": {
"@types/json-schema": "^7.0.8",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
}
}
}
},
"rc9": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-1.2.0.tgz",
@@ -13314,6 +13360,24 @@
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
},
"vue-pdf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
"requires": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "2.6.347",
"raw-loader": "^4.0.2",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
}
},
"vue-resize-sensor": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
"integrity": "sha512-W+y2EAI/BxS4Vlcca9scQv8ifeBFck56DRtSwWJ2H4Cw1GLNUYxiZxUHHkuzuI5JPW/cYtL1bPO5xPyEXx4LmQ=="
},
"vue-router": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.2.tgz",
@@ -14181,6 +14245,26 @@
"errno": "~0.1.7"
}
},
"worker-loader": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz",
"integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==",
"requires": {
"loader-utils": "^1.0.0",
"schema-utils": "^0.4.0"
},
"dependencies": {
"schema-utils": {
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz",
"integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==",
"requires": {
"ajv": "^6.1.0",
"ajv-keywords": "^3.1.0"
}
}
}
},
"wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.4.7",
"version": "1.4.16",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {
@@ -19,8 +19,10 @@
"date-fns": "^2.25.0",
"epubjs": "^0.3.88",
"hls.js": "^1.0.7",
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.7",
"nuxt-socket-io": "^1.1.18",
"vue-pdf": "^4.3.0",
"vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3"
},
+5 -1
View File
@@ -61,7 +61,11 @@ export default {
},
methods: {
logout() {
this.$axios.$post('/logout').catch((error) => {
var rootSocket = this.$root.socket || {}
const logoutPayload = {
socketId: rootSocket.id
}
this.$axios.$post('/logout', logoutPayload).catch((error) => {
console.error(error)
})
if (localStorage.getItem('token')) {
+97 -7
View File
@@ -10,10 +10,24 @@
</div>
<div class="w-full flex items-center text-sm py-4 bg-primary border-l border-r border-t border-gray-600">
<div class="font-book text-center px-4 w-12">New</div>
<div class="font-book text-center px-4 w-12">Old</div>
<div class="font-book text-center px-4 w-32">Track Parsed from Filename</div>
<div class="font-book text-center px-4 w-32">Track From Metadata</div>
<div class="font-book truncate px-4 flex-grow">Filename</div>
<div class="font-book text-center px-4 w-24 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByCurrent" @mousedown.prevent>
<span class="text-white">Current</span>
<span class="material-icons ml-1" :class="currentSort === 'current' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'current' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="font-book text-center px-4 w-32 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByFilenameTrack" @mousedown.prevent>
<span class="text-white">Track From Filename</span>
<span class="material-icons ml-1" :class="currentSort === 'track-filename' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'track-filename' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="font-book text-center px-4 w-32 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByMetadataTrack" @mousedown.prevent>
<span class="text-white">Track From Metadata</span>
<span class="material-icons ml-1" :class="currentSort === 'metadata' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'metadata' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="font-mono w-20 text-center">CD From Filename</div>
<div class="font-book text-center px-4 flex-grow flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByFilename" @mousedown.prevent>
<span class="text-white">Filename</span>
<span class="material-icons ml-1" :class="currentSort === 'filename' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'filename' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<!-- <div class="font-book truncate px-4 flex-grow">Filename</div> -->
<div class="font-mono w-20 text-center">Size</div>
<div class="font-mono w-20 text-center">Duration</div>
@@ -21,19 +35,22 @@
<div class="font-mono w-56">Notes</div>
<div class="font-book w-40">Include in Tracklist</div>
</div>
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false">
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
<li v-for="(audio, index) in files" :key="audio.path" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center">
<div class="font-book text-center px-4 py-1 w-12">
{{ audio.include ? index - numExcluded + 1 : -1 }}
</div>
<div class="font-book text-center px-4 w-12">{{ audio.index }}</div>
<div class="font-book text-center px-4 w-24">{{ audio.index }}</div>
<div class="font-book text-center px-2 w-32">
{{ audio.trackNumFromFilename }}
</div>
<div class="font-book text-center w-32">
{{ audio.trackNumFromMeta }}
</div>
<div class="font-book truncate px-4 w-20">
{{ audio.cdNumFromFilename }}
</div>
<div class="font-book truncate px-4 flex-grow">
{{ audio.filename }}
</div>
@@ -56,6 +73,30 @@
</li>
</transition-group>
</draggable>
<div v-if="showExperimentalFeatures" class="p-4">
<ui-btn :loading="checkingTrackNumbers" small @click="checkTrackNumbers">Check Track Numbers</ui-btn>
<div v-if="trackNumData && trackNumData.length" class="w-full max-w-4xl py-2">
<table class="tracksTable">
<tr>
<th class="text-left">Filename</th>
<th class="w-32">Index</th>
<th class="w-32"># From Metadata</th>
<th class="w-32"># From Filename</th>
<th class="w-32"># From Probe</th>
<th class="w-32">Raw Tags</th>
</tr>
<tr v-for="trackData in trackNumData" :key="trackData.filename">
<td class="text-xs">{{ trackData.filename }}</td>
<td class="text-center">{{ trackData.currentTrackNum }}</td>
<td class="text-center">{{ trackData.trackNumFromMeta }}</td>
<td class="text-center">{{ trackData.trackNumFromFilename }}</td>
<td class="text-center">{{ trackData.scanDataTrackNum }}</td>
<td class="text-left text-xs">{{ JSON.stringify(trackData.rawTags || '') }}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</template>
@@ -95,7 +136,10 @@ export default {
group: 'description',
ghostClass: 'ghost'
},
saving: false
saving: false,
checkingTrackNumbers: false,
trackNumData: [],
currentSort: 'current'
}
},
computed: {
@@ -172,9 +216,55 @@ export default {
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
draggableUpdate(e) {
this.currentSort = ''
},
sortByCurrent() {
this.files.sort((a, b) => {
if (a.index === null) return 1
return a.index - b.index
})
this.currentSort = 'current'
},
sortByMetadataTrack() {
this.files.sort((a, b) => {
if (a.trackNumFromMeta === null) return 1
return a.trackNumFromMeta - b.trackNumFromMeta
})
this.currentSort = 'metadata'
},
sortByFilenameTrack() {
this.files.sort((a, b) => {
if (a.trackNumFromFilename === null) return 1
return a.trackNumFromFilename - b.trackNumFromFilename
})
this.currentSort = 'track-filename'
},
sortByFilename() {
this.files.sort((a, b) => {
return (a.filename || '').toLowerCase().localeCompare((b.filename || '').toLowerCase())
})
this.currentSort = 'filename'
},
checkTrackNumbers() {
this.checkingTrackNumbers = true
this.$axios
.$get(`/api/scantracks/${this.audiobookId}`)
.then((res) => {
this.trackNumData = res
this.checkingTrackNumbers = false
})
.catch((error) => {
console.error('Failed', error)
this.checkingTrackNumbers = false
})
},
includeToggled(audio) {
var new_index = 0
if (audio.include) {
+5 -21
View File
@@ -19,16 +19,11 @@
</div>
<p class="mb-2 mt-0.5 text-gray-100 text-xl">
by <nuxt-link v-if="author" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}</nuxt-link
by <nuxt-link v-if="authorFL" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(authorFL)}`" class="hover:underline">{{ authorFL }}</nuxt-link
><span v-else>Unknown</span>
</p>
<nuxt-link v-if="series" :to="`/library/${libraryId}/bookshelf/series?series=${$encode(series)}`" class="hover:underline font-sans text-gray-300 text-lg leading-7 mb-4"> {{ seriesText }}</nuxt-link>
<!-- <div class="w-min">
<ui-tooltip :text="authorTooltipText" direction="bottom">
<span class="text-base text-gray-100 leading-8 whitespace-nowrap"><span class="text-white text-opacity-60">By:</span> {{ author }}</span>
</ui-tooltip>
</div> -->
<div v-if="narrator" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Narrated By</span>
@@ -81,10 +76,6 @@
<span class="material-icons text-2xl">warning_amber</span>
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
</div>
<div v-show="showEpubAlert" class="bg-error p-4 rounded-xl flex items-center mt-2">
<span class="material-icons text-2xl">warning_amber</span>
<p class="ml-4">Book has valid ebook files, but the experimental e-reader currently only supports epub files.</p>
</div>
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
@@ -104,7 +95,7 @@
{{ isMissing ? 'Missing' : 'Incomplete' }}
</ui-btn>
<ui-btn v-if="showExperimentalFeatures && (epubEbook || mobiEbook)" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<ui-btn v-if="showExperimentalFeatures && numEbooks" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
Read
</ui-btn>
@@ -152,8 +143,6 @@
</div>
</div>
</div>
<!-- <app-reader v-if="showExperimentalFeatures" v-model="showReader" :url="epubUrl" /> -->
</div>
</template>
@@ -321,17 +310,11 @@ export default {
ebooks() {
return this.audiobook.ebooks
},
showEpubAlert() {
return this.ebooks.length && !this.epubEbook && !this.tracks.length
},
showExperimentalReadAlert() {
return !this.tracks.length && this.ebooks.length && !this.showExperimentalFeatures
},
epubEbook() {
return this.ebooks.find((eb) => eb.ext === '.epub')
},
mobiEbook() {
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
numEbooks() {
return this.audiobook.numEbooks
},
userToken() {
return this.$store.getters['user/getToken']
@@ -411,6 +394,7 @@ export default {
this.$root.socket.emit('open_stream', this.audiobook.id)
},
editClick() {
this.$store.commit('setBookshelfBookIds', [])
this.$store.commit('showEditModal', this.audiobook)
},
lookupMetadata(index) {
+46 -5
View File
@@ -33,10 +33,10 @@
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genres" />
<ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" />
</div>
<div class="flex-grow px-1">
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tags" />
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" />
</div>
</div>
@@ -76,7 +76,9 @@ export default {
isProcessing: false,
audiobookCopies: [],
isScrollable: false,
newSeriesItems: []
newSeriesItems: [],
newTagItems: [],
newGenreItems: []
}
},
computed: {
@@ -86,9 +88,15 @@ export default {
genres() {
return this.$store.state.audiobooks.genres
},
genreItems() {
return this.genres.concat(this.newGenreItems)
},
tags() {
return this.$store.state.audiobooks.tags
},
tagItems() {
return this.tags.concat(this.newTagItems)
},
series() {
return this.$store.state.audiobooks.series
},
@@ -100,9 +108,42 @@ export default {
}
},
methods: {
newTagItem(item) {
if (item && !this.newTagItems.includes(item)) {
this.newTagItems.push(item)
}
},
removedTagItem(item) {
// If newly added, remove if not used on any other audiobooks
if (item && this.newTagItems.includes(item)) {
var usedByOtherAb = this.audiobookCopies.find((ab) => {
return ab.tags && ab.tags.includes(item)
})
if (!usedByOtherAb) {
this.newTagItems = this.newTagItems.filter((t) => t !== item)
}
}
},
newGenreItem(item) {
if (item && !this.newGenreItems.includes(item)) {
this.newGenreItems.push(item)
}
},
removedGenreItem(item) {
// If newly added, remove if not used on any other audiobooks
if (item && this.newGenreItems.includes(item)) {
var usedByOtherAb = this.audiobookCopies.find((ab) => {
return ab.book.genres && ab.book.genres.includes(item)
})
if (!usedByOtherAb) {
this.newGenreItems = this.newGenreItems.filter((t) => t !== item)
}
}
},
newSeriesItem(item) {
if (!item) return
this.newSeriesItems.push(item)
if (item && !this.newSeriesItems.includes(item)) {
this.newSeriesItems.push(item)
}
},
seriesChanged() {
this.newSeriesItems = this.newSeriesItems.filter((item) => {
+45
View File
@@ -0,0 +1,45 @@
<template>
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamAudiobook ? 'streaming' : ''">
<app-config-side-nav />
<div class="w-full max-w-4xl mx-auto">
<nuxt-child />
</div>
<div class="fixed bottom-0 right-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsRoot']) {
redirect('/?error=unauthorized')
}
},
data() {
return {}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
}
},
methods: {
setDeveloperMode() {
var value = !this.$store.state.developerMode
this.$store.commit('setDeveloperMode', value)
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
}
// saveMetadataComplete(result) {
// this.savingMetadata = false
// if (!result) return
// this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
// },
// saveMetadataFiles() {
// this.savingMetadata = true
// this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
// this.$root.socket.emit('save_metadata')
// }
},
mounted() {}
}
</script>
+70
View File
@@ -0,0 +1,70 @@
<template>
<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">Backups</h1>
</div>
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/books</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="dailyBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
<ui-tooltip :text="dailyBackupsTooltip">
<p class="pl-4 text-lg">Run daily backups <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
<p class="pl-4 text-lg">Number of backups to keep</p>
</div>
<tables-backups-table />
</div>
</div>
</template>
<script>
export default {
data() {
return {
updatingServerSettings: false,
dailyBackups: true,
backupsToKeep: 2
}
},
computed: {
dailyBackupsTooltip() {
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
}
},
methods: {
updateBackupsSettings() {
if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {
this.$toast.error('Invalid number of backups to keep')
return
}
var updatePayload = {
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
backupsToKeep: Number(this.backupsToKeep)
}
this.updateServerSettings(updatePayload)
},
updateServerSettings(payload) {
this.updatingServerSettings = true
this.$store
.dispatch('updateServerSettings', payload)
.then((success) => {
console.log('Updated Server Settings', success)
this.updatingServerSettings = false
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.updatingServerSettings = false
})
}
},
mounted() {}
}
</script>
+72 -166
View File
@@ -1,125 +1,77 @@
<template>
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full max-w-4xl mx-auto">
<tables-users-table />
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
<div>
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
<tables-libraries-table />
<!-- <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">
<h1 class="text-xl">Settings</h1>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" small :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
<ui-tooltip :text="parseSubtitleTooltip">
<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">
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="updateScannerFindCovers" />
<ui-tooltip :text="scannerFindCoversTooltip">
<p class="pl-4 text-lg">Scanner find 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="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
<ui-tooltip :text="coverDestinationTooltip">
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
<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">Settings</h1>
</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">
<h1 class="text-xl">Backups</h1>
</div>
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/books</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="dailyBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
<ui-tooltip :text="dailyBackupsTooltip">
<p class="pl-4 text-lg">Run daily backups <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
<p class="pl-4 text-lg">Number of backups to keep</p>
</div>
<tables-backups-table />
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" small :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
<ui-tooltip :text="parseSubtitleTooltip">
<p class="pl-4 text-lg">Scanner parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4">
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
<div class="flex-grow" />
<ui-btn to="/config/log">View Logger</ui-btn>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="updateScannerFindCovers" />
<ui-tooltip :text="scannerFindCoversTooltip">
<p class="pl-4 text-lg">Scanner find covers <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4">
<p class="font-mono">v{{ $config.version }}</p>
<div class="flex-grow" />
<p class="pr-2 text-sm font-book text-yellow-400">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>.</p>
<a href="https://github.com/advplyr/audiobookshelf" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
</a>
</div>
<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="experimentalFeaturesTooltip">
<p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
</div>
<!-- <div class="flex-grow" /> -->
<div>
<a href="https://github.com/advplyr/audiobookshelf/discussions/75#discussion-3604812" target="_blank" class="text-blue-500 hover:text-blue-300 underline">Join the discussion</a>
</div>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
<ui-tooltip :text="coverDestinationTooltip">
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
</div>
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
<div class="flex items-center py-4">
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
<div class="flex-grow" />
<p class="pr-2 text-sm font-book text-yellow-400">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>.</p>
<a href="https://github.com/advplyr/audiobookshelf" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
</a>
</div>
<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="experimentalFeaturesTooltip">
<p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
</div>
<div>
<a href="https://github.com/advplyr/audiobookshelf/discussions/75#discussion-3604812" target="_blank" class="text-blue-500 hover:text-blue-300 underline">Join the discussion</a>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsRoot']) {
redirect('/?error=unauthorized')
}
},
data() {
return {
storeCoversInAudiobookDir: false,
isResettingAudiobooks: false,
newServerSettings: {},
storeCoversInAudiobookDir: false,
updatingServerSettings: false,
dailyBackups: true,
backupsToKeep: 2
newServerSettings: {}
}
},
watch: {
@@ -131,6 +83,15 @@ export default {
}
},
computed: {
saveMetadataTooltip() {
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
},
experimentalFeaturesTooltip() {
return 'Features in development that could use your feedback and help testing.'
},
serverSettings() {
return this.$store.state.serverSettings
},
parseSubtitleTooltip() {
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
},
@@ -140,30 +101,6 @@ export default {
scannerFindCoversTooltip() {
return 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time'
},
saveMetadataTooltip() {
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
},
experimentalFeaturesTooltip() {
return 'Features in development that could use your feedback and help testing.'
},
dailyBackupsTooltip() {
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
},
backupsToKeepTooltip() {
return ''
},
serverSettings() {
return this.$store.state.serverSettings
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
isScanning() {
return this.$store.state.isScanning
},
isScanningCovers() {
return this.$store.state.isScanningCovers
},
showExperimentalFeatures: {
get() {
return this.$store.state.showExperimentalFeatures
@@ -174,17 +111,6 @@ export default {
}
},
methods: {
updateBackupsSettings() {
if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {
this.$toast.error('Invalid number of backups to keep')
return
}
var updatePayload = {
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
backupsToKeep: Number(this.backupsToKeep)
}
this.updateServerSettings(updatePayload)
},
updateScannerFindCovers(val) {
this.updateServerSettings({
scannerFindCovers: !!val
@@ -214,23 +140,13 @@ export default {
this.updatingServerSettings = false
})
},
setDeveloperMode() {
var value = !this.$store.state.developerMode
this.$store.commit('setDeveloperMode', value)
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
},
scan() {
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
},
saveMetadataComplete(result) {
this.savingMetadata = false
if (!result) return
this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
},
saveMetadataFiles() {
this.savingMetadata = true
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
this.$root.socket.emit('save_metadata')
initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
this.dailyBackups = !!this.newServerSettings.backupSchedule
},
resetAudiobooks() {
if (confirm('WARNING! This action will remove all audiobooks from the database including any updates or matches you have made. This does not do anything to your actual files. Shall we continue?')) {
@@ -248,20 +164,10 @@ export default {
this.$toast.error('Failed to reset audiobooks - manually remove the /config/audiobooks folder')
})
}
},
init() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.initServerSettings()
},
initServerSettings() {
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
this.dailyBackups = !!this.newServerSettings.backupSchedule
}
},
mounted() {
this.init()
this.initServerSettings()
}
}
</script>
</script>
+16
View File
@@ -0,0 +1,16 @@
<template>
<div>
<tables-libraries-table />
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
+110
View File
@@ -0,0 +1,110 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<nuxt-link to="/config/users" class="text-white text-opacity-70 hover:text-opacity-100 hover:bg-white hover:bg-opacity-5 cursor-pointer rounded-full">
<div class="flex items-center">
<div class="h-10 w-10 flex items-center justify-center">
<span class="material-icons text-2xl">arrow_back</span>
</div>
<p class="pl-1">All Users</p>
</div>
</nuxt-link>
<div class="flex items-center mb-2 mt-4">
<widgets-online-indicator :value="!!userOnline" />
<h1 class="text-xl pl-2">{{ username }}</h1>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90">Reading Progress</h1>
<table v-if="userAudiobooks.length" class="userAudiobooksTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-16 text-left">Book</th>
<th class="text-left"></th>
<th class="w-32">Progress</th>
<th class="w-40">Started At</th>
<th class="w-40">Last Update</th>
</tr>
<tr v-for="ab in userAudiobooks" :key="ab.audiobookId" :class="!ab.isRead ? '' : 'isRead'">
<td>
<cards-book-cover :width="50" :audiobook="ab" />
</td>
<td class="font-book">
<p>{{ ab.book ? ab.book.title : ab.audiobookTitle || 'Unknown' }}</p>
<p v-if="ab.book && ab.book.author" class="text-white text-opacity-50 text-sm font-sans">by {{ ab.book.author }}</p>
</td>
<td class="text-center">{{ Math.floor(ab.progress * 100) }}%</td>
<td class="text-center">
<ui-tooltip v-if="ab.startedAt" direction="top" :text="$formatDate(ab.startedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-sm">{{ $dateDistanceFromNow(ab.startedAt) }}</p>
</ui-tooltip>
</td>
<td class="text-center">
<ui-tooltip v-if="ab.lastUpdate" direction="top" :text="$formatDate(ab.lastUpdate, 'MMMM do, yyyy HH:mm')">
<p class="text-sm">{{ $dateDistanceFromNow(ab.lastUpdate) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<p v-else class="text-white text-opacity-50">Nothing read yet...</p>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ params, redirect, app }) {
var user = await app.$axios.$get(`/api/user/${params.id}`).catch((error) => {
console.error('Failed to get user', error)
return null
})
if (!user) return redirect('/config/users')
return {
user
}
},
data() {
return {}
},
computed: {
username() {
return this.user.username
},
userOnline() {
return this.$store.getters['users/getIsUserOnline'](this.user.id)
},
userAudiobooks() {
return Object.values(this.user.audiobooks || {}).sort((a, b) => b.lastUpdate - a.lastUpdate)
}
},
methods: {},
mounted() {}
}
</script>
<style>
.userAudiobooksTable {
border-collapse: collapse;
width: 100%;
border: 1px solid #474747;
}
.userAudiobooksTable tr:nth-child(even) {
background-color: #2e2e2e;
}
.userAudiobooksTable tr:not(:first-child) {
background-color: #373838;
}
.userAudiobooksTable tr:hover:not(:first-child) {
background-color: #474747;
}
.userAudiobooksTable tr.isRead {
background-color: rgba(76, 175, 80, 0.1);
}
.userAudiobooksTable td {
padding: 4px 8px;
}
.userAudiobooksTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>
+16
View File
@@ -0,0 +1,16 @@
<template>
<div>
<tables-users-table />
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
+1 -3
View File
@@ -21,9 +21,7 @@
<script>
export default {
asyncData({ redirect, store }) {
var currentLibraryId = store.state.libraries.currentLibraryId
console.log('Redir', currentLibraryId)
redirect(`/library/${currentLibraryId}`)
redirect(`/library/${store.state.libraries.currentLibraryId}`)
},
data() {
return {}
@@ -19,26 +19,37 @@ export default {
return redirect('/oops?message=Library not found')
}
// Set filter by
if (query.filter) {
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
}
var searchResults = []
// Search page
var searchResults = {}
var audiobookSearchResults = []
var searchQuery = null
if (params.id === 'search' && query.query) {
searchQuery = query.query
searchResults = await app.$axios.$get(`/api/library/${libraryId}/audiobooks?q=${query.query}`).catch((error) => {
searchResults = await app.$axios.$get(`/api/library/${libraryId}/search?q=${searchQuery}`).catch((error) => {
console.error('Search error', error)
return []
return {}
})
audiobookSearchResults = searchResults.audiobooks || []
store.commit('audiobooks/setSearchResults', searchResults)
if (audiobookSearchResults.length) audiobookSearchResults.forEach((ab) => store.commit('audiobooks/addUpdate', ab.audiobook))
}
// Series page
var selectedSeries = query.series ? app.$decode(query.series) : null
store.commit('audiobooks/setSelectedSeries', selectedSeries)
var libraryPage = params.id || ''
store.commit('audiobooks/setLibraryPage', libraryPage)
return {
id: libraryPage,
libraryId,
searchQuery,
searchResults,
selectedSeries
@@ -64,9 +75,9 @@ export default {
methods: {
async newQuery() {
var query = this.$route.query.query
this.searchResults = await this.$axios.$get(`/api/audiobooks?q=${query}`).catch((error) => {
this.searchResults = await this.$axios.$get(`/api/library/${this.libraryId}/search?q=${query}`).catch((error) => {
console.error('Search error', error)
return []
return {}
})
this.searchQuery = query
}
+20 -2
View File
@@ -48,6 +48,24 @@ export default {
}
},
methods: {
setUser(user) {
// If user is not able to access main library, then set current library
// var userLibrariesAccessible = this.$store.getters['user/getLibrariesAccessible']
var userCanAccessAll = user.permissions ? !!user.permissions.accessAllLibraries : false
if (!userCanAccessAll) {
var accessibleLibraries = user.librariesAccessible || []
console.log('Setting user without all library access', accessibleLibraries)
if (accessibleLibraries.length && !accessibleLibraries.includes('main')) {
console.log('Setting current library', accessibleLibraries[0])
this.$store.commit('libraries/setCurrentLibrary', accessibleLibraries[0])
}
}
// if (userLibrariesAccessible.length && !userLibrariesAccessible.includes('main')) {
// this.$store.commit('libraries/setCurrentLibrary', userLibrariesAccessible[0])
// }
this.$store.commit('user/setUser', user)
},
async submitForm() {
this.error = null
this.processing = true
@@ -65,7 +83,7 @@ export default {
if (authRes && authRes.error) {
this.error = authRes.error
} else if (authRes) {
this.$store.commit('user/setUser', authRes.user)
this.setUser(authRes.user)
}
this.processing = false
},
@@ -83,7 +101,7 @@ export default {
}
})
.then((res) => {
this.$store.commit('user/setUser', res.user)
this.setUser(res.user)
this.processing = false
})
.catch((error) => {
+4
View File
@@ -1,5 +1,9 @@
export default function ({ $axios, store }) {
$axios.onRequest(config => {
if (!config.url) {
console.error('Axios request invalid config', config)
return
}
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
return
}
+12
View File
@@ -15,6 +15,18 @@ const Constants = {
CoverDestination
}
const KeyNames = {
27: 'Escape',
32: 'Space',
37: 'ArrowLeft',
38: 'ArrowUp',
39: 'ArrowRight',
40: 'ArrowDown',
76: 'KeyL',
77: 'KeyM'
}
export default ({ app }, inject) => {
inject('constants', Constants)
inject('keynames', KeyNames)
}
+15
View File
@@ -1,6 +1,8 @@
import Vue from 'vue'
import { formatDistance, format } from 'date-fns'
Vue.prototype.$eventBus = new Vue()
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
Vue.prototype.$dateDistanceFromNow = (unixms) => {
@@ -127,6 +129,19 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
return sanitized
}
function xmlToJson(xml) {
const json = {};
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
const key = res[1] || res[3];
const value = res[2] && xmlToJson(res[2]);
json[key] = ((value && Object.keys(value).length) ? value : res[2]) || null;
}
return json;
}
Vue.prototype.$xmlToJson = xmlToJson
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
Vue.prototype.$encode = encode
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -14,7 +14,8 @@ export const state = () => ({
keywordFilter: null,
selectedSeries: null,
libraryPage: null,
searchResults: []
searchResults: {},
searchResultAudiobooks: []
})
export const getters = {
@@ -25,7 +26,7 @@ export const getters = {
if (!state.libraryPage) {
return getters.getFiltered()
} else if (state.libraryPage === 'search') {
return state.searchResults
return state.searchResultAudiobooks
} else if (state.libraryPage === 'series') {
var series = getters.getSeriesGroups()
if (state.selectedSeries) {
@@ -53,7 +54,7 @@ export const getters = {
if (filter === 'No Series') filtered = filtered.filter(ab => ab.book && !ab.book.series)
else filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
}
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.authorFL === filter)
else if (group === 'narrators') filtered = filtered.filter(ab => ab.book && ab.book.narrator === filter)
else if (group === 'progress') {
filtered = filtered.filter(ab => {
@@ -121,7 +122,7 @@ export const getters = {
return seriesArray
},
getUniqueAuthors: (state) => {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.authorFL)).map(ab => ab.book.authorFL)
return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
},
getUniqueNarrators: (state) => {
@@ -222,6 +223,7 @@ export const mutations = {
},
setSearchResults(state, val) {
state.searchResults = val
state.searchResultAudiobooks = val && val.audiobooks ? val.audiobooks.map(ab => ab.audiobook) : []
},
set(state, audiobooks) {
// GENRES
+22 -2
View File
@@ -9,6 +9,7 @@ export const state = () => ({
showEditModal: false,
showEReader: false,
selectedAudiobook: null,
selectedAudiobookFile: null,
playOnLoad: false,
developerMode: false,
selectedAudiobooks: [],
@@ -16,14 +17,19 @@ export const state = () => ({
previousPath: '/',
routeHistory: [],
showExperimentalFeatures: false,
backups: []
backups: [],
bookshelfBookIds: [],
openModal: null
})
export const getters = {
getIsAudiobookSelected: state => audiobookId => {
return !!state.selectedAudiobooks.includes(audiobookId)
},
getNumAudiobooksSelected: state => state.selectedAudiobooks.length
getNumAudiobooksSelected: state => state.selectedAudiobooks.length,
getAudiobookIdStreaming: state => {
return state.streamAudiobook ? state.streamAudiobook.id : null
}
}
export const actions = {
@@ -66,6 +72,9 @@ export const actions = {
}
export const mutations = {
setBookshelfBookIds(state, val) {
state.bookshelfBookIds = val || []
},
setRouteHistory(state, val) {
state.routeHistory = val
},
@@ -113,7 +122,15 @@ export const mutations = {
state.showEditModal = val
},
showEReader(state, audiobook) {
state.selectedAudiobookFile = null
state.selectedAudiobook = audiobook
state.showEReader = true
},
showEReaderForFile(state, { audiobook, file }) {
state.selectedAudiobookFile = file
state.selectedAudiobook = audiobook
state.showEReader = true
},
setShowEReader(state, val) {
@@ -142,5 +159,8 @@ export const mutations = {
},
setBackups(state, val) {
state.backups = val.sort((a, b) => b.createdAt - a.createdAt)
},
setOpenModal(state, val) {
state.openModal = val
}
}
+7 -5
View File
@@ -3,7 +3,6 @@ export const state = () => ({
lastLoad: 0,
listeners: [],
currentLibraryId: 'main',
showModal: false,
folders: [],
folderLastUpdate: 0
})
@@ -42,12 +41,18 @@ export const actions = {
return []
})
},
fetch({ state, commit, rootState }, libraryId) {
fetch({ state, commit, rootState, rootGetters }, libraryId) {
if (!rootState.user || !rootState.user.user) {
console.error('libraries/fetch - User not set')
return false
}
var canUserAccessLibrary = rootGetters['user/getCanAccessLibrary'](libraryId)
if (!canUserAccessLibrary) {
console.warn('Access not allowed to library')
return false
}
var library = state.libraries.find(lib => lib.id === libraryId)
if (library) {
commit('setCurrentLibrary', libraryId)
@@ -102,9 +107,6 @@ export const mutations = {
setFoldersLastUpdate(state) {
state.folderLastUpdate = Date.now()
},
setShowModal(state, val) {
state.showModal = val
},
setLastLoad(state) {
state.lastLoad = Date.now()
},
+14
View File
@@ -33,6 +33,19 @@ export const getters = {
},
getUserCanUpload: (state) => {
return state.user && state.user.permissions ? !!state.user.permissions.upload : false
},
getUserCanAccessAllLibraries: (state) => {
return state.user && state.user.permissions ? !!state.user.permissions.accessAllLibraries : false
},
getLibrariesAccessible: (state, getters) => {
if (!state.user) return []
if (getters.getUserCanAccessAllLibraries) return []
return state.user.librariesAccessible || []
},
getCanAccessLibrary: (state, getters) => (libraryId) => {
if (!state.user) return false
if (getters.getUserCanAccessAllLibraries) return true
return getters.getLibrariesAccessible.includes(libraryId)
}
}
@@ -60,6 +73,7 @@ export const actions = {
export const mutations = {
setUser(state, user) {
state.user = user
if (user) {
if (user.token) localStorage.setItem('token', user.token)
} else {
+6 -1
View File
@@ -4,7 +4,9 @@ export const state = () => ({
})
export const getters = {
getIsUserOnline: state => id => {
return state.users.find(u => u.id === id)
}
}
export const actions = {
@@ -12,6 +14,9 @@ export const actions = {
}
export const mutations = {
resetUsers(state) {
state.users = []
},
updateUser(state, user) {
var index = state.users.findIndex(u => u.id === user.id)
if (index >= 0) {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.4.3",
"version": "1.4.13",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -1222,9 +1222,9 @@
"integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
},
"njodb": {
"version": "0.4.20",
"resolved": "https://registry.npmjs.org/njodb/-/njodb-0.4.20.tgz",
"integrity": "sha512-y/V9yTSa6fXlfkD453o8engmbFvMabpogSYt53sNft48oqzO5tk4OTl564Zf2IN8JtJDp4ShnZE4hIXePqfvhg==",
"version": "0.4.21",
"resolved": "https://registry.npmjs.org/njodb/-/njodb-0.4.21.tgz",
"integrity": "sha512-3qLMzwIZUgT1yq2PCzJlT6FFK/zfLHz71QnFeE9ec4KKJH9abY4SXnmHVaWP7wVq+lY77wW1F+EeKG9gm8j6WA==",
"requires": {
"proper-lockfile": "^4.1.2"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.4.7",
"version": "1.4.16",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js",
"scripts": {
@@ -37,7 +37,7 @@
"ip": "^1.1.5",
"jsonwebtoken": "^8.5.1",
"libgen": "^2.1.0",
"njodb": "^0.4.20",
"njodb": "^0.4.21",
"node-cron": "^3.0.0",
"node-dir": "^0.1.17",
"node-stream-zip": "^1.15.0",
+34 -1
View File
@@ -117,6 +117,39 @@ cd audiobookshelf
npm run prod -- -p [PORT] --audiobooks [AUDIOBOOKS_PATH] --config [CONFIG_PATH] --metadata [METADATA_PATH]
```
### nginx Reverse Proxy
Add this to the site config file on your nginx server after you have changed the relevant parts in the <> brackets, and inserted the path to you certificates.
```bash
server
{
listen 443 ssl;
server_name <sub>.<domain>.<tld>;
access_log /var/log/nginx/audiobookshelf.access.log;
error_log /var/log/nginx/audiobookshelf.error.log;
ssl_certificate /path/to/certificate;
ssl_certificate_key /path/to/key;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
proxy_pass http://<URL_to_forward_to>;
proxy_redirect http:// https://;
}
}
```
## Contributing
Feel free to help out
Feel free to help out
+138 -16
View File
@@ -1,10 +1,13 @@
const express = require('express')
const Path = require('path')
const fs = require('fs-extra')
const Logger = require('./Logger')
const User = require('./objects/User')
const { isObject } = require('./utils/index')
const audioFileScanner = require('./utils/audioFileScanner')
const Library = require('./objects/Library')
const User = require('./objects/User')
class ApiController {
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, backupManager, watcher, emitter, clientEmitter) {
@@ -47,6 +50,7 @@ class ApiController {
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
this.router.patch('/audiobook/:id/coverfile', this.updateAudiobookCoverFromFile.bind(this))
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
this.router.patch('/match/:id', this.match.bind(this))
@@ -59,6 +63,7 @@ class ApiController {
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
this.router.get('/users', this.getUsers.bind(this))
this.router.post('/user', this.createUser.bind(this))
this.router.get('/user/:id', this.getUser.bind(this))
this.router.patch('/user/:id', this.updateUser.bind(this))
this.router.delete('/user/:id', this.deleteUser.bind(this))
@@ -76,6 +81,8 @@ class ApiController {
this.router.get('/download/:id', this.download.bind(this))
this.router.get('/filesystem', this.getFileSystemPaths.bind(this))
this.router.get('/scantracks/:id', this.scanAudioTrackNums.bind(this))
}
find(req, res) {
@@ -142,15 +149,18 @@ class ApiController {
var bookMatches = []
var authorMatches = {}
var seriesMatches = {}
var tagMatches = {}
var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
audiobooksInLibrary.forEach((ab) => {
var queryResult = ab.searchQuery(req.query.q)
if (queryResult.book) {
bookMatches.push({
var bookMatchObj = {
audiobook: ab,
matchKey: queryResult.book
})
matchKey: queryResult.book,
matchText: queryResult.bookMatchText
}
bookMatches.push(bookMatchObj)
}
if (queryResult.author && !authorMatches[queryResult.author]) {
authorMatches[queryResult.author] = {
@@ -167,10 +177,23 @@ class ApiController {
seriesMatches[queryResult.series].audiobooks.push(ab)
}
}
if (queryResult.tags && queryResult.tags.length) {
queryResult.tags.forEach((tag) => {
if (!tagMatches[tag]) {
tagMatches[tag] = {
tag,
audiobooks: [ab]
}
} else {
tagMatches[tag].audiobooks.push(ab)
}
})
}
})
res.json({
audiobooks: bookMatches.slice(0, maxResults),
tags: Object.values(tagMatches).slice(0, maxResults),
authors: Object.values(authorMatches).slice(0, maxResults),
series: Object.values(seriesMatches).slice(0, maxResults)
})
@@ -292,8 +315,17 @@ class ApiController {
}
getAudiobook(req, res) {
if (!req.user) {
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
// Check user can access this audiobooks library
if (!req.user.checkCanAccessLibrary(audiobook.libraryId)) {
return res.sendStatus(403)
}
res.json(audiobook.toJSONExpanded())
}
@@ -445,6 +477,26 @@ class ApiController {
})
}
async updateAudiobookCoverFromFile(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update without permission', req.user)
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
var coverFile = req.body
var updated = await audiobook.setCoverFromFile(coverFile)
if (updated) {
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
}
if (updated) res.status(200).send('Cover updated successfully')
else res.status(200).send('No update was made to cover')
}
async updateAudiobook(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update without permission', req.user)
@@ -480,20 +532,23 @@ class ApiController {
res.sendStatus(200)
}
getUsers(req, res) {
if (req.user.type !== 'root') return res.sendStatus(403)
return res.json(this.db.users.map(u => u.toJSONForBrowser()))
}
async resetUserAudiobookProgress(req, res) {
req.user.resetAudiobookProgress(req.params.id)
var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id)
if (!audiobook) {
return res.status(404).send('Audiobook not found')
}
req.user.resetAudiobookProgress(audiobook)
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.sendStatus(200)
}
async updateUserAudiobookProgress(req, res) {
var wasUpdated = req.user.updateAudiobookProgress(req.params.id, req.body)
var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id)
if (!audiobook) {
return res.status(404).send('Audiobook not found')
}
var wasUpdated = req.user.updateAudiobookProgress(audiobook, req.body)
if (wasUpdated) {
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
@@ -509,8 +564,11 @@ class ApiController {
var shouldUpdate = false
abProgresses.forEach((progress) => {
var wasUpdated = req.user.updateAudiobookProgress(progress.audiobookId, progress)
if (wasUpdated) shouldUpdate = true
var audiobook = this.db.audiobooks.find(ab => ab.id === progress.audiobookId)
if (audiobook) {
var wasUpdated = req.user.updateAudiobookProgress(audiobook, progress)
if (wasUpdated) shouldUpdate = true
}
})
if (shouldUpdate) {
@@ -549,12 +607,43 @@ class ApiController {
})
}
userJsonWithBookProgressDetails(user) {
var json = user.toJSONForBrowser()
// User audiobook progress attach book details
if (json.audiobooks && Object.keys(json.audiobooks).length) {
for (const audiobookId in json.audiobooks) {
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
if (!audiobook) {
Logger.error('[ApiController] Audiobook not found for users progress ' + audiobookId)
} else {
json.audiobooks[audiobookId].book = audiobook.book.toJSON()
}
}
}
return json
}
getUsers(req, res) {
if (req.user.type !== 'root') return res.sendStatus(403)
var users = this.db.users.map(u => this.userJsonWithBookProgressDetails(u))
res.json(users)
}
async createUser(req, res) {
if (!req.user.isRoot) {
Logger.warn('Non-root user attempted to create user', req.user)
return res.sendStatus(403)
}
var account = req.body
var username = account.username
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase())
if (usernameExists) {
return res.status(500).send('Username already taken')
}
account.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
account.pash = await this.auth.hashPass(account.password)
delete account.password
@@ -568,12 +657,24 @@ class ApiController {
user: newUser.toJSONForBrowser()
})
} else {
res.json({
error: 'Failed to save new user'
})
return res.status(500).send('Failed to save new user')
}
}
async getUser(req, res) {
if (!req.user.isRoot) {
Logger.error('User other than root attempting to get user', req.user)
return res.sendStatus(403)
}
var user = this.db.users.find(u => u.id === req.params.id)
if (!user) {
return res.sendStatus(404)
}
res.json(this.userJsonWithBookProgressDetails(user))
}
async updateUser(req, res) {
if (!req.user.isRoot) {
Logger.error('User other than root attempting to update user', req.user)
@@ -586,6 +687,14 @@ class ApiController {
}
var account = req.body
if (account.username !== undefined && account.username !== user.username) {
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === account.username.toLowerCase())
if (usernameExists) {
return res.status(500).send('Username already taken')
}
}
// Updating password
if (account.password) {
account.pash = await this.auth.hashPass(account.password)
@@ -762,5 +871,18 @@ class ApiController {
var dirs = await this.getDirectories(global.appRoot, '/', excludedDirs)
res.json(dirs)
}
async scanAudioTrackNums(req, res) {
if (!req.user || !req.user.isRoot) {
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id)
if (!audiobook) {
return res.status(404).send('Audiobook not found')
}
var scandata = await audioFileScanner.scanTrackNumbers(audiobook)
res.json(scandata)
}
}
module.exports = ApiController
+2 -2
View File
@@ -97,11 +97,11 @@ class Auth {
}
async login(req, res) {
var username = req.body.username
var username = (req.body.username || '').toLowerCase()
var password = req.body.password || ''
Logger.debug('Check Auth', username, !!password)
var user = this.users.find(u => u.username === username)
var user = this.users.find(u => u.username.toLowerCase() === username)
if (!user || !user.isActive) {
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
+10 -1
View File
@@ -307,7 +307,16 @@ class BackupManager {
// pipe archive data to the file
archive.pipe(output)
archive.directory(configPath, 'config')
var audiobooksDbDir = Path.join(configPath, 'audiobooks')
var librariesDbDir = Path.join(configPath, 'libraries')
var settingsDbDir = Path.join(configPath, 'settings')
var usersDbDir = Path.join(configPath, 'users')
archive.directory(audiobooksDbDir, 'config/audiobooks')
archive.directory(librariesDbDir, 'config/libraries')
archive.directory(settingsDbDir, 'config/settings')
archive.directory(usersDbDir, 'config/users')
if (metadataBooksPath) {
Logger.debug(`[BackupManager] Backing up Metadata Books "${metadataBooksPath}"`)
archive.directory(metadataBooksPath, 'metadata-books')
+5 -34
View File
@@ -82,10 +82,14 @@ class Db {
await this.load()
// Insert Defaults
if (!this.users.find(u => u.type === 'root')) {
var rootUser = this.users.find(u => u.type === 'root')
if (!rootUser) {
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
Logger.debug('Generated default token', token)
Logger.info('[Db] Root user created')
await this.insertEntity('user', this.getDefaultUser(token))
} else {
Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`)
}
if (!this.libraries.length) {
@@ -123,19 +127,6 @@ class Db {
await Promise.all([p1, p2, p3, p4])
}
// insertAudiobook(audiobook) {
// return this.insertAudiobooks([audiobook])
// }
// insertAudiobooks(audiobooks) {
// return this.audiobooksDb.insert(audiobooks).then((results) => {
// Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
// this.audiobooks = this.audiobooks.concat(audiobooks)
// }).catch((error) => {
// Logger.error(`[DB] Insert audiobooks Failed ${error}`)
// })
// }
updateAudiobook(audiobook) {
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
Logger.debug(`[DB] Audiobook updated ${results.updated}`)
@@ -146,26 +137,6 @@ class Db {
})
}
// insertUser(user) {
// return this.usersDb.insert([user]).then((results) => {
// Logger.debug(`[DB] Inserted user ${results.inserted}`)
// this.users.push(user)
// return true
// }).catch((error) => {
// Logger.error(`[DB] Insert user Failed ${error}`)
// return false
// })
// }
// insertSettings(settings) {
// return this.settingsDb.insert([settings]).then((results) => {
// Logger.debug(`[DB] Inserted ${results.inserted} settings`)
// this.settings = this.settings.concat(settings)
// }).catch((error) => {
// Logger.error(`[DB] Insert settings Failed ${error}`)
// })
// }
updateUserStream(userId, streamId) {
return this.usersDb.update((record) => record.id === userId, (user) => {
user.stream = streamId
+3
View File
@@ -241,10 +241,13 @@ class DownloadManager {
if (shouldIncludeCover) {
var _cover = audiobook.book.coverFullPath
// Supporting old local file prefix
if (!_cover && audiobook.book.cover && audiobook.book.cover.startsWith(Path.sep + 'local')) {
_cover = Path.join(this.AudiobookPath, _cover.replace(Path.sep + 'local', ''))
Logger.debug('Local cover url', _cover)
}
ffmpegInputs.push({
input: _cover,
options: ['-f image2pipe']
+9 -94
View File
@@ -109,7 +109,7 @@ class Scanner {
titleDistance: 2,
authorDistance: 2
}
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.authorFL, options)
if (results.length) {
Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
@@ -199,6 +199,14 @@ class Scanner {
if (forceAudioFileScan) {
Logger.info(`[Scanner] Rescanning ${existingAudiobook.audioFiles.length} audio files for "${existingAudiobook.title}"`)
var numAudioFilesUpdated = await audioFileScanner.rescanAudioFiles(existingAudiobook)
// Set book details from metadata pulled from audio files
var bookMetadataUpdated = existingAudiobook.setDetailsFromFileMetadata()
if (bookMetadataUpdated) {
Logger.debug(`[Scanner] Book Metadata Updated for "${existingAudiobook.title}"`)
hasUpdatedAudioFiles = true
}
if (numAudioFilesUpdated > 0) {
Logger.info(`[Scanner] Rescan complete, ${numAudioFilesUpdated} audio files were updated for "${existingAudiobook.title}"`)
hasUpdatedAudioFiles = true
@@ -580,49 +588,6 @@ class Scanner {
return this.scanAudiobookData(audiobookData, forceAudioFileScan)
}
// Files were modified in this directory, check it out
// async checkDir(dir) {
// var exists = await fs.pathExists(dir)
// if (!exists) {
// // Audiobook was deleted, TODO: Should confirm this better
// var audiobook = this.db.audiobooks.find(ab => ab.fullPath === dir)
// if (audiobook) {
// var audiobookJSON = audiobook.toJSONMinified()
// await this.db.removeEntity('audiobook', audiobook.id)
// this.emitter('audiobook_removed', audiobookJSON)
// return ScanResult.REMOVED
// }
// // Path inside audiobook was deleted, scan audiobook
// audiobook = this.db.audiobooks.find(ab => dir.startsWith(ab.fullPath))
// if (audiobook) {
// Logger.info(`[Scanner] Path inside audiobook "${audiobook.title}" was deleted: ${dir}`)
// return this.scanAudiobook(audiobook.fullPath)
// }
// Logger.warn('[Scanner] Path was deleted but no audiobook found', dir)
// return ScanResult.NOTHING
// }
// // Check if this is a subdirectory of an audiobook
// var audiobook = this.db.audiobooks.find((ab) => dir.startsWith(ab.fullPath))
// if (audiobook) {
// Logger.debug(`[Scanner] Check Dir audiobook "${audiobook.title}" found: ${dir}`)
// return this.scanAudiobook(audiobook.fullPath)
// }
// // Check if an audiobook is a subdirectory of this dir
// audiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(dir))
// if (audiobook) {
// Logger.warn(`[Scanner] Files were added/updated in a root directory of an existing audiobook, ignore files: ${dir}`)
// return ScanResult.NOTHING
// }
// // Must be a new audiobook
// Logger.debug(`[Scanner] Check Dir must be a new audiobook: ${dir}`)
// return this.scanAudiobook(dir)
// }
async scanFolderUpdates(libraryId, folderId, fileUpdateBookGroup) {
var library = this.db.libraries.find(lib => lib.id === libraryId)
if (!library) {
@@ -730,56 +695,6 @@ class Scanner {
return libraryScanResults
}
// async scanCovers() {
// var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
// var found = 0
// var notFound = 0
// var failed = 0
// for (let i = 0; i < audiobooksNeedingCover.length; i++) {
// var audiobook = audiobooksNeedingCover[i]
// var options = {
// titleDistance: 2,
// authorDistance: 2
// }
// var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
// if (results.length) {
// Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
// var coverUrl = results[0]
// var result = await this.coverController.downloadCoverFromUrl(audiobook, coverUrl)
// if (result.error) {
// failed++
// } else {
// found++
// await this.db.updateAudiobook(audiobook)
// this.emitter('audiobook_updated', audiobook.toJSONMinified())
// }
// } else {
// notFound++
// }
// var progress = Math.round(100 * (i + 1) / audiobooksNeedingCover.length)
// this.emitter('scan_progress', {
// scanType: 'covers',
// progress: {
// total: audiobooksNeedingCover.length,
// done: i + 1,
// progress
// }
// })
// if (this.cancelScan) {
// this.cancelScan = false
// break
// }
// }
// return {
// found,
// notFound,
// failed
// }
// }
async saveMetadata(audiobookId) {
if (audiobookId) {
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
+44 -13
View File
@@ -46,7 +46,7 @@ class Server {
this.watcher = new Watcher(this.AudiobookPath)
this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
this.streamManager = new StreamManager(this.db, this.MetadataPath)
this.streamManager = new StreamManager(this.db, this.MetadataPath, this.emitter.bind(this))
this.rssFeeds = new RssFeeds(this.Port, this.db)
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.emitter.bind(this), this.clientEmitter.bind(this))
@@ -57,8 +57,6 @@ class Server {
this.io = null
this.clients = {}
this.isScanningCovers = false
}
get audiobooks() {
@@ -70,6 +68,11 @@ class Server {
get serverSettings() {
return this.db.serverSettings
}
get usersOnline() {
return Object.values(this.clients).filter(c => c.user).map(client => {
return client.user.toJSONForPublic(this.streamManager.streams)
})
}
getClientsForUser(userId) {
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
@@ -83,6 +86,7 @@ class Server {
clientEmitter(userId, ev, data) {
var clients = this.getClientsForUser(userId)
if (!clients.length) {
console.log('clients', clients)
return Logger.error(`[Server] clientEmitter - no clients found for user ${userId}`)
}
clients.forEach((client) => {
@@ -193,7 +197,7 @@ class Server {
var loginRateLimiter = this.getLoginRateLimiter()
app.post('/login', loginRateLimiter, (req, res) => this.auth.login(req, res))
app.post('/logout', this.logout.bind(this))
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
app.get('/ping', (req, res) => {
Logger.info('Recieved ping')
@@ -203,10 +207,6 @@ class Server {
// Used in development to set-up streams without authentication
if (process.env.NODE_ENV !== 'production') {
app.use('/test-hls', this.hlsController.router)
app.get('/test-stream/:id', async (req, res) => {
var uri = await this.streamManager.openTestStream(this.MetadataPath, req.params.id)
res.send(uri)
})
app.get('/catalog.json', (req, res) => {
Logger.error('Catalog request made', req.headers)
res.json()
@@ -269,15 +269,16 @@ class Server {
var _client = this.clients[socket.id]
if (!_client) {
Logger.warn('[SOCKET] Socket disconnect, no client ' + socket.id)
Logger.warn('[Server] Socket disconnect, no client ' + socket.id)
} else if (!_client.user) {
Logger.info('[SOCKET] Unauth socket disconnected ' + socket.id)
Logger.info('[Server] Unauth socket disconnected ' + socket.id)
delete this.clients[socket.id]
} else {
socket.broadcast.emit('user_offline', _client.user.toJSONForPublic(this.streamManager.streams))
Logger.debug('[Server] User Offline ' + _client.user.username)
this.io.emit('user_offline', _client.user.toJSONForPublic(this.streamManager.streams))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[SOCKET] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`)
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`)
delete this.clients[socket.id]
}
})
@@ -426,6 +427,27 @@ class Server {
}
logout(req, res) {
var { socketId } = req.body
Logger.info(`[Server] User ${req.user ? req.user.username : 'Unknown'} is logging out with socket ${socketId}`)
// Strip user and client from client and client socket
if (socketId && this.clients[socketId]) {
var client = this.clients[socketId]
var clientSocket = client.socket
Logger.debug(`[Server] Found user client ${clientSocket.id}, Has user: ${!!client.user}, Socket has client: ${!!clientSocket.sheepClient}`)
if (client.user) {
Logger.debug('[Server] User Offline ' + client.user.username)
this.io.emit('user_offline', client.user.toJSONForPublic(null))
}
delete this.clients[socketId].user
delete this.clients[socketId].stream
if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient
} else if (socketId) {
Logger.warn(`[Server] No client for socket ${socketId}`)
}
res.sendStatus(200)
}
@@ -444,6 +466,11 @@ class Server {
return socket.emit('invalid_token')
}
var client = this.clients[socket.id]
if (client.user !== undefined) {
Logger.debug(`[Server] Authenticating socket client already has user`, client.user)
}
client.user = user
if (!client.user.toJSONForBrowser) {
@@ -462,7 +489,8 @@ class Server {
}
}
socket.broadcast.emit('user_online', client.user.toJSONForPublic(this.streamManager.streams))
Logger.debug(`[Server] User Online ${client.user.username}`)
this.io.emit('user_online', client.user.toJSONForPublic(this.streamManager.streams))
user.lastSeen = Date.now()
await this.db.updateEntity('user', user)
@@ -477,6 +505,9 @@ class Server {
librariesScanning: this.scanner.librariesScanning,
backups: (this.backupManager.backups || []).map(b => b.toJSON())
}
if (user.type === 'root') {
initialPayload.usersOnline = this.usersOnline
}
client.socket.emit('init', initialPayload)
// Setup log listener for root user
+5 -20
View File
@@ -5,9 +5,11 @@ const fs = require('fs-extra')
const Path = require('path')
class StreamManager {
constructor(db, MetadataPath) {
constructor(db, MetadataPath, emitter) {
this.db = db
this.emitter = emitter
this.MetadataPath = MetadataPath
this.streams = []
this.StreamsPath = Path.join(this.MetadataPath, 'streams')
@@ -112,7 +114,7 @@ class StreamManager {
var stream = await this.openStream(client, audiobook)
this.db.updateUserStream(client.user.id, stream.id)
socket.broadcast.emit('user_stream_update', client.user.toJSONForPublic(this.streams))
this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams))
}
async closeStreamRequest(socket) {
@@ -129,24 +131,7 @@ class StreamManager {
client.stream = null
this.db.updateUserStream(client.user.id, null)
socket.broadcast.emit('user_stream_update', client.user.toJSONForPublic(this.streams))
}
async openTestStream(StreamsPath, audiobookId) {
Logger.info('Open Stream Test Request', audiobookId)
// var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
// var stream = new StreamTest(StreamsPath, audiobook)
// stream.on('closed', () => {
// console.log('Stream closed')
// })
// var playlistUri = await stream.generatePlaylist()
// stream.start()
// Logger.info('Stream Playlist', playlistUri)
// Logger.info('Test Stream Opened for audiobook', audiobook.title, 'with streamId', stream.id)
// return playlistUri
this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams))
}
streamUpdate(socket, { currentTime, streamId }) {
+18 -1
View File
@@ -13,6 +13,7 @@ class AudioFile {
this.trackNumFromMeta = null
this.trackNumFromFilename = null
this.cdNumFromFilename = null
this.format = null
this.duration = null
@@ -39,6 +40,16 @@ class AudioFile {
}
}
// Sort number takes cd num into account
// get sortNumber() {
// if (this.manuallyVerified) return this.index
// var num = this.index
// if (this.cdNumFromFilename && !isNaN(this.cdNumFromFilename)) {
// num += (Number(this.cdNumFromFilename) * 1000)
// }
// return num
// }
toJSON() {
return {
index: this.index,
@@ -50,6 +61,7 @@ class AudioFile {
addedAt: this.addedAt,
trackNumFromMeta: this.trackNumFromMeta,
trackNumFromFilename: this.trackNumFromFilename,
cdNumFromFilename: this.cdNumFromFilename,
manuallyVerified: !!this.manuallyVerified,
invalid: !!this.invalid,
exclude: !!this.exclude,
@@ -84,6 +96,7 @@ class AudioFile {
this.trackNumFromMeta = data.trackNumFromMeta || null
this.trackNumFromFilename = data.trackNumFromFilename || null
this.cdNumFromFilename = data.cdNumFromFilename || null
this.format = data.format
this.duration = data.duration
@@ -117,6 +130,7 @@ class AudioFile {
this.trackNumFromMeta = data.trackNumFromMeta || null
this.trackNumFromFilename = data.trackNumFromFilename || null
this.cdNumFromFilename = data.cdNumFromFilename || null
this.manuallyVerified = !!data.manuallyVerified
this.invalid = !!data.invalid
@@ -178,7 +192,10 @@ class AudioFile {
channels: data.channels,
channelLayout: data.channel_layout,
chapters: data.chapters || [],
embeddedCoverArt: data.embedded_cover_art || null
embeddedCoverArt: data.embedded_cover_art || null,
trackNumFromMeta: data.trackNumFromMeta,
trackNumFromFilename: data.trackNumFromFilename,
cdNumFromFilename: data.cdNumFromFilename
}
var hasUpdates = false
+8
View File
@@ -4,6 +4,8 @@ class AudioFileMetadata {
this.tagArtist = null
this.tagGenre = null
this.tagTitle = null
this.tagSeries = null
this.tagSeriesPart = null
this.tagTrack = null
this.tagSubtitle = null
this.tagAlbumArtist = null
@@ -36,6 +38,8 @@ class AudioFileMetadata {
this.tagArtist = metadata.tagArtist || null
this.tagGenre = metadata.tagGenre || null
this.tagTitle = metadata.tagTitle || null
this.tagSeries = metadata.tagSeries || null
this.tagSeriesPart = metadata.tagSeriesPart || null
this.tagTrack = metadata.tagTrack || null
this.tagSubtitle = metadata.tagSubtitle || null
this.tagAlbumArtist = metadata.tagAlbumArtist || null
@@ -54,6 +58,8 @@ class AudioFileMetadata {
this.tagArtist = payload.file_tag_artist || null
this.tagGenre = payload.file_tag_genre || null
this.tagTitle = payload.file_tag_title || null
this.tagSeries = payload.file_tag_series || null
this.tagSeriesPart = payload.file_tag_seriespart || null
this.tagTrack = payload.file_tag_track || null
this.tagSubtitle = payload.file_tag_subtitle || null
this.tagAlbumArtist = payload.file_tag_albumartist || null
@@ -72,6 +78,8 @@ class AudioFileMetadata {
tagArtist: payload.file_tag_artist || null,
tagGenre: payload.file_tag_genre || null,
tagTitle: payload.file_tag_title || null,
tagSeries: payload.file_tag_series || null,
tagSeriesPart: payload.file_tag_seriespart || null,
tagTrack: payload.file_tag_track || null,
tagSubtitle: payload.file_tag_subtitle || null,
tagAlbumArtist: payload.file_tag_albumartist || null,
+29 -12
View File
@@ -127,14 +127,6 @@ class Audiobook {
return this.otherFiles.filter(file => file.filetype === 'ebook')
}
get hasEpub() {
return this.otherFiles.find(file => file.ext === '.epub')
}
get hasMobi() {
return this.otherFiles.find(file => file.ext === '.mobi' || file.ext === '.azw3')
}
get hasMissingIno() {
return !this.ino || this._audioFiles.find(abf => !abf.ino) || this._otherFiles.find(f => !f.ino) || this._tracks.find(t => !t.ino)
}
@@ -206,7 +198,7 @@ class Audiobook {
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
// numEbooks: this.ebooks.length,
ebooks: this.ebooks.map(ebook => ebook.toJSON()),
numEbooks: (this.hasEpub || this.hasMobi) ? 1 : 0, // Only supporting epubs in the reader currently
numEbooks: this.ebooks.length,
numTracks: this.tracks.length,
chapters: this.chapters || [],
isMissing: !!this.isMissing,
@@ -233,7 +225,8 @@ class Audiobook {
audioFiles: this._audioFiles.map(audioFile => audioFile.toJSON()),
otherFiles: this._otherFiles.map(otherFile => otherFile.toJSON()),
ebooks: this.ebooks.map(ebook => ebook.toJSON()),
numEbooks: this.hasEpub ? 1 : 0,
numEbooks: this.ebooks.length,
numTracks: this.tracks.length,
tags: this.tags,
book: this.bookToJSON(),
tracks: this.tracksToJSON(),
@@ -363,6 +356,19 @@ class Audiobook {
this.book.setData(data)
}
setCoverFromFile(file) {
if (!file || !file.fullPath || !file.path) {
Logger.error(`[Audiobook] "${this.title}" Invalid file for setCoverFromFile`, file)
return false
}
var updateBookPayload = {}
updateBookPayload.coverFullPath = Path.normalize(file.fullPath)
// Set ab local static path from file relative path
var relImagePath = file.path.replace(this.path, '')
updateBookPayload.cover = Path.normalize(Path.join(`/s/book/${this.id}`, relImagePath))
return this.book.update(updateBookPayload)
}
addTrack(trackData) {
var track = new AudioTrack()
track.setData(trackData)
@@ -632,11 +638,22 @@ class Audiobook {
}
isSearchMatch(search) {
return this.book.isSearchMatch(search.toLowerCase().trim())
var tagMatch = this.tags.filter(tag => {
return tag.toLowerCase().includes(search.toLowerCase().trim())
})
return this.book.isSearchMatch(search.toLowerCase().trim()) || tagMatch.length
}
searchQuery(search) {
return this.book.getQueryMatches(search.toLowerCase().trim())
var matches = this.book.getQueryMatches(search.toLowerCase().trim())
matches.tags = this.tags.filter(tag => {
return tag.toLowerCase().includes(search.toLowerCase().trim())
})
if (!matches.book && matches.tags.length) {
matches.book = 'tags'
matches.bookMatchText = matches.tags.join(', ')
}
return matches
}
getAudioFileByIno(ino) {
+5 -4
View File
@@ -1,7 +1,8 @@
class AudiobookProgress {
constructor(progress) {
this.audiobookId = null
this.audiobookTitle = null
this.id = null
this.totalDuration = null // seconds
this.progress = null // 0 to 1
this.currentTime = null // seconds
@@ -18,7 +19,6 @@ class AudiobookProgress {
toJSON() {
return {
audiobookId: this.audiobookId,
audiobookTitle: this.audiobookTitle,
totalDuration: this.totalDuration,
progress: this.progress,
currentTime: this.currentTime,
@@ -31,7 +31,6 @@ class AudiobookProgress {
construct(progress) {
this.audiobookId = progress.audiobookId
this.audiobookTitle = progress.audiobookTitle || null
this.totalDuration = progress.totalDuration
this.progress = progress.progress
this.currentTime = progress.currentTime
@@ -43,7 +42,6 @@ class AudiobookProgress {
updateFromStream(stream) {
this.audiobookId = stream.audiobookId
this.audiobookTitle = stream.audiobookTitle
this.totalDuration = stream.totalDuration
this.progress = stream.clientProgress
this.currentTime = stream.clientCurrentTime
@@ -89,6 +87,9 @@ class AudiobookProgress {
if (!this.startedAt) {
this.startedAt = Date.now()
}
if (hasUpdates) {
this.lastUpdate = Date.now()
}
return hasUpdates
}
}
+50 -9
View File
@@ -35,11 +35,11 @@ class Book {
get _title() { return this.title || '' }
get _subtitle() { return this.subtitle || '' }
get _narrator() { return this.narrator || '' }
get _author() { return this.author || '' }
get _author() { return this.authorFL || '' }
get _series() { return this.series || '' }
get shouldSearchForCover() {
if (this.author !== this.lastCoverSearchAuthor || this.title !== this.lastCoverSearchTitle || !this.lastCoverSearch) return true
if (this.authorFL !== this.lastCoverSearchAuthor || this.title !== this.lastCoverSearchTitle || !this.lastCoverSearch) return true
var timeSinceLastSearch = Date.now() - this.lastCoverSearch
return timeSinceLastSearch > 1000 * 60 * 60 * 24 * 7 // every 7 days do another lookup
}
@@ -132,12 +132,18 @@ class Book {
update(payload) {
var hasUpdates = false
// Normalize cover paths if passed
if (payload.cover) {
// If updating to local cover then normalize path
if (!payload.cover.startsWith('http:') && !payload.cover.startsWith('https:')) {
payload.cover = Path.normalize(payload.cover)
if (payload.coverFullPath) payload.coverFullPath = Path.normalize(payload.coverFullPath)
else {
Logger.warn(`[Book] "${this.title}" updating book cover to "${payload.cover}" but no full path was passed`)
}
}
} else if (payload.coverFullPath) {
Logger.warn(`[Book] "${this.title}" updating book full cover path to "${payload.coverFullPath}" but no relative path was passed`)
payload.coverFullPath = Path.normalize(payload.coverFullPath)
}
for (const key in payload) {
@@ -174,7 +180,7 @@ class Book {
updateLastCoverSearch(coverWasFound) {
this.lastCoverSearch = coverWasFound ? null : Date.now()
this.lastCoverSearchAuthor = coverWasFound ? null : this.author
this.lastCoverSearchAuthor = coverWasFound ? null : this.authorFL
this.lastCoverSearchTitle = coverWasFound ? null : this.title
}
@@ -214,16 +220,32 @@ class Book {
}
getQueryMatches(search) {
var titleMatch = this._title.toLowerCase().includes(search) || this._subtitle.toLowerCase().includes(search)
var titleMatch = this._title.toLowerCase().includes(search)
var subtitleMatch = this._subtitle.toLowerCase().includes(search)
var authorMatch = this._author.toLowerCase().includes(search)
var seriesMatch = this._series.toLowerCase().includes(search)
var bookMatchKey = titleMatch ? 'title' : subtitleMatch ? 'subtitle' : authorMatch ? 'authorFL' : seriesMatch ? 'series' : false
var bookMatchText = bookMatchKey ? this[bookMatchKey] : ''
return {
book: titleMatch ? 'title' : authorMatch ? 'author' : seriesMatch ? 'series' : false,
book: bookMatchKey,
bookMatchText,
author: authorMatch ? this._author : false,
series: seriesMatch ? this._series : false
}
}
parseGenresTag(genreTag) {
if (!genreTag || !genreTag.length) return []
var separators = ['/', '//', ';']
for (let i = 0; i < separators.length; i++) {
if (genreTag.includes(separators[i])) {
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
}
}
return [genreTag]
}
setDetailsFromFileMetadata(audioFileMetadata) {
const MetadataMapArray = [
{
@@ -249,14 +271,33 @@ class Book {
{
tag: 'tagArtist',
key: 'author'
},
{
tag: 'tagGenre',
key: 'genres'
},
{
tag: 'tagSeries',
key: 'series'
},
{
tag: 'tagSeriesPart',
key: 'volumeNumber'
}
]
var updatePayload = {}
// Metadata is only mapped to the book if it is empty
MetadataMapArray.forEach((mapping) => {
if (!this[mapping.key] && audioFileMetadata[mapping.tag]) {
updatePayload[mapping.key] = audioFileMetadata[mapping.tag]
Logger.debug(`[Book] Mapping metadata to key ${mapping.tag} => ${mapping.key}: ${updatePayload[mapping.key]}`)
if (audioFileMetadata[mapping.tag]) {
// Genres can contain multiple
if (mapping.key === 'genres' && (!this[mapping.key].length || !this[mapping.key])) {
updatePayload[mapping.key] = this.parseGenresTag(audioFileMetadata[mapping.tag])
Logger.debug(`[Book] Mapping metadata to key ${mapping.tag} => ${mapping.key}: ${updatePayload[mapping.key].join(',')}`)
} else if (!this[mapping.key]) {
updatePayload[mapping.key] = audioFileMetadata[mapping.tag]
Logger.debug(`[Book] Mapping metadata to key ${mapping.tag} => ${mapping.key}: ${updatePayload[mapping.key]}`)
}
}
})
+3 -2
View File
@@ -261,7 +261,7 @@ class Stream extends EventEmitter {
const hlsOptions = [
'-f hls',
"-copyts",
"-avoid_negative_ts disabled",
"-avoid_negative_ts make_non_negative",
"-max_delay 5000000",
"-max_muxing_queue_size 2048",
`-hls_time 6`,
@@ -273,7 +273,8 @@ class Stream extends EventEmitter {
]
if (this.hlsSegmentType === 'fmp4') {
hlsOptions.push('-strict -2')
var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4')
// var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4')
var fmp4InitFilename = 'init.mp4'
hlsOptions.push(`-hls_fmp4_init_filename ${fmp4InitFilename}`)
}
this.ffmpeg.addOption(hlsOptions)
+48 -11
View File
@@ -16,6 +16,7 @@ class User {
this.settings = {}
this.permissions = {}
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
if (user) {
this.construct(user)
@@ -37,9 +38,18 @@ class User {
get canUpload() {
return !!this.permissions.upload && this.isActive
}
get canAccessAllLibraries() {
return !!this.permissions.accessAllLibraries && this.isActive
}
get hasPw() {
return !!this.pash && !!this.pash.length
}
getDefaultUserSettings() {
return {
mobileOrderBy: 'recent',
mobileOrderDesc: true,
mobileFilterBy: 'all',
orderBy: 'book.title',
orderDesc: false,
filterBy: 'all',
@@ -53,7 +63,8 @@ class User {
download: true,
update: true,
delete: this.type === 'root',
upload: this.type === 'root' || this.type === 'admin'
upload: this.type === 'root' || this.type === 'admin',
accessAllLibraries: true
}
}
@@ -82,7 +93,8 @@ class User {
lastSeen: this.lastSeen,
createdAt: this.createdAt,
settings: this.settings,
permissions: this.permissions
permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible]
}
}
@@ -99,10 +111,12 @@ class User {
lastSeen: this.lastSeen,
createdAt: this.createdAt,
settings: this.settings,
permissions: this.permissions
permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible]
}
}
// Data broadcasted
toJSONForPublic(streams) {
var stream = this.stream && streams ? streams.find(s => s.id === this.stream) : null
return {
@@ -138,6 +152,11 @@ class User {
this.permissions = user.permissions || this.getDefaultUserPermissions()
// Upload permission added v1.1.13, make sure root user has upload permissions
if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true
// Library restriction permissions added v1.4.14, defaults to all libraries
if (this.permissions.accessAllLibraries === undefined) this.permissions.accessAllLibraries = true
this.librariesAccessible = (user.librariesAccessible || []).map(l => l)
}
update(payload) {
@@ -163,6 +182,18 @@ class User {
}
}
}
// Update accessible libraries
if (payload.librariesAccessible !== undefined) {
if (payload.librariesAccessible.length) {
if (payload.librariesAccessible.join(',') !== this.librariesAccessible.join(',')) {
hasUpdates = true
this.librariesAccessible = [...payload.librariesAccessible]
}
} else if (this.librariesAccessible.length > 0) {
hasUpdates = true
this.librariesAccessible = []
}
}
return hasUpdates
}
@@ -174,13 +205,13 @@ class User {
this.audiobooks[stream.audiobookId].updateFromStream(stream)
}
updateAudiobookProgress(audiobookId, updatePayload) {
updateAudiobookProgress(audiobook, updatePayload) {
if (!this.audiobooks) this.audiobooks = {}
if (!this.audiobooks[audiobookId]) {
this.audiobooks[audiobookId] = new AudiobookProgress()
this.audiobooks[audiobookId].audiobookId = audiobookId
if (!this.audiobooks[audiobook.id]) {
this.audiobooks[audiobook.id] = new AudiobookProgress()
this.audiobooks[audiobook.id].audiobookId = audiobook.id
}
return this.audiobooks[audiobookId].update(updatePayload)
return this.audiobooks[audiobook.id].update(updatePayload)
}
// Returns Boolean If update was made
@@ -209,11 +240,11 @@ class User {
return madeUpdates
}
resetAudiobookProgress(audiobookId) {
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
resetAudiobookProgress(audiobook) {
if (!this.audiobooks || !this.audiobooks[audiobook.id]) {
return false
}
return this.updateAudiobookProgress(audiobookId, {
return this.updateAudiobookProgress(audiobook, {
progress: 0,
currentTime: 0,
isRead: false,
@@ -230,5 +261,11 @@ class User {
delete this.audiobooks[audiobookId]
return true
}
checkCanAccessLibrary(libraryId) {
if (this.permissions.accessAllLibraries) return true
if (!this.librariesAccessible) return false
return this.librariesAccessible.includes(libraryId)
}
}
module.exports = User
+88 -7
View File
@@ -11,9 +11,9 @@ function getDefaultAudioStream(audioStreams) {
return defaultStream
}
async function scan(path) {
async function scan(path, verbose = false) {
Logger.debug(`Scanning path "${path}"`)
var probeData = await prober(path)
var probeData = await prober(path, verbose)
if (!probeData || !probeData.audio_streams || !probeData.audio_streams.length) {
return {
error: 'Invalid audio file'
@@ -62,6 +62,10 @@ async function scan(path) {
}
}
if (verbose && probeData.rawTags) {
finalData.rawTags = probeData.rawTags
}
return finalData
}
module.exports.scan = scan
@@ -85,7 +89,10 @@ function getTrackNumberFromFilename(title, author, series, publishYear, filename
if (publishYear) partbasename = partbasename.replace(publishYear)
// Remove eg. "disc 1" from path
partbasename = partbasename.replace(/ disc \d\d? /i, '')
partbasename = partbasename.replace(/\bdisc \d\d?\b/i, '')
// Remove "cd01" or "cd 01" from path
partbasename = partbasename.replace(/\bcd ?\d\d?\b/i, '')
var numbersinpath = partbasename.match(/\d{1,4}/g)
if (!numbersinpath) return null
@@ -94,6 +101,27 @@ function getTrackNumberFromFilename(title, author, series, publishYear, filename
return number
}
function getCdNumberFromFilename(title, author, series, publishYear, filename) {
var partbasename = Path.basename(filename, Path.extname(filename))
// Remove title, author, series, and publishYear from filename if there
if (title) partbasename = partbasename.replace(title, '')
if (author) partbasename = partbasename.replace(author, '')
if (series) partbasename = partbasename.replace(series, '')
if (publishYear) partbasename = partbasename.replace(publishYear)
var cdNumber = null
var cdmatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
if (cdmatch && cdmatch.length > 2 && cdmatch[2]) {
if (!isNaN(cdmatch[2])) {
cdNumber = Number(cdmatch[2])
}
}
return cdNumber
}
async function scanAudioFiles(audiobook, newAudioFiles) {
if (!newAudioFiles || !newAudioFiles.length) {
Logger.error('[AudioFileScanner] Scan Audio Files no new files', audiobook.title)
@@ -111,7 +139,6 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
var scanData = await scan(audioFile.fullPath)
if (!scanData || scanData.error) {
Logger.error('[AudioFileScanner] Scan failed for', audioFile.path)
// audiobook.invalidAudioFiles.push(parts[i])
continue;
}
@@ -120,6 +147,14 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
var trackNumFromFilename = getTrackNumberFromFilename(book.title, book.author, book.series, book.publishYear, audioFile.filename)
var cdNumFromFilename = getCdNumberFromFilename(book.title, book.author, book.series, book.publishYear, audioFile.filename)
// IF CD num was found but no track num - USE cd num as track num
if (!trackNumFromFilename && cdNumFromFilename) {
trackNumFromFilename = cdNumFromFilename
cdNumFromFilename = null
}
var audioFileObj = {
ino: audioFile.ino,
filename: audioFile.filename,
@@ -128,7 +163,8 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
ext: audioFile.ext,
...scanData,
trackNumFromMeta,
trackNumFromFilename
trackNumFromFilename,
cdNumFromFilename
}
var audioFile = audiobook.addAudioFile(audioFileObj)
@@ -169,6 +205,7 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
}
tracks.sort((a, b) => a.index - b.index)
audiobook.audioFiles.sort((a, b) => {
var aNum = isNumber(a.trackNumFromMeta) ? a.trackNumFromMeta : isNumber(a.trackNumFromFilename) ? a.trackNumFromFilename : 0
var bNum = isNumber(b.trackNumFromMeta) ? b.trackNumFromMeta : isNumber(b.trackNumFromFilename) ? b.trackNumFromFilename : 0
@@ -206,7 +243,27 @@ async function rescanAudioFiles(audiobook) {
// audiobook.invalidAudioFiles.push(parts[i])
continue;
}
var hasUpdates = audioFile.updateMetadata(scanData)
var trackNumFromMeta = getTrackNumberFromMeta(scanData)
var book = audiobook.book || {}
var trackNumFromFilename = getTrackNumberFromFilename(book.title, book.author, book.series, book.publishYear, audioFile.filename)
var cdNumFromFilename = getCdNumberFromFilename(book.title, book.author, book.series, book.publishYear, audioFile.filename)
// IF CD num was found but no track num - USE cd num as track num
if (!trackNumFromFilename && cdNumFromFilename) {
trackNumFromFilename = cdNumFromFilename
cdNumFromFilename = null
}
var metadataUpdate = {
...scanData,
trackNumFromMeta,
trackNumFromFilename,
cdNumFromFilename
}
var hasUpdates = audioFile.updateMetadata(metadataUpdate)
if (hasUpdates) {
// Sync audio track with audio file
var matchingAudioTrack = audiobook.tracks.find(t => t.ino === audioFile.ino)
@@ -233,4 +290,28 @@ async function rescanAudioFiles(audiobook) {
return updates
}
module.exports.rescanAudioFiles = rescanAudioFiles
module.exports.rescanAudioFiles = rescanAudioFiles
async function scanTrackNumbers(audiobook) {
var tracks = audiobook.tracks || []
var scannedTrackNumData = []
for (let i = 0; i < tracks.length; i++) {
var track = tracks[i]
var scanData = await scan(track.fullPath, true)
var trackNumFromMeta = getTrackNumberFromMeta(scanData)
var book = audiobook.book || {}
var trackNumFromFilename = getTrackNumberFromFilename(book.title, book.author, book.series, book.publishYear, track.filename)
Logger.info(`[AudioFileScanner] Track # for "${track.filename}", Metadata: "${trackNumFromMeta}", Filename: "${trackNumFromFilename}", Current: "${track.index}"`)
scannedTrackNumData.push({
filename: track.filename,
currentTrackNum: track.index,
trackNumFromFilename,
trackNumFromMeta,
scanDataTrackNum: scanData.file_tag_track,
rawTags: scanData.rawTags || null
})
}
return scannedTrackNumData
}
module.exports.scanTrackNumbers = scanTrackNumbers
+1 -1
View File
@@ -1,7 +1,7 @@
const globals = {
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'mp4'],
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3']
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz']
}
module.exports = globals
-20
View File
@@ -29,26 +29,6 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
}
module.exports.levenshteinDistance = levenshteinDistance
const cleanString = (str) => {
if (!str) return ''
// Now supporting all utf-8 characters, can remove this method in future
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
// str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
// const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
// const cleanChar = (char) => availableChars.indexOf(char) < 0 ? '?' : char
// var cleaned = ''
// for (let i = 0; i < str.length; i++) {
// cleaned += cleanChar(str[i])
// }
return cleaned.trim()
}
module.exports.cleanString = cleanString
module.exports.isObject = (val) => {
return val !== null && typeof val === 'object'
}
+19 -13
View File
@@ -135,11 +135,13 @@ function parseChapters(chapters) {
})
}
function parseTags(format) {
function parseTags(format, verbose) {
if (!format.tags) {
return {}
}
// Logger.debug('Tags', format.tags)
if (verbose) {
Logger.debug('Tags', format.tags)
}
const tags = {
file_tag_encoder: tryGrabTags(format, 'encoder', 'tsse', 'tss'),
file_tag_encodedby: tryGrabTags(format, 'encoded_by', 'tenc', 'ten'),
@@ -155,6 +157,8 @@ function parseTags(format) {
file_tag_comment: tryGrabTags(format, 'comment', 'comm', 'com'),
file_tag_description: tryGrabTags(format, 'description', 'desc'),
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
file_tag_series: tryGrabTag(format, 'series'),
file_tag_seriespart: tryGrabTag(format, 'series-part'),
// Not sure if these are actually used yet or not
file_tag_creation_time: tryGrabTag(format, 'creation_time'),
@@ -163,10 +167,8 @@ function parseTags(format) {
file_tag_releasetime: tryGrabTags(format, 'releasetime', 'tdrl'),
file_tag_movementname: tryGrabTags(format, 'movementname', 'mvnm'),
file_tag_movement: tryGrabTags(format, 'movement', 'mvin'),
file_tag_series: tryGrabTag(format, 'series'),
file_tag_seriespart: tryGrabTag(format, 'series-part'),
file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'),
file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2')
file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2'),
}
for (const key in tags) {
if (!tags[key]) {
@@ -175,14 +177,15 @@ function parseTags(format) {
}
var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime']
var success = keysToLookOutFor.find(key => !!tags[key])
if (success) {
Logger.debug('Notable!', success)
}
keysToLookOutFor.forEach((key) => {
if (tags[key]) {
Logger.debug(`Notable! ${key} => ${tags[key]}`)
}
})
return tags
}
function parseProbeData(data) {
function parseProbeData(data, verbose = false) {
try {
var { format, streams, chapters } = data
var { format_long_name, duration, size, bit_rate } = format
@@ -191,7 +194,7 @@ function parseProbeData(data) {
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
// Logger.debug('Parsing Data for', Path.basename(format.filename))
var tags = parseTags(format)
var tags = parseTags(format, verbose)
var cleanedData = {
format: format_long_name,
duration: !isNaN(duration) ? Number(duration) : null,
@@ -200,6 +203,9 @@ function parseProbeData(data) {
bit_rate: !isNaN(bit_rate) ? Number(bit_rate) : null,
...tags
}
if (verbose && format.tags) {
cleanedData.rawTags = format.tags
}
const cleaned_streams = streams.map(s => parseMediaStreamInfo(s, streams, cleanedData.bit_rate))
cleanedData.video_stream = cleaned_streams.find(s => s.type === 'video')
@@ -223,14 +229,14 @@ function parseProbeData(data) {
}
}
function probe(filepath) {
function probe(filepath, verbose = false) {
return new Promise((resolve) => {
Ffmpeg.ffprobe(filepath, ['-show_chapters'], (err, raw) => {
if (err) {
console.error(err)
resolve(null)
} else {
resolve(parseProbeData(raw))
resolve(parseProbeData(raw, verbose))
}
})
})
+10 -3
View File
@@ -206,9 +206,16 @@ function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) {
var publishYear = null
// If Title is of format 1999 - Title, then use 1999 as publish year
var publishYearMatch = title.match(/^([0-9]{4}) - (.+)/)
if (publishYearMatch && publishYearMatch.length > 2) {
// OLD regex (not matching parentheses)
// var publishYearMatch = title.match(/^([0-9]{4}) - (.+)/)
// If Title is of format 1999 OR (1999) - Title, then use 1999 as publish year
var publishYearMatch = title.match(/^(\(?[0-9]{4}\)?) - (.+)/)
if (publishYearMatch && publishYearMatch.length > 2 && publishYearMatch[1]) {
// Strip parentheses
if (publishYearMatch[1].startsWith('(') && publishYearMatch[1].endsWith(')')) {
publishYearMatch[1] = publishYearMatch[1].slice(1, -1)
}
if (!isNaN(publishYearMatch[1])) {
publishYear = publishYearMatch[1]
title = publishYearMatch[2]