mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-01 16:30:39 +02:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d38d4dcd62 | |||
| 724aa67cf4 | |||
| 5a0eb6d52b | |||
| bf9cb5ddec | |||
| e073577574 | |||
| 94f47b855b | |||
| 8a684ccdc4 | |||
| e564c80ed2 | |||
| 729654f5b2 | |||
| 6fd3317454 | |||
| 9057afb5ee | |||
| 7933eb369e | |||
| 866ee18016 | |||
| ff92fbb849 | |||
| ad4dad1c29 | |||
| 7c1789a7c2 | |||
| b6ae6d86fa | |||
| 9f66054a72 | |||
| fffa02e7e8 | |||
| 6fbd9dc260 | |||
| c6614aba05 | |||
| ee62385980 | |||
| 806017175d | |||
| 1a3a7c5823 | |||
| 550873ff87 | |||
| 4dd9f779e2 | |||
| 580b961c4a | |||
| 28b1132171 | |||
| 8b31c6555a | |||
| 8ffb4f88c9 | |||
| c5eafdfa8a | |||
| f9bf846b30 | |||
| 335bbac81d | |||
| 874c910e24 | |||
| aca88f73ad | |||
| ed80e15b7d | |||
| 3f13d35241 | |||
| 05be496817 | |||
| 1cca288031 | |||
| d05e9ebfdd | |||
| 92c2c53c09 | |||
| 80f8a784c8 | |||
| de9c0ef034 | |||
| b32b99418a | |||
| 98c1ee01fd | |||
| 97a065030e | |||
| ee1dc92898 | |||
| 74d2987310 | |||
| c20aaf3cb2 | |||
| 994eb2862e | |||
| ff1eeda468 | |||
| 7d9ed75a28 | |||
| 09aed354b3 | |||
| e3425acd75 | |||
| 03e39640be |
+3
-6
@@ -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
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar:horizontal {
|
||||
height: 8px;
|
||||
}
|
||||
/* ::-webkit-scrollbar:horizontal { */
|
||||
/* height: 16px; */
|
||||
/* height: 24px;
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
@@ -21,13 +20,11 @@
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.material-icons.text-icon {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.material-icons.text-base {
|
||||
font-size: 1rem;
|
||||
.material-icons:not(.text-xs):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;
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="w-full relative mb-4">
|
||||
<div class="absolute left-2 top-0 bottom-0 h-full flex items-center">
|
||||
<p ref="currentTimestamp" class="font-mono text-sm">00:00:00</p>
|
||||
</div>
|
||||
<div class="absolute right-2 top-0 bottom-0 h-full flex items-center">
|
||||
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
|
||||
<div class="w-full -mt-4">
|
||||
<div class="w-full relative mb-2">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-red flex items-end pointer-events-none">
|
||||
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-20 top-0 bottom-0">
|
||||
<div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<div v-if="chapters.length" class="absolute right-20 top-0 bottom-0 h-full flex items-end">
|
||||
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-32 top-0 bottom-0">
|
||||
<controls-volume-control v-model="volume" @input="updateVolume" />
|
||||
<div class="absolute top-0 bottom-0 h-full flex items-end" :class="chapters.length ? ' right-32' : 'right-20'">
|
||||
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showBookmarks">
|
||||
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-0 bottom-0 h-full flex items-end" :class="chapters.length ? ' right-44' : 'right-32'">
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="updateVolume" />
|
||||
</div>
|
||||
<div class="flex my-2">
|
||||
<div class="flex-grow" />
|
||||
|
||||
<div class="flex pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
|
||||
<span class="material-icons text-3xl">first_page</span>
|
||||
@@ -85,13 +89,17 @@ export default {
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
bookmarks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hlsInstance: null,
|
||||
staleHlsInstance: null,
|
||||
volume: 0.5,
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
trackWidth: 0,
|
||||
isPaused: true,
|
||||
@@ -116,6 +124,17 @@ export default {
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
},
|
||||
timeRemaining() {
|
||||
if (!this.audioEl) return 0
|
||||
return this.totalDuration - this.currentTime
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||
},
|
||||
progressPercent() {
|
||||
if (!this.totalDuration) return 0
|
||||
return Math.round((100 * this.currentTime) / this.totalDuration)
|
||||
},
|
||||
chapterTicks() {
|
||||
return this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.totalDuration
|
||||
@@ -127,6 +146,9 @@ export default {
|
||||
},
|
||||
currentChapter() {
|
||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -134,6 +156,11 @@ export default {
|
||||
this.seek(chapter.start)
|
||||
this.showChaptersModal = false
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
if (bookmark) {
|
||||
this.seek(bookmark.time)
|
||||
}
|
||||
},
|
||||
seek(time) {
|
||||
if (this.loading) {
|
||||
return
|
||||
@@ -232,6 +259,7 @@ export default {
|
||||
},
|
||||
restart() {
|
||||
this.seek(0)
|
||||
this.$nextTick(this.sendStreamUpdate)
|
||||
},
|
||||
backward10() {
|
||||
var newTime = this.audioEl.currentTime - 10
|
||||
@@ -346,7 +374,7 @@ export default {
|
||||
return
|
||||
}
|
||||
var lastbuff = this.getLastBufferedTime()
|
||||
this.sendStreamUpdate()
|
||||
|
||||
var bufferlen = (lastbuff / this.audioEl.duration) * this.trackWidth
|
||||
bufferlen = Math.round(bufferlen)
|
||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||
@@ -373,6 +401,13 @@ export default {
|
||||
|
||||
this.updateTimestamp()
|
||||
|
||||
// Send update to server when currentTime > 0
|
||||
// this prevents errors when seeking to position not yet transcoded
|
||||
// seeking to position not yet transcoded will cause audio element to set currentTime to 0
|
||||
if (this.audioEl.currentTime) {
|
||||
this.sendStreamUpdate()
|
||||
}
|
||||
|
||||
this.currentTime = this.audioEl.currentTime
|
||||
|
||||
var perc = this.audioEl.currentTime / this.audioEl.duration
|
||||
@@ -399,6 +434,7 @@ export default {
|
||||
},
|
||||
audioLoadedData() {
|
||||
this.totalDuration = this.audioEl.duration
|
||||
this.$emit('loaded', this.totalDuration)
|
||||
},
|
||||
set(url, currentTime, playOnLoad = false) {
|
||||
if (this.hlsInstance) {
|
||||
@@ -451,7 +487,11 @@ export default {
|
||||
})
|
||||
},
|
||||
showChapters() {
|
||||
this.showChaptersModal = true
|
||||
if (!this.chapters.length) return
|
||||
this.showChaptersModal = !this.showChaptersModal
|
||||
},
|
||||
showBookmarks() {
|
||||
this.$emit('showBookmarks', this.currentTime)
|
||||
},
|
||||
play() {
|
||||
if (!this.$refs.audio) {
|
||||
@@ -486,6 +526,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 +539,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 === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPauseClick()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.forward10()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.backward10()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.volumeUp()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.volumeDown()
|
||||
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
|
||||
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
|
||||
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.CLOSE) 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>
|
||||
|
||||
@@ -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 || '/'
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="outer-container">
|
||||
<!-- absolute positioned container -->
|
||||
<div class="inner-container">
|
||||
<div class="relative h-10">
|
||||
<div class="table-header" id="headerdiv">
|
||||
<table id="headertable" width="100%" cellpadding="0" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header-cell min-w-12 max-w-12"></th>
|
||||
<th class="header-cell min-w-6 max-w-6"></th>
|
||||
<th class="header-cell min-w-64 max-w-64 px-2">Title</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Author</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Series</th>
|
||||
<th class="header-cell min-w-24 max-w-24 px-2">Year</th>
|
||||
<th class="header-cell min-w-80 max-w-80 px-2">Description</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Narrator</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Genres</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Tags</th>
|
||||
<th class="header-cell min-w-24 max-w-24 px-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
<div class="absolute top-0 left-0 w-full h-full pointer-events-none" :class="isScrollable ? 'header-shadow' : ''" />
|
||||
</div>
|
||||
|
||||
<div ref="tableBody" class="table-body" onscroll="document.getElementById('headerdiv').scrollLeft = this.scrollLeft;" @scroll="tableScrolled">
|
||||
<table id="bodytable" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<template v-for="book in books">
|
||||
<app-book-list-row :key="book.id" :book="book" @edit="editBook" />
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
books: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
checkIsScrolled() {
|
||||
if (!this.$refs.tableBody) return
|
||||
this.isScrollable = this.$refs.tableBody.scrollTop > 0
|
||||
},
|
||||
tableScrolled() {
|
||||
this.checkIsScrolled()
|
||||
},
|
||||
editBook(book) {
|
||||
var bookIds = this.books.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', book)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.checkIsScrolled()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.outer-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: visible;
|
||||
height: calc(100% - 50px);
|
||||
width: calc(100% - 10px);
|
||||
margin: 10px;
|
||||
}
|
||||
.inner-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.table-header {
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
.header-shadow {
|
||||
box-shadow: 3px 8px 3px #11111155;
|
||||
}
|
||||
.table-body {
|
||||
float: left;
|
||||
height: 100%;
|
||||
width: inherit;
|
||||
overflow-y: scroll;
|
||||
padding-right: 0px;
|
||||
}
|
||||
.header-cell {
|
||||
background-color: #22222288;
|
||||
padding: 0px 4px;
|
||||
text-align: left;
|
||||
height: 40px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: semi-bold;
|
||||
}
|
||||
.body-cell {
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.book-row {
|
||||
background-color: #22222288;
|
||||
}
|
||||
.book-row:nth-child(odd) {
|
||||
background-color: #333;
|
||||
}
|
||||
.book-row.selected {
|
||||
background-color: rgba(0, 255, 0, 0.05);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<tr class="book-row" :class="selected ? 'selected' : ''">
|
||||
<td class="body-cell min-w-12 max-w-12">
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500 w-4 h-4" @click="selectBtnClick">
|
||||
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none w-2.5 h-2.5" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="body-cell min-w-6 max-w-6">
|
||||
<cards-hover-book-cover :audiobook="book" />
|
||||
</td>
|
||||
<td class="body-cell min-w-64 max-w-64 px-2">
|
||||
<nuxt-link :to="`/audiobook/${book.id}`" class="hover:underline">
|
||||
<p class="truncate">
|
||||
{{ book.book.title }}<span v-if="book.book.subtitle">: {{ book.book.subtitle }}</span>
|
||||
</p>
|
||||
</nuxt-link>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ book.book.authorFL }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ seriesText }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
||||
<p class="truncate">{{ book.book.publishYear }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-80 max-w-80 px-2">
|
||||
<p class="truncate">{{ book.book.description }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ book.book.narrator }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ genresText }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ tagsText }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
||||
<div class="flex">
|
||||
<span v-if="userCanUpdate" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="editClick">edit</span>
|
||||
<span v-if="showPlayButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-2xl mx-1" @click="startStream">play_arrow</span>
|
||||
<span v-if="showReadButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="openEbook">auto_stories</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
book: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
userAudiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
audiobookId() {
|
||||
return this.book.id
|
||||
},
|
||||
isSelectionMode() {
|
||||
return !!this.selectedAudiobooks.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
},
|
||||
selected: {
|
||||
get() {
|
||||
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
|
||||
},
|
||||
set(val) {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
|
||||
}
|
||||
},
|
||||
processingBatch() {
|
||||
return this.$store.state.processingBatch
|
||||
},
|
||||
bookObj() {
|
||||
return this.book.book || {}
|
||||
},
|
||||
series() {
|
||||
return this.bookObj.series || null
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.bookObj.volumeNumber || null
|
||||
},
|
||||
seriesText() {
|
||||
if (!this.series) return ''
|
||||
if (!this.volumeNumber) return this.series
|
||||
return `${this.series} #${this.volumeNumber}`
|
||||
},
|
||||
genresText() {
|
||||
if (!this.bookObj.genres) return ''
|
||||
return this.bookObj.genres.join(', ')
|
||||
},
|
||||
tagsText() {
|
||||
return (this.book.tags || []).join(', ')
|
||||
},
|
||||
isMissing() {
|
||||
return this.book.isMissing
|
||||
},
|
||||
isIncomplete() {
|
||||
return this.book.isIncomplete
|
||||
},
|
||||
numEbooks() {
|
||||
return this.book.numEbooks
|
||||
},
|
||||
numTracks() {
|
||||
return this.book.numTracks
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
||||
},
|
||||
showReadButton() {
|
||||
return this.showExperimentalFeatures && this.numEbooks
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isMissing && !this.isIncomplete && this.numTracks && !this.isStreaming
|
||||
},
|
||||
userIsRead() {
|
||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectBtnClick() {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
|
||||
},
|
||||
openEbook() {
|
||||
this.$store.commit('showEReader', this.book)
|
||||
},
|
||||
downloadClick() {
|
||||
this.$store.commit('showEditModalOnTab', { audiobook: this.book, tab: 'download' })
|
||||
},
|
||||
toggleRead() {
|
||||
var updatePayload = {
|
||||
isRead: !this.userIsRead
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
this.$axios
|
||||
.$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
})
|
||||
},
|
||||
startStream() {
|
||||
this.$store.commit('setStreamAudiobook', this.book)
|
||||
this.$root.socket.emit('open_stream', this.book.id)
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.book)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,47 +1,56 @@
|
||||
<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-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>
|
||||
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
|
||||
<div class="bookshelf overflow-hidden relative block max-h-full">
|
||||
<div ref="wrapper" class="h-full w-full relative" :class="isGridMode ? 'overflow-y-scroll' : 'overflow-hidden'">
|
||||
<!-- Cover size widget -->
|
||||
<div v-show="!isSelectionMode && isGridMode" 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>
|
||||
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
</div>
|
||||
</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 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>
|
||||
<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">
|
||||
<div class="flex justify-center items-center">
|
||||
<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" @edit="editBook" />
|
||||
<div v-else class="w-full">
|
||||
<template v-if="viewMode === 'grid'">
|
||||
<div class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<div :key="index" class="w-full bookshelfRow relative">
|
||||
<div class="flex justify-center items-center">
|
||||
<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" @edit="editBook" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<app-book-list :books="entities" />
|
||||
</template>
|
||||
<div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
|
||||
<div v-if="page === 'search'" class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
|
||||
<div v-else class="py-4">No {{ showGroups ? 'Series' : 'Audiobooks' }}</div>
|
||||
<ui-btn v-if="!showGroups && (filterBy !== 'all' || keywordFilter)" @click="clearFilter">Clear Filter</ui-btn>
|
||||
<ui-btn v-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
|
||||
<div v-if="page === 'search'" class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
|
||||
<div v-else class="py-4">No {{ showGroups ? 'Series' : 'Audiobooks' }}</div>
|
||||
<ui-btn v-if="!showGroups && (filterBy !== 'all' || keywordFilter)" @click="clearFilter">Clear Filter</ui-btn>
|
||||
<ui-btn v-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,7 +65,8 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
searchQuery: String
|
||||
searchQuery: String,
|
||||
viewMode: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -89,10 +99,15 @@ export default {
|
||||
'$route.query.filter'() {
|
||||
if (this.$route.query.filter && this.$route.query.filter !== this.filterBy) {
|
||||
this.$store.dispatch('user/updateUserSettings', { filterBy: this.$route.query.filter })
|
||||
} else if (!this.$route.query.filter && this.filterBy) {
|
||||
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isGridMode() {
|
||||
return this.viewMode === 'grid'
|
||||
},
|
||||
keywordFilter() {
|
||||
return this.$store.state.audiobooks.keywordFilter
|
||||
},
|
||||
@@ -106,7 +121,8 @@ export default {
|
||||
return this.bookCoverWidth / 120
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return this.availableSizes[this.selectedSizeIndex]
|
||||
var coverWidth = this.availableSizes[this.selectedSizeIndex]
|
||||
return coverWidth
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.bookCoverWidth / 120
|
||||
@@ -345,8 +361,9 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#bookshelf {
|
||||
.bookshelf {
|
||||
height: calc(100% - 40px);
|
||||
width: calc(100vw - 80px);
|
||||
}
|
||||
.bookshelfRow {
|
||||
background-image: url(/wood_panels.jpg);
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" clearable class="text-xs w-40" />
|
||||
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
|
||||
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
|
||||
<div class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md">
|
||||
<div class="h-full px-2 text-white flex items-center rounded-l-md hover:bg-primary hover:bg-opacity-75 cursor-pointer" :class="isGridMode ? 'bg-primary' : 'text-opacity-70'" @click="$emit('update:viewMode', 'grid')">
|
||||
<span class="material-icons" style="font-size: 1.4rem">view_module</span>
|
||||
</div>
|
||||
<div class="h-full px-2 text-white flex items-center rounded-r-md hover:bg-primary hover:bg-opacity-75 cursor-pointer" :class="!isGridMode ? 'bg-primary' : 'text-opacity-70'" @click="$emit('update:viewMode', 'list')">
|
||||
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="!isHome">
|
||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
@@ -44,7 +52,8 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
searchQuery: String
|
||||
searchQuery: String,
|
||||
viewMode: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -53,6 +62,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isGridMode() {
|
||||
return this.viewMode === 'grid'
|
||||
},
|
||||
showSortFilters() {
|
||||
return this.page === ''
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -1,361 +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="ebookType === 'epub'" 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-if="ebookType === 'mobi'" 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>
|
||||
<!-- PDF -->
|
||||
<div v-else-if="ebookType === 'pdf'" class="h-full flex items-center">
|
||||
<app-pdf-reader :src="ebookUrl" />
|
||||
</div>
|
||||
<!-- COMIC -->
|
||||
<div v-else-if="ebookType === 'comic'" class="h-full flex items-center">
|
||||
<app-comic-reader :src="ebookUrl" @close="show = false" />
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-2 left-2">{{ ebookType }}</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,
|
||||
ebookType: '',
|
||||
ebookUrl: ''
|
||||
}
|
||||
},
|
||||
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')
|
||||
},
|
||||
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}`
|
||||
},
|
||||
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() {
|
||||
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)
|
||||
}
|
||||
},
|
||||
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.ebookUrl, {
|
||||
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() {
|
||||
this.registerListeners()
|
||||
// var book = ePub(this.url, {
|
||||
// requestHeaders: {
|
||||
// Authorization: `Bearer ${this.userToken}`
|
||||
// }
|
||||
// })
|
||||
var book = ePub(this.ebookUrl)
|
||||
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() {
|
||||
if (this.ebookType === 'epub') {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.show) this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* @import url(@/assets/calibre/basic.css); */
|
||||
.ebook-viewer {
|
||||
height: calc(100% - 96px);
|
||||
}
|
||||
</style>
|
||||
@@ -11,14 +11,14 @@
|
||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' && !homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Library</p>
|
||||
|
||||
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
@@ -31,6 +31,16 @@
|
||||
<div v-show="paramId === 'series'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
||||
<span class="material-icons text-2xl">warning</span>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Issues</p>
|
||||
|
||||
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
|
||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<!-- <nuxt-link to="/library/collections" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
@@ -80,6 +90,19 @@ export default {
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
libraryBookshelfPage() {
|
||||
return this.$route.name === 'library-library-bookshelf-id'
|
||||
},
|
||||
showLibrary() {
|
||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||
},
|
||||
showingIssues() {
|
||||
if (!this.$route.query) return false
|
||||
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
|
||||
},
|
||||
numIssues() {
|
||||
return this.$store.getters['audiobooks/getAudiobooksWithIssues'].length
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
<template>
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4">
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary px-4 pb-4 pt-2">
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute -top-16 left-4 cursor-pointer">
|
||||
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
|
||||
</nuxt-link>
|
||||
<div class="flex items-center pl-24">
|
||||
<div>
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer">
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-lg">
|
||||
{{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
|
||||
</nuxt-link>
|
||||
<p class="text-gray-400 text-sm hover:underline cursor-pointer" @click="filterByAuthor">by {{ author }}</p>
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<p class="text-base hover:underline cursor-pointer pl-2" @click="filterByAuthor">{{ author }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-xs">schedule</span>
|
||||
<p class="font-mono text-sm pl-2 pb-px">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<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" :bookmarks="bookmarks" @close="cancelStream" @updateTime="updateTime" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @hook:mounted="audioPlayerMounted" />
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" @create="createBookmark" @update="updateBookmark" @delete="deleteBookmark" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -24,7 +34,11 @@ export default {
|
||||
return {
|
||||
audioPlayerReady: false,
|
||||
lastServerUpdateSentSeconds: 0,
|
||||
stream: null
|
||||
stream: null,
|
||||
totalDuration: 0,
|
||||
showBookmarksModal: false,
|
||||
bookmarkCurrentTime: 0,
|
||||
bookmarkAudiobookId: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -35,6 +49,14 @@ export default {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
userAudiobook() {
|
||||
if (!this.audiobookId) return
|
||||
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
|
||||
},
|
||||
bookmarks() {
|
||||
if (!this.userAudiobook) return []
|
||||
return this.userAudiobook.bookmarks || []
|
||||
},
|
||||
isLoading() {
|
||||
if (!this.streamAudiobook) return false
|
||||
if (this.stream) {
|
||||
@@ -46,6 +68,9 @@ export default {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
audiobookId() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.id : null
|
||||
},
|
||||
book() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
|
||||
},
|
||||
@@ -66,9 +91,49 @@ export default {
|
||||
},
|
||||
libraryId() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showBookmarks(currentTime) {
|
||||
this.bookmarkAudiobookId = this.audiobookId
|
||||
this.bookmarkCurrentTime = currentTime
|
||||
this.showBookmarksModal = true
|
||||
},
|
||||
// bookmarkCreated(time) {
|
||||
// if (time === this.bookmarkTimeProcessing) {
|
||||
// this.bookmarkTimeProcessing = 0
|
||||
// this.$toast.success(`${this.$secondsToTimestamp(time)} Bookmarked`)
|
||||
// }
|
||||
// },
|
||||
createBookmark(bookmark) {
|
||||
// this.bookmarkTimeProcessing = bookmark.time
|
||||
this.$root.socket.emit('create_bookmark', bookmark)
|
||||
this.showBookmarksModal = false
|
||||
},
|
||||
// bookmarkUpdated(time) {
|
||||
// if (time === this.bookmarkTimeProcessing) {
|
||||
// this.bookmarkTimeProcessing = 0
|
||||
// this.$toast.success(`Bookmark @${this.$secondsToTimestamp(time)} Updated`)
|
||||
// }
|
||||
// },
|
||||
updateBookmark(bookmark) {
|
||||
// this.bookmarkTimeProcessing = bookmark.time
|
||||
this.$root.socket.emit('update_bookmark', bookmark)
|
||||
this.showBookmarksModal = false
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.selectBookmark(bookmark)
|
||||
}
|
||||
this.showBookmarksModal = false
|
||||
},
|
||||
deleteBookmark(bookmark) {
|
||||
this.$root.socket.emit('delete_bookmark', bookmark)
|
||||
this.showBookmarksModal = false
|
||||
},
|
||||
filterByAuthor() {
|
||||
if (this.$route.name !== 'index') {
|
||||
this.$router.push(`/library/${this.libraryId || this.$store.state.libraries.currentLibraryId}/bookshelf`)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
||||
|
||||
<p v-if="matchKey !== 'author'" class="text-xs text-gray-200 truncate">by {{ author }}</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" />
|
||||
@@ -39,8 +39,8 @@ export default {
|
||||
subtitle() {
|
||||
return this.book ? this.book.subtitle : ''
|
||||
},
|
||||
author() {
|
||||
return this.book ? this.book.author : 'Unknown'
|
||||
authorFL() {
|
||||
return this.book ? this.book.authorFL : 'Unknown'
|
||||
},
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
@@ -62,7 +62,8 @@ export default {
|
||||
html += lastPart
|
||||
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'author') return `by ${html}`
|
||||
if (this.matchKey === 'authorFL') return `by ${html}`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||
return `${html}`
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="absolute -bottom-4 left-0 triangle-right" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @click.stop>
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `${paddingY}px ${paddingX}px` }" @click.stop>
|
||||
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
|
||||
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }" @click="clickCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
|
||||
@@ -77,6 +77,10 @@ export default {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
paddingY: {
|
||||
type: Number,
|
||||
default: 16
|
||||
},
|
||||
showVolumeNumber: Boolean
|
||||
},
|
||||
data() {
|
||||
@@ -164,11 +168,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
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<div class="flex items-center">
|
||||
<h1>{{ book.title }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<p>{{ book.year || book.first_publish_date }}</p>
|
||||
<p>{{ book.publishYear }}</p>
|
||||
</div>
|
||||
<p class="text-gray-400">{{ book.author }}</p>
|
||||
<div class="w-full max-h-12 overflow-hidden">
|
||||
<p class="text-gray-500 text-xs" v-html="book.description"></p>
|
||||
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,7 +53,7 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : null
|
||||
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,11 @@
|
||||
<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">
|
||||
<div class="rounded-sm h-full relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||
<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" />
|
||||
<cards-group-cover ref="groupcover" :name="groupName" :group-to="groupTo" :book-items="bookItems" :width="height" :height="height" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<div v-if="hasValidCovers && !showExperimentalFeatures" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||
</div>
|
||||
|
||||
@@ -100,9 +100,20 @@ export default {
|
||||
hasValidCovers() {
|
||||
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
|
||||
return !!validCovers.length
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseoverCard() {
|
||||
this.isHovering = true
|
||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(true)
|
||||
},
|
||||
mouseleaveCard() {
|
||||
this.isHovering = false
|
||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(false)
|
||||
},
|
||||
clickCard() {
|
||||
this.$emit('click', this.group)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative">
|
||||
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative" @mouseover="mouseoverCover" @mouseleave="mouseleaveCover">
|
||||
<div v-if="noValidCovers" class="absolute top-0 left-0 w-full h-full flex items-center justify-center box-shadow-book" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
@@ -15,12 +15,21 @@ export default {
|
||||
default: () => []
|
||||
},
|
||||
width: Number,
|
||||
height: Number
|
||||
height: Number,
|
||||
groupTo: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
noValidCovers: false,
|
||||
coverDiv: null
|
||||
coverDiv: null,
|
||||
isHovering: false,
|
||||
coverWrapperEl: null,
|
||||
coverImageEls: [],
|
||||
coverWidth: 0,
|
||||
offsetIncrement: 0,
|
||||
isFannedOut: false,
|
||||
isDetached: false,
|
||||
isAttaching: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -37,13 +46,152 @@ export default {
|
||||
computed: {
|
||||
sizeMultiplier() {
|
||||
return this.width / 192
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseoverCover() {
|
||||
if (this.showExperimentalFeatures) this.setHover(true)
|
||||
},
|
||||
mouseleaveCover() {
|
||||
if (this.showExperimentalFeatures) this.setHover(false)
|
||||
},
|
||||
detchCoverWrapper() {
|
||||
if (!this.coverWrapperEl || !this.$refs.wrapper || this.isDetached) return
|
||||
|
||||
this.coverWrapperEl.remove()
|
||||
|
||||
this.isDetached = true
|
||||
document.body.appendChild(this.coverWrapperEl)
|
||||
this.coverWrapperEl.addEventListener('mouseleave', this.mouseleaveCover)
|
||||
|
||||
this.coverWrapperEl.style.position = 'absolute'
|
||||
this.coverWrapperEl.style.zIndex = 40
|
||||
|
||||
this.updatePosition()
|
||||
},
|
||||
attachCoverWrapper() {
|
||||
if (!this.coverWrapperEl || !this.$refs.wrapper || !this.isDetached) return
|
||||
|
||||
this.coverWrapperEl.remove()
|
||||
this.coverWrapperEl.style.position = 'relative'
|
||||
this.coverWrapperEl.style.left = 'unset'
|
||||
this.coverWrapperEl.style.top = 'unset'
|
||||
this.coverWrapperEl.style.width = this.$refs.wrapper.clientWidth + 'px'
|
||||
|
||||
this.$refs.wrapper.appendChild(this.coverWrapperEl)
|
||||
console.log('Appended to wrapper', this.$refs.wrapper.children)
|
||||
this.isDetached = false
|
||||
},
|
||||
updatePosition() {
|
||||
var rect = this.$refs.wrapper.getBoundingClientRect()
|
||||
this.coverWrapperEl.style.top = rect.top + window.scrollY + 'px'
|
||||
|
||||
this.coverWrapperEl.style.left = rect.left + window.scrollX + 4 + 'px'
|
||||
|
||||
this.coverWrapperEl.style.height = rect.height + 'px'
|
||||
this.coverWrapperEl.style.width = rect.width + 'px'
|
||||
},
|
||||
setHover(val) {
|
||||
if (this.isAttaching) return
|
||||
if (val && !this.isHovering) {
|
||||
this.detchCoverWrapper()
|
||||
this.fanOutCovers()
|
||||
} else if (!val && this.isHovering) {
|
||||
this.isAttaching = true
|
||||
this.reverseFan()
|
||||
setTimeout(() => {
|
||||
this.attachCoverWrapper()
|
||||
this.isAttaching = false
|
||||
}, 100)
|
||||
}
|
||||
this.isHovering = val
|
||||
},
|
||||
fanOutCovers() {
|
||||
if (this.coverImageEls.length < 2 || this.isFannedOut) return
|
||||
this.isFannedOut = true
|
||||
var fanCoverWidth = this.coverWidth * 0.75
|
||||
var maximumWidth = window.innerWidth - 80
|
||||
|
||||
var totalFanWidth = (this.coverImageEls.length + 1) * fanCoverWidth
|
||||
|
||||
// If Fan width is too large, set new fan cover width
|
||||
if (totalFanWidth > maximumWidth) {
|
||||
fanCoverWidth = maximumWidth / (this.coverImageEls.length + 1)
|
||||
}
|
||||
|
||||
var fanWidth = (this.coverImageEls.length - 1) * fanCoverWidth
|
||||
var offsetLeft = (-1 * fanWidth) / 2
|
||||
|
||||
var rect = this.$refs.wrapper.getBoundingClientRect()
|
||||
|
||||
// If fan is going off page left or right, make adjustment
|
||||
var leftEdge = rect.left + offsetLeft
|
||||
var rightEdge = rect.left + rect.width - offsetLeft
|
||||
if (leftEdge < 0) {
|
||||
offsetLeft += leftEdge * -1
|
||||
}
|
||||
if (rightEdge + 80 > window.innerWidth) {
|
||||
var difference = rightEdge + 80 - window.innerWidth
|
||||
offsetLeft -= difference / 2
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.coverImageEls.length; i++) {
|
||||
var coverEl = this.coverImageEls[i]
|
||||
|
||||
// Series name card pop out further
|
||||
if (i === this.coverImageEls.length - 1) {
|
||||
offsetLeft += fanCoverWidth * 0.25
|
||||
}
|
||||
|
||||
coverEl.style.transform = `translateX(${offsetLeft}px)`
|
||||
offsetLeft += fanCoverWidth
|
||||
|
||||
var coverOverlay = document.createElement('div')
|
||||
coverOverlay.className = 'absolute top-0 left-0 w-full h-full hover:bg-black hover:bg-opacity-40 text-white text-opacity-0 hover:text-opacity-100 flex items-center justify-center cursor-pointer'
|
||||
|
||||
if (coverEl.dataset.volumeNumber) {
|
||||
var pEl = document.createElement('p')
|
||||
pEl.className = 'text-2xl'
|
||||
pEl.textContent = `#${coverEl.dataset.volumeNumber}`
|
||||
coverOverlay.appendChild(pEl)
|
||||
}
|
||||
if (coverEl.dataset.audiobookId) {
|
||||
let audiobookId = coverEl.dataset.audiobookId
|
||||
coverOverlay.addEventListener('click', (e) => {
|
||||
this.$router.push(`/audiobook/${audiobookId}`)
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
})
|
||||
} else {
|
||||
// Is Series
|
||||
coverOverlay.addEventListener('click', (e) => {
|
||||
this.$router.push(this.groupTo)
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
})
|
||||
}
|
||||
|
||||
coverEl.appendChild(coverOverlay)
|
||||
}
|
||||
},
|
||||
reverseFan() {
|
||||
if (this.coverImageEls.length < 2 || !this.isFannedOut) return
|
||||
this.isFannedOut = false
|
||||
for (let i = 0; i < this.coverImageEls.length; i++) {
|
||||
var coverEl = this.coverImageEls[i]
|
||||
coverEl.style.transform = 'translateX(0px)'
|
||||
if (coverEl.lastChild) coverEl.lastChild.remove() // Remove cover overlay
|
||||
}
|
||||
},
|
||||
getCoverUrl(book) {
|
||||
return this.$store.getters['audiobooks/getBookCoverSrc'](book, '')
|
||||
},
|
||||
async buildCoverImg(src, bgCoverWidth, offsetLeft, forceCoverBg = false) {
|
||||
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
|
||||
var src = coverData.coverUrl
|
||||
|
||||
var showCoverBg =
|
||||
forceCoverBg ||
|
||||
(await new Promise((resolve) => {
|
||||
@@ -72,8 +220,11 @@ export default {
|
||||
imgdiv.style.height = this.height + 'px'
|
||||
imgdiv.style.width = bgCoverWidth + 'px'
|
||||
imgdiv.style.left = offsetLeft + 'px'
|
||||
imgdiv.className = 'absolute top-0 box-shadow-book'
|
||||
imgdiv.style.boxShadow = '-4px 0px 4px #11111166'
|
||||
imgdiv.style.zIndex = zIndex
|
||||
imgdiv.dataset.audiobookId = coverData.id
|
||||
imgdiv.dataset.volumeNumber = coverData.volumeNumber || ''
|
||||
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform'
|
||||
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
|
||||
// imgdiv.style.transform = 'skew(0deg, 15deg)'
|
||||
|
||||
if (showCoverBg) {
|
||||
@@ -100,12 +251,36 @@ export default {
|
||||
imgdiv.appendChild(img)
|
||||
return imgdiv
|
||||
},
|
||||
createSeriesNameCover(offsetLeft) {
|
||||
var imgdiv = document.createElement('div')
|
||||
imgdiv.style.height = this.height + 'px'
|
||||
imgdiv.style.width = this.height / 1.6 + 'px'
|
||||
imgdiv.style.left = offsetLeft + 'px'
|
||||
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform flex items-center justify-center'
|
||||
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
|
||||
imgdiv.style.backgroundColor = '#111'
|
||||
|
||||
var innerP = document.createElement('p')
|
||||
innerP.textContent = this.name
|
||||
innerP.className = 'text-sm font-book text-white'
|
||||
imgdiv.appendChild(innerP)
|
||||
|
||||
return imgdiv
|
||||
},
|
||||
async init() {
|
||||
if (this.coverDiv) {
|
||||
this.coverDiv.remove()
|
||||
this.coverDiv = null
|
||||
}
|
||||
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem)).filter((b) => b !== '')
|
||||
var validCovers = this.bookItems
|
||||
.map((bookItem) => {
|
||||
return {
|
||||
id: bookItem.id,
|
||||
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
|
||||
coverUrl: this.getCoverUrl(bookItem)
|
||||
}
|
||||
})
|
||||
.filter((b) => b.coverUrl !== '')
|
||||
if (!validCovers.length) {
|
||||
this.noValidCovers = true
|
||||
return
|
||||
@@ -118,22 +293,40 @@ export default {
|
||||
coverWidth = this.height / 1.6
|
||||
widthPer = (this.width - coverWidth) / (validCovers.length - 1)
|
||||
}
|
||||
this.coverWidth = coverWidth
|
||||
this.offsetIncrement = widthPer
|
||||
|
||||
var outerdiv = document.createElement('div')
|
||||
this.coverWrapperEl = outerdiv
|
||||
outerdiv.className = 'w-full h-full relative'
|
||||
|
||||
var coverImageEls = []
|
||||
var offsetLeft = 0
|
||||
for (let i = 0; i < validCovers.length; i++) {
|
||||
var offsetLeft = widthPer * i
|
||||
var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, validCovers.length === 1)
|
||||
offsetLeft = widthPer * i
|
||||
var zIndex = validCovers.length - i
|
||||
var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, zIndex, validCovers.length === 1)
|
||||
outerdiv.appendChild(img)
|
||||
coverImageEls.push(img)
|
||||
}
|
||||
|
||||
if (this.showExperimentalFeatures) {
|
||||
var seriesNameCover = this.createSeriesNameCover(offsetLeft)
|
||||
outerdiv.appendChild(seriesNameCover)
|
||||
coverImageEls.push(seriesNameCover)
|
||||
}
|
||||
|
||||
this.coverImageEls = coverImageEls
|
||||
|
||||
if (this.$refs.wrapper) {
|
||||
this.coverDiv = outerdiv
|
||||
this.$refs.wrapper.appendChild(outerdiv)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
if (this.coverWrapperEl) this.coverWrapperEl.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div ref="container" @mouseover="mouseover" @mouseleave="mouseleave" class="relative">
|
||||
<cards-book-cover :width="24" :audiobook="audiobook" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
fullCoverUrl() {
|
||||
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.audiobook.book.cover
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -101,6 +101,11 @@ export default {
|
||||
text: 'Progress',
|
||||
value: 'progress',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Issues',
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="bookmarks" :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">
|
||||
<div class="w-full h-full px-6 py-6" v-show="showBookmarkTitleInput">
|
||||
<div class="flex mb-4 items-center">
|
||||
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click="showBookmarkTitleInput = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xl font-mono">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
</div>
|
||||
<form @submit.prevent="submitBookmark">
|
||||
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
|
||||
<div class="flex justify-end mt-6">
|
||||
<ui-btn color="success" class="w-1/2" type="submit">{{ selectedBookmark ? 'Update' : 'Create' }} Bookmark</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="w-full h-full" v-show="!showBookmarkTitleInput">
|
||||
<template v-for="bookmark in bookmarks">
|
||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @edit="editBookmark" @delete="deleteBookmark" />
|
||||
</template>
|
||||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">No Bookmarks</p>
|
||||
</div>
|
||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-white border-opacity-10 bg-blue-500 bg-opacity-20 cursor-pointer text-white text-opacity-80 hover:bg-opacity-40 hover:text-opacity-100" @click="createBookmark">
|
||||
<span class="material-icons">add</span>
|
||||
<p class="text-base pl-2">Create Bookmark</p>
|
||||
<p class="text-sm font-mono">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
bookmarks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
audiobookId: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedBookmark: null,
|
||||
showBookmarkTitleInput: false,
|
||||
newBookmarkTitle: ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.showBookmarkTitleInput = false
|
||||
this.newBookmarkTitle = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
canCreateBookmark() {
|
||||
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editBookmark(bm) {
|
||||
this.selectedBookmark = bm
|
||||
this.newBookmarkTitle = bm.title
|
||||
this.showBookmarkTitleInput = true
|
||||
},
|
||||
deleteBookmark(bm) {
|
||||
var bookmark = { ...bm, audiobookId: this.audiobookId }
|
||||
this.$emit('delete', bookmark)
|
||||
},
|
||||
clickBookmark(bm) {
|
||||
this.$emit('select', bm)
|
||||
},
|
||||
createBookmark() {
|
||||
this.selectedBookmark = null
|
||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
this.showBookmarkTitleInput = true
|
||||
},
|
||||
submitBookmark() {
|
||||
if (this.selectedBookmark) {
|
||||
if (this.selectedBookmark.title !== this.newBookmarkTitle) {
|
||||
var bookmark = { ...this.selectedBookmark }
|
||||
bookmark.audiobookId = this.audiobookId
|
||||
bookmark.title = this.newBookmarkTitle
|
||||
this.$emit('update', bookmark)
|
||||
}
|
||||
} else {
|
||||
var bookmark = {
|
||||
audiobookId: this.audiobookId,
|
||||
title: this.newBookmarkTitle,
|
||||
time: this.currentTime
|
||||
}
|
||||
this.$emit('create', bookmark)
|
||||
}
|
||||
this.newBookmarkTitle = ''
|
||||
this.showBookmarkTitleInput = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<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" />
|
||||
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
</keep-alive>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -44,11 +44,6 @@ export default {
|
||||
title: 'Cover',
|
||||
component: 'modals-edit-tabs-cover'
|
||||
},
|
||||
// {
|
||||
// id: 'match',
|
||||
// title: 'Match',
|
||||
// component: 'modals-edit-tabs-match'
|
||||
// },
|
||||
{
|
||||
id: 'tracks',
|
||||
title: 'Tracks',
|
||||
@@ -68,6 +63,11 @@ export default {
|
||||
id: 'download',
|
||||
title: 'Download',
|
||||
component: 'modals-edit-tabs-download'
|
||||
},
|
||||
{
|
||||
id: 'match',
|
||||
title: 'Match',
|
||||
component: 'modals-edit-tabs-match'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -93,6 +93,9 @@ export default {
|
||||
this.fetchOnShow = false
|
||||
this.audiobook = null
|
||||
this.init()
|
||||
this.registerListeners()
|
||||
} else {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,12 +123,16 @@ export default {
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
availableTabs() {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
if (tab.id === 'download' && this.isMissing) return false
|
||||
if ((tab.id === 'download' || tab.id === 'tracks') && this.userCanDownload) return true
|
||||
if (tab.id !== 'download' && tab.id !== 'tracks' && this.userCanUpdate) return true
|
||||
if (tab.id === 'match' && this.showExperimentalFeatures) return true
|
||||
return false
|
||||
})
|
||||
},
|
||||
@@ -179,7 +186,8 @@ export default {
|
||||
}
|
||||
},
|
||||
goNextBook() {
|
||||
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length) return
|
||||
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) {
|
||||
@@ -190,7 +198,9 @@ export default {
|
||||
}
|
||||
},
|
||||
selectTab(tab) {
|
||||
this.selectedTab = tab
|
||||
if (this.availableTabs.find((t) => t.id === tab)) {
|
||||
this.selectedTab = tab
|
||||
}
|
||||
},
|
||||
audiobookUpdated() {
|
||||
if (!this.show) this.fetchOnShow = true
|
||||
@@ -212,9 +222,25 @@ export default {
|
||||
this.processing = false
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
|
||||
this.goNextBook()
|
||||
} else if (action === this.$hotkeys.Modal.PREV_PAGE) {
|
||||
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>
|
||||
@@ -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 === this.$hotkeys.Modal.CLOSE) {
|
||||
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() {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-4 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<div class="flex-grow overflow-hidden">
|
||||
<p class="pl-2 pr-2 truncate">{{ bookmark.title }}</p>
|
||||
</div>
|
||||
<div class="h-full flex items-center w-16 justify-end">
|
||||
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span>
|
||||
</div>
|
||||
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
|
||||
<span class="material-icons text-lg mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-icons text-lg text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
bookmark: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
highlight: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
click() {
|
||||
this.$emit('click', this.bookmark)
|
||||
},
|
||||
deleteClick() {
|
||||
this.$emit('delete', this.bookmark)
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.bookmark)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -188,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) {
|
||||
|
||||
@@ -53,18 +53,19 @@
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
||||
<div class="flex px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
|
||||
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="ml-4">
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4">
|
||||
<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="mr-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" />
|
||||
<ui-btn type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,6 +122,9 @@ export default {
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
isMissing() {
|
||||
return !!this.audiobook && !!this.audiobook.isMissing
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden px-4 py-6">
|
||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" :disabled="processing" />
|
||||
<div class="w-40 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" :disabled="processing" />
|
||||
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
</form>
|
||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||
@@ -23,6 +25,51 @@
|
||||
<cards-book-match-card :key="index" :book="res" @select="selectMatch" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-3">Update Book Details</p>
|
||||
</div>
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatch.cover" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.title" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.title" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.author" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.author" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" />
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publisher" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.publishYear" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publishYear" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.publishYear" :disabled="!selectedMatchUsage.publishYear" label="Publish Year" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<ui-btn color="success" type="submit">Update</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -41,9 +88,30 @@ export default {
|
||||
searchTitle: null,
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
provider: 'best',
|
||||
providers: [
|
||||
{
|
||||
text: 'Google Books',
|
||||
value: 'google'
|
||||
},
|
||||
{
|
||||
text: 'Open Library',
|
||||
value: 'openlibrary'
|
||||
}
|
||||
],
|
||||
provider: 'google',
|
||||
searchResults: [],
|
||||
hasSearched: false
|
||||
hasSearched: false,
|
||||
selectedMatch: null,
|
||||
selectedMatchUsage: {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
cover: true,
|
||||
author: true,
|
||||
description: true,
|
||||
isbn: true,
|
||||
publisher: true,
|
||||
publishYear: true
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -95,6 +163,18 @@ export default {
|
||||
this.hasSearched = true
|
||||
},
|
||||
init() {
|
||||
this.selectedMatch = null
|
||||
this.selectedMatchUsage = {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
cover: true,
|
||||
author: true,
|
||||
description: true,
|
||||
isbn: true,
|
||||
publisher: true,
|
||||
publishYear: true
|
||||
}
|
||||
|
||||
if (this.audiobook.id !== this.audiobookId) {
|
||||
this.searchResults = []
|
||||
this.hasSearched = false
|
||||
@@ -107,31 +187,63 @@ export default {
|
||||
return
|
||||
}
|
||||
this.searchTitle = this.audiobook.book.title
|
||||
this.searchAuthor = this.audiobook.book.author || ''
|
||||
this.searchAuthor = this.audiobook.book.authorFL || ''
|
||||
},
|
||||
async selectMatch(match) {
|
||||
selectMatch(match) {
|
||||
this.selectedMatch = match
|
||||
},
|
||||
buildMatchUpdatePayload() {
|
||||
var updatePayload = {}
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||
updatePayload[key] = this.selectedMatch[key]
|
||||
}
|
||||
}
|
||||
return updatePayload
|
||||
},
|
||||
async submitMatchUpdate() {
|
||||
var updatePayload = this.buildMatchUpdatePayload()
|
||||
if (!Object.keys(updatePayload).length) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
const updatePayload = {
|
||||
book: {}
|
||||
|
||||
if (updatePayload.cover) {
|
||||
var coverPayload = {
|
||||
url: updatePayload.cover
|
||||
}
|
||||
var success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success('Book Cover Updated')
|
||||
} else {
|
||||
this.$toast.error('Book Cover Failed to Update')
|
||||
}
|
||||
console.log('Updated cover')
|
||||
delete updatePayload.cover
|
||||
}
|
||||
if (match.cover) {
|
||||
updatePayload.book.cover = match.cover
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
var bookUpdatePayload = {
|
||||
book: updatePayload
|
||||
}
|
||||
var success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success('Book Details Updated')
|
||||
this.selectedMatch = null
|
||||
this.$emit('selectTab', 'details')
|
||||
} else {
|
||||
this.$toast.error('Book Details Failed to Update')
|
||||
}
|
||||
} else {
|
||||
this.selectedMatch = null
|
||||
}
|
||||
if (match.title) {
|
||||
updatePayload.book.title = match.title
|
||||
}
|
||||
if (match.description) {
|
||||
updatePayload.book.description = match.description
|
||||
}
|
||||
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-white shadow-lg z-20 border border-gray-400">
|
||||
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-gray-200 px-2 py-1" @click="setPage(index)">
|
||||
<p class="text-sm">{{ file }}</p>
|
||||
<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 class="absolute top-0 right-40 border-b border-l border-r border-gray-400 hover:bg-gray-200 cursor-pointer rounded-b-md bg-gray-50 w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
|
||||
<span class="material-icons">menu</span>
|
||||
<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 right-20 border-b border-l border-r border-gray-400 rounded-b-md bg-gray-50 px-2 h-9 flex items-center text-center">
|
||||
<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 class="flex items-center justify-center">
|
||||
<div class="px-12">
|
||||
<span v-show="loadedFirstPage" class="material-icons text-5xl text-black" :class="!canGoPrev ? 'text-opacity-10' : 'cursor-pointer text-opacity-30 hover:text-opacity-90'" @click.stop.prevent="goPrevPage" @mousedown.prevent>arrow_back_ios</span>
|
||||
<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 class="px-12">
|
||||
<span v-show="loadedFirstPage" class="material-icons text-5xl text-black" :class="!canGoNext ? 'text-opacity-10' : 'cursor-pointer text-opacity-30 hover:text-opacity-90'" @click.stop.prevent="goNextPage" @mousedown.prevent>arrow_forward_ios</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
|
||||
@@ -44,11 +57,10 @@ import { Archive } from 'libarchive.js/main.js'
|
||||
Archive.init({
|
||||
workerUrl: '/libarchive/worker-bundle.js'
|
||||
})
|
||||
// Archive.init()
|
||||
|
||||
export default {
|
||||
props: {
|
||||
src: String
|
||||
url: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -59,19 +71,24 @@ export default {
|
||||
page: 0,
|
||||
numPages: 0,
|
||||
showPageMenu: false,
|
||||
showInfoMenu: false,
|
||||
loadTimeout: null,
|
||||
loadedFirstPage: false
|
||||
loadedFirstPage: false,
|
||||
comicMetadata: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
src: {
|
||||
url: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
handler() {
|
||||
this.extract()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
comicMetadataKeys() {
|
||||
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
|
||||
},
|
||||
canGoNext() {
|
||||
return this.page < this.numPages - 1
|
||||
},
|
||||
@@ -82,12 +99,13 @@ export default {
|
||||
methods: {
|
||||
clickOutside() {
|
||||
if (this.showPageMenu) this.showPageMenu = false
|
||||
if (this.showInfoMenu) this.showInfoMenu = false
|
||||
},
|
||||
goNextPage() {
|
||||
next() {
|
||||
if (!this.canGoNext) return
|
||||
this.setPage(this.page + 1)
|
||||
},
|
||||
goPrevPage() {
|
||||
prev() {
|
||||
if (!this.canGoPrev) return
|
||||
this.setPage(this.page - 1)
|
||||
},
|
||||
@@ -126,9 +144,9 @@ export default {
|
||||
},
|
||||
async extract() {
|
||||
this.loading = true
|
||||
console.log('Extracting', this.src)
|
||||
console.log('Extracting', this.url)
|
||||
|
||||
var buff = await this.$axios.$get(this.src, {
|
||||
var buff = await this.$axios.$get(this.url, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
const archive = await Archive.open(buff)
|
||||
@@ -136,6 +154,9 @@ export default {
|
||||
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) {
|
||||
@@ -147,6 +168,23 @@ export default {
|
||||
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)
|
||||
@@ -177,30 +215,10 @@ export default {
|
||||
orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename))
|
||||
|
||||
this.pages = orderedImages
|
||||
},
|
||||
keyUp(e) {
|
||||
if ((e.keyCode || e.which) == 37) {
|
||||
this.goPrevPage()
|
||||
} else if ((e.keyCode || e.which) == 39) {
|
||||
this.goNextPage()
|
||||
} else if ((e.keyCode || e.which) == 27) {
|
||||
this.unregisterListeners()
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
document.addEventListener('keyup', this.keyUp)
|
||||
},
|
||||
unregisterListeners() {
|
||||
document.removeEventListener('keyup', this.keyUp)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.registerListeners()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -213,7 +231,8 @@ export default {
|
||||
margin: auto;
|
||||
}
|
||||
.comicwrapper {
|
||||
width: calc(100vw - 300px);
|
||||
width: 100vw;
|
||||
height: calc(100vh - 40px);
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,23 +1,31 @@
|
||||
<template>
|
||||
<div class="w-full pt-20">
|
||||
<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 class="px-12">
|
||||
<span class="material-icons text-5xl text-black" :class="!canGoPrev ? 'text-opacity-10' : 'cursor-pointer text-opacity-30 hover:text-opacity-90'" @click.stop.prevent="goPrevPage" @mousedown.prevent>arrow_back_ios</span>
|
||||
</div>
|
||||
<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="src" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
|
||||
</div>
|
||||
|
||||
<div class="px-12">
|
||||
<span class="material-icons text-5xl text-black" :class="!canGoNext ? 'text-opacity-10' : 'cursor-pointer text-opacity-30 hover:text-opacity-90'" @click.stop.prevent="goNextPage" @mousedown.prevent>arrow_forward_ios</span>
|
||||
<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">
|
||||
<!-- <div class="text-center py-2 text-lg">
|
||||
<p>{{ page }} / {{ numPages }}</p>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -29,7 +37,7 @@ export default {
|
||||
pdf
|
||||
},
|
||||
props: {
|
||||
src: String
|
||||
url: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -57,11 +65,11 @@ export default {
|
||||
numPagesLoaded(e) {
|
||||
this.numPages = e
|
||||
},
|
||||
goPrevPage() {
|
||||
prev() {
|
||||
if (this.page <= 1) return
|
||||
this.page--
|
||||
},
|
||||
goNextPage() {
|
||||
next() {
|
||||
if (this.page >= this.numPages) return
|
||||
this.page++
|
||||
},
|
||||
@@ -0,0 +1,155 @@
|
||||
<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 === this.$hotkeys.EReader.NEXT_PAGE) {
|
||||
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
|
||||
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
|
||||
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
|
||||
} else if (action === this.$hotkeys.EReader.CLOSE) {
|
||||
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'
|
||||
} else if (this.selectedAudiobookFile.ext === '.epub') {
|
||||
this.ebookType = 'epub'
|
||||
} 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)
|
||||
} else if (this.mobiEbook) {
|
||||
this.ebookType = 'mobi'
|
||||
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
|
||||
} 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>
|
||||
@@ -102,7 +102,7 @@ export default {
|
||||
applyBackupComplete(success) {
|
||||
if (success) {
|
||||
// this.$toast.success('Backup Applied, refresh the page')
|
||||
location.replace('/config?backup=1')
|
||||
location.replace('/config/backups?backup=1')
|
||||
} else {
|
||||
this.$toast.error('Failed to apply backup')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<label class="flex justify-start items-start">
|
||||
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500" :class="wrapperClass">
|
||||
<input v-model="selected" type="checkbox" class="opacity-0 absolute" />
|
||||
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||
</div>
|
||||
<div v-if="label" class="select-none">{{ label }}</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
label: Boolean,
|
||||
small: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', !!val)
|
||||
}
|
||||
},
|
||||
wrapperClass() {
|
||||
if (this.small) return 'w-4 h-4'
|
||||
return 'w-6 h-6'
|
||||
},
|
||||
svgClass() {
|
||||
if (this.small) return 'w-3 h-3'
|
||||
return 'w-4 h-4'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="relative w-full" v-click-outside="clickOutside">
|
||||
<p class="text-sm text-opacity-75 mb-1">{{ label }}</p>
|
||||
<button type="button" :disabled="disabled" class="relative h-10 w-full border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<p class="text-sm font-semibold">{{ label }}</p>
|
||||
<button type="button" :disabled="disabled" class="relative w-full border border-gray-500 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate">{{ selectedText }}</span>
|
||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-icons text-gray-100">expand_more</span>
|
||||
@@ -36,7 +36,8 @@ export default {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
small: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md border border-gray-600 flex items-center justify-center h-9 w-9 relative" :disabled="disabled" :class="className" @click="clickBtn">
|
||||
<span class="material-icons icon-text">{{ icon }}</span>
|
||||
<span class="material-icons" :style="{ fontSize }">{{ icon }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -22,6 +22,10 @@ export default {
|
||||
var classes = []
|
||||
classes.push(`bg-${this.bgColor}`)
|
||||
return classes.join(' ')
|
||||
},
|
||||
fontSize() {
|
||||
if (this.icon === 'edit') return '1.25rem'
|
||||
return '1.4rem'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
@@ -80,6 +80,7 @@ input {
|
||||
border-style: inherit !important;
|
||||
}
|
||||
input:read-only {
|
||||
color: #aaa;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
|
||||
|
||||
@@ -38,10 +38,11 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
textarea {
|
||||
border-style: inherit !important;
|
||||
}
|
||||
input:read-only {
|
||||
background-color: #eee;
|
||||
textarea:read-only {
|
||||
color: #aaa;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">{{ label }}</p>
|
||||
<ui-textarea-input v-model="inputValue" :rows="rows" class="w-full" />
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<ui-textarea-input v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: String,
|
||||
disabled: Boolean,
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 2
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -172,6 +177,10 @@ export default {
|
||||
userStreamUpdate(user) {
|
||||
this.$store.commit('users/updateUser', user)
|
||||
},
|
||||
currentUserAudiobookUpdate(payload) {
|
||||
// console.log('Received user audiobook update', payload)
|
||||
this.$store.commit('user/updateUserAudiobook', payload)
|
||||
},
|
||||
downloadToastClick(download) {
|
||||
if (!download || !download.audiobookId) {
|
||||
return console.error('Invalid download object', download)
|
||||
@@ -230,6 +239,12 @@ export default {
|
||||
download.status = this.$constants.DownloadStatus.EXPIRED
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
showErrorToast(message) {
|
||||
this.$toast.error(message)
|
||||
},
|
||||
showSuccessToast(message) {
|
||||
this.$toast.success(message)
|
||||
},
|
||||
logEvtReceived(payload) {
|
||||
this.$store.commit('logs/logEvt', payload)
|
||||
},
|
||||
@@ -280,6 +295,7 @@ export default {
|
||||
this.socket.on('user_online', this.userOnline)
|
||||
this.socket.on('user_offline', this.userOffline)
|
||||
this.socket.on('user_stream_update', this.userStreamUpdate)
|
||||
this.socket.on('current_user_audiobook_update', this.currentUserAudiobookUpdate)
|
||||
|
||||
// Scan Listeners
|
||||
this.socket.on('scan_start', this.scanStart)
|
||||
@@ -293,6 +309,10 @@ export default {
|
||||
this.socket.on('download_killed', this.downloadKilled)
|
||||
this.socket.on('download_expired', this.downloadExpired)
|
||||
|
||||
// Toast Listeners
|
||||
this.socket.on('show_error_toast', this.showErrorToast)
|
||||
this.socket.on('show_success_toast', this.showSuccessToast)
|
||||
|
||||
this.socket.on('log', this.logEvtReceived)
|
||||
|
||||
this.socket.on('backup_applied', this.backupApplied)
|
||||
@@ -320,9 +340,68 @@ 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
|
||||
},
|
||||
getHotkeyName(e) {
|
||||
var keyCode = e.keyCode || e.which
|
||||
if (!this.$keynames[keyCode]) {
|
||||
// Unused hotkey
|
||||
return null
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return name
|
||||
},
|
||||
keyDown(e) {
|
||||
var name = this.getHotkeyName(e)
|
||||
if (!name) return
|
||||
|
||||
// Input is focused then ignore key press
|
||||
if (this.checkActiveElementIsInput()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Modal is open
|
||||
if (this.$store.state.openModal && Object.values(this.$hotkeys.Modal).includes(name)) {
|
||||
this.$eventBus.$emit('modal-hotkey', name)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// EReader is open
|
||||
if (this.$store.state.showEReader && Object.values(this.$hotkeys.EReader).includes(name)) {
|
||||
this.$eventBus.$emit('reader-hotkey', name)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// Batch selecting
|
||||
if (this.$store.getters['getNumAudiobooksSelected'] && name === 'Escape') {
|
||||
// ESCAPE key cancels batch selection
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// Playing audiobook
|
||||
if (this.$store.state.streamAudiobook && Object.values(this.$hotkeys.AudioPlayer).includes(name)) {
|
||||
this.$eventBus.$emit('player-hotkey', name)
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('keydown', this.keyDown)
|
||||
|
||||
this.initializeSocket()
|
||||
this.$store.dispatch('libraries/load')
|
||||
|
||||
@@ -343,6 +422,9 @@ export default {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('keydown', this.keyDown)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.4.11",
|
||||
"version": "1.6.0",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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>
|
||||
@@ -67,6 +84,7 @@
|
||||
<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>
|
||||
@@ -74,6 +92,7 @@
|
||||
<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>
|
||||
@@ -119,7 +138,8 @@ export default {
|
||||
},
|
||||
saving: false,
|
||||
checkingTrackNumbers: false,
|
||||
trackNumData: []
|
||||
trackNumData: [],
|
||||
currentSort: 'current'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -202,12 +222,41 @@ export default {
|
||||
}
|
||||
},
|
||||
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) => {
|
||||
console.log('RES', res)
|
||||
this.trackNumData = res
|
||||
this.checkingTrackNumbers = false
|
||||
})
|
||||
|
||||
@@ -13,22 +13,17 @@
|
||||
<div class="mb-4">
|
||||
<div class="flex items-end">
|
||||
<h1 class="text-3xl font-sans">
|
||||
{{ title }}<span v-if="isDeveloperMode"> ({{ audiobook.ino }})</span>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="subtitle" class="ml-4 text-gray-400 text-2xl">{{ subtitle }}</p>
|
||||
</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>
|
||||
@@ -152,8 +143,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <app-reader v-if="showExperimentalFeatures" v-model="showReader" :url="epubUrl" /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -321,19 +310,12 @@ export default {
|
||||
ebooks() {
|
||||
return this.audiobook.ebooks
|
||||
},
|
||||
showEpubAlert() {
|
||||
return this.ebooks.length && !this.numEbooks && !this.tracks.length
|
||||
},
|
||||
showExperimentalReadAlert() {
|
||||
return !this.tracks.length && this.ebooks.length && !this.showExperimentalFeatures
|
||||
},
|
||||
numEbooks() {
|
||||
// Number of currently supported for reading ebooks, not all ebooks
|
||||
return this.audiobook.numEbooks
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
description() {
|
||||
return this.book.description || ''
|
||||
},
|
||||
@@ -412,16 +394,6 @@ export default {
|
||||
this.$store.commit('setBookshelfBookIds', [])
|
||||
this.$store.commit('showEditModal', this.audiobook)
|
||||
},
|
||||
lookupMetadata(index) {
|
||||
this.$axios
|
||||
.$get(`/api/metadata/${this.audiobookId}/${index}`)
|
||||
.then((metadata) => {
|
||||
console.log('Metadata for ' + index, metadata)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
},
|
||||
audiobookUpdated() {
|
||||
console.log('Audiobook Updated - Fetch full audiobook')
|
||||
this.$axios
|
||||
@@ -438,7 +410,7 @@ export default {
|
||||
if (confirm(`Are you sure you want to reset your progress?`)) {
|
||||
this.resettingProgress = true
|
||||
this.$axios
|
||||
.$delete(`/api/user/audiobook/${this.audiobookId}`)
|
||||
.$patch(`/api/user/audiobook/${this.audiobookId}/reset-progress`)
|
||||
.then(() => {
|
||||
console.log('Progress reset complete')
|
||||
this.$toast.success(`Your progress was reset`)
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,90 @@
|
||||
<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,
|
||||
newServerSettings: {}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
serverSettings(newVal, oldVal) {
|
||||
if (newVal && !oldVal) {
|
||||
this.newServerSettings = { ...this.serverSettings }
|
||||
this.initServerSettings()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dailyBackupsTooltip() {
|
||||
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
}
|
||||
},
|
||||
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
|
||||
})
|
||||
},
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
|
||||
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||
this.dailyBackups = !!this.newServerSettings.backupSchedule
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initServerSettings()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+70
-166
@@ -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,11 @@ 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
|
||||
},
|
||||
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 +162,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>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<tables-libraries-table />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<tables-users-table />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +1,5 @@
|
||||
<template>
|
||||
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<!-- <app-book-shelf-toolbar /> -->
|
||||
<!-- <div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow"> -->
|
||||
<!-- <app-book-shelf /> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow">
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
|
||||
<app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
|
||||
<app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode.sync="viewMode" />
|
||||
<app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode="viewMode" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,7 +56,9 @@ export default {
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
viewMode: 'grid'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.query'(newVal) {
|
||||
@@ -64,6 +66,17 @@ export default {
|
||||
if (this.$route.query.query !== this.searchQuery) {
|
||||
this.newQuery()
|
||||
}
|
||||
} else if (this.id === 'series') {
|
||||
if (this.selectedSeries && this.$route.query.series && this.$route.query.series !== this.$encode(this.selectedSeries)) {
|
||||
// Series changed
|
||||
this.selectedSeries = this.$decode(this.$route.query.series)
|
||||
} else if (!this.selectedSeries && this.$route.query.series) {
|
||||
// Series selected
|
||||
this.selectedSeries = this.$decode(this.$route.query.series)
|
||||
} else if (this.selectedSeries && !this.$route.query.series) {
|
||||
// Series unselected
|
||||
this.selectedSeries = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+20
-2
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -15,6 +15,43 @@ const Constants = {
|
||||
CoverDestination
|
||||
}
|
||||
|
||||
const KeyNames = {
|
||||
27: 'Escape',
|
||||
32: 'Space',
|
||||
37: 'ArrowLeft',
|
||||
38: 'ArrowUp',
|
||||
39: 'ArrowRight',
|
||||
40: 'ArrowDown',
|
||||
76: 'KeyL',
|
||||
77: 'KeyM'
|
||||
}
|
||||
const Hotkeys = {
|
||||
AudioPlayer: {
|
||||
PLAY_PAUSE: 'Space',
|
||||
JUMP_FORWARD: 'ArrowRight',
|
||||
JUMP_BACKWARD: 'ArrowLeft',
|
||||
VOLUME_UP: 'ArrowUp',
|
||||
VOLUME_DOWN: 'ArrowDown',
|
||||
MUTE_UNMUTE: 'KeyM',
|
||||
SHOW_CHAPTERS: 'KeyL',
|
||||
INCREASE_PLAYBACK_RATE: 'Shift-ArrowUp',
|
||||
DECREASE_PLAYBACK_RATE: 'Shift-ArrowDown',
|
||||
CLOSE: 'Escape'
|
||||
},
|
||||
EReader: {
|
||||
NEXT_PAGE: 'ArrowRight',
|
||||
PREV_PAGE: 'ArrowLeft',
|
||||
CLOSE: 'Escape'
|
||||
},
|
||||
Modal: {
|
||||
NEXT_PAGE: 'ArrowRight',
|
||||
PREV_PAGE: 'ArrowLeft',
|
||||
CLOSE: 'Escape'
|
||||
}
|
||||
}
|
||||
|
||||
export default ({ app }, inject) => {
|
||||
inject('constants', Constants)
|
||||
inject('keynames', KeyNames)
|
||||
inject('hotkeys', Hotkeys)
|
||||
}
|
||||
@@ -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) => {
|
||||
@@ -42,7 +44,7 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
_seconds -= _minutes * 60
|
||||
var _hours = Math.floor(_minutes / 60)
|
||||
_minutes -= _hours * 60
|
||||
_seconds = Math.round(_seconds)
|
||||
_seconds = Math.floor(_seconds)
|
||||
if (!_hours) {
|
||||
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -22,6 +22,11 @@ export const getters = {
|
||||
getAudiobook: (state) => id => {
|
||||
return state.audiobooks.find(ab => ab.id === id)
|
||||
},
|
||||
getAudiobooksWithIssues: (state) => {
|
||||
return state.audiobooks.filter(ab => {
|
||||
return ab.hasMissingParts || ab.hasInvalidParts || ab.isMissing || ab.isIncomplete
|
||||
})
|
||||
},
|
||||
getEntitiesShowing: (state, getters, rootState, rootGetters) => () => {
|
||||
if (!state.libraryPage) {
|
||||
return getters.getFiltered()
|
||||
@@ -54,7 +59,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 => {
|
||||
@@ -66,7 +71,12 @@ export const getters = {
|
||||
return false
|
||||
})
|
||||
}
|
||||
} else if (filterBy === 'issues') {
|
||||
filtered = filtered.filter(ab => {
|
||||
return ab.hasMissingParts || ab.hasInvalidParts || ab.isMissing || ab.isIncomplete
|
||||
})
|
||||
}
|
||||
|
||||
if (state.keywordFilter) {
|
||||
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrator']
|
||||
const keyworkFilter = state.keywordFilter.toLowerCase()
|
||||
@@ -122,7 +132,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) => {
|
||||
|
||||
+18
-2
@@ -18,14 +18,18 @@ export const state = () => ({
|
||||
routeHistory: [],
|
||||
showExperimentalFeatures: false,
|
||||
backups: [],
|
||||
bookshelfBookIds: []
|
||||
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 = {
|
||||
@@ -146,6 +150,15 @@ export const mutations = {
|
||||
Vue.set(state, 'selectedAudiobooks', newSel)
|
||||
}
|
||||
},
|
||||
setAudiobookSelected(state, { audiobookId, selected }) {
|
||||
var isThere = state.selectedAudiobooks.includes(audiobookId)
|
||||
if (isThere && !selected) {
|
||||
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
||||
} else if (selected && !isThere) {
|
||||
var newSel = state.selectedAudiobooks.concat([audiobookId])
|
||||
Vue.set(state, 'selectedAudiobooks', newSel)
|
||||
}
|
||||
},
|
||||
setProcessingBatch(state, val) {
|
||||
state.processingBatch = val
|
||||
},
|
||||
@@ -155,5 +168,8 @@ export const mutations = {
|
||||
},
|
||||
setBackups(state, val) {
|
||||
state.backups = val.sort((a, b) => b.createdAt - a.createdAt)
|
||||
},
|
||||
setOpenModal(state, val) {
|
||||
state.openModal = val
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
import Vue from 'vue'
|
||||
|
||||
export const state = () => ({
|
||||
user: null,
|
||||
settings: {
|
||||
@@ -33,6 +35,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,12 +75,20 @@ export const actions = {
|
||||
export const mutations = {
|
||||
setUser(state, user) {
|
||||
state.user = user
|
||||
|
||||
if (user) {
|
||||
if (user.token) localStorage.setItem('token', user.token)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
},
|
||||
updateUserAudiobook(state, { id, data }) {
|
||||
if (!state.user) return
|
||||
if (!state.user.audiobooks) {
|
||||
Vue.set(state.user, 'audiobooks', {})
|
||||
}
|
||||
Vue.set(state.user.audiobooks, id, data)
|
||||
},
|
||||
setSettings(state, settings) {
|
||||
if (!settings) return
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -17,6 +17,24 @@ module.exports = {
|
||||
height: {
|
||||
'7.5': '1.75rem'
|
||||
},
|
||||
maxWidth: {
|
||||
'6': '1.5rem',
|
||||
'12': '3rem',
|
||||
'24': '6rem',
|
||||
'32': '8rem',
|
||||
'48': '12rem',
|
||||
'64': '16rem',
|
||||
'80': '20rem'
|
||||
},
|
||||
minWidth: {
|
||||
'6': '1.5rem',
|
||||
'12': '3rem',
|
||||
'24': '6rem',
|
||||
'32': '8rem',
|
||||
'48': '12rem',
|
||||
'64': '16rem',
|
||||
'80': '20rem'
|
||||
},
|
||||
spacing: {
|
||||
'-54': '-13.5rem'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
### EXAMPLE DOCKER COMPOSE ###
|
||||
version: "3.7"
|
||||
|
||||
services:
|
||||
audiobookshelf:
|
||||
image: advplyr/audiobookshelf
|
||||
ports:
|
||||
- 13378:80
|
||||
volumes:
|
||||
- /audiobooks:/audiobooks
|
||||
- /metadata:/metadata
|
||||
- /config:/config
|
||||
Generated
+4
-4
@@ -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"
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.4.11",
|
||||
"version": "1.6.0",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -9,7 +9,8 @@
|
||||
"client": "cd client && npm install && npm run generate",
|
||||
"prod": "npm run client && npm install && node prod.js",
|
||||
"build-win": "pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
|
||||
"build-linux": "build/linuxpackager"
|
||||
"build-linux": "build/linuxpackager",
|
||||
"docker": "docker buildx build -t advplyr/audiobookshelf --platform linux/amd64,linux/arm64 --push ."
|
||||
},
|
||||
"bin": "prod.js",
|
||||
"pkg": {
|
||||
@@ -37,7 +38,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",
|
||||
|
||||
@@ -39,8 +39,6 @@ Android app is in beta, try it out on the [Google Play Store](https://play.googl
|
||||
See [documentation](https://audiobookshelf.org/docs) for supported directory structure, folder naming conventions, and audio file metadata usage.
|
||||
|
||||
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
** Default username is "root" with no password
|
||||
@@ -60,6 +58,22 @@ docker run -d \
|
||||
--rm advplyr/audiobookshelf
|
||||
```
|
||||
|
||||
### Running with Docker Compose
|
||||
|
||||
```bash
|
||||
### docker-compose.yml ###
|
||||
services:
|
||||
audiobookshelf:
|
||||
image: advplyr/audiobookshelf
|
||||
ports:
|
||||
- 13378:80
|
||||
volumes:
|
||||
- <path/to/your/audiobooks>:/audiobooks
|
||||
- <path/to/metadata>:/metadata
|
||||
- <path/to/config>:/config
|
||||
```
|
||||
|
||||
|
||||
### Linux (amd64) Install
|
||||
|
||||
A simple installer is added to setup the initial config. If you already have audiobooks, you can enter the path to your audiobooks during the install. The installer will create a user and group named `audiobookshelf`.
|
||||
@@ -103,6 +117,73 @@ System Service: `/lib/systemd/system/audiobookshelf.service`
|
||||
|
||||
Ffmpeg static build: `/usr/lib/audiobookshelf-ffmpeg/`
|
||||
|
||||
## Reverse Proxy Set Up
|
||||
|
||||
### 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 your certificate paths.
|
||||
|
||||
|
||||
```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://;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Apache Reverse Proxy
|
||||
|
||||
Add this to the site config file on your Apache server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
|
||||
|
||||
For this to work you must enable at least the following mods using `a2enmod`:
|
||||
- `ssl`
|
||||
- `proxy_module`
|
||||
- `proxy_wstunnel_module`
|
||||
- `rewrite_module`
|
||||
|
||||
```bash
|
||||
<IfModule mod_ssl.c>
|
||||
<VirtualHost *:443>
|
||||
ServerName <sub>.<domain>.<tld>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/access.log combined
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyPass / http://localhost:<audiobookshelf_port>/
|
||||
RewriteEngine on
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://localhost:<audiobookshelf_port>/$1" [P,L]
|
||||
|
||||
# unless you're doing something special this should be generated by a
|
||||
# tool like certbot by let's encrypt
|
||||
SSLCertificateFile /path/to/cert/file
|
||||
SSLCertificateKeyFile /path/to/key/file
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
```
|
||||
|
||||
## Run from source
|
||||
|
||||
Note: you will need `npm`, `node12`, and `ffmpeg` to run this project locally
|
||||
@@ -119,4 +200,4 @@ npm run prod -- -p [PORT] --audiobooks [AUDIOBOOKS_PATH] --config [CONFIG_PATH]
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to help out
|
||||
Feel free to help out
|
||||
|
||||
+113
-22
@@ -6,6 +6,8 @@ const Logger = require('./Logger')
|
||||
const { isObject } = require('./utils/index')
|
||||
const audioFileScanner = require('./utils/audioFileScanner')
|
||||
|
||||
const BookFinder = require('./BookFinder')
|
||||
|
||||
const Library = require('./objects/Library')
|
||||
const User = require('./objects/User')
|
||||
|
||||
@@ -24,6 +26,8 @@ class ApiController {
|
||||
this.clientEmitter = clientEmitter
|
||||
this.MetadataPath = MetadataPath
|
||||
|
||||
this.bookFinder = new BookFinder()
|
||||
|
||||
this.router = express()
|
||||
this.init()
|
||||
}
|
||||
@@ -51,18 +55,23 @@ class ApiController {
|
||||
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.get('/audiobook/:id/match', this.matchAudiobookBook.bind(this))
|
||||
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
|
||||
|
||||
this.router.patch('/match/:id', this.match.bind(this))
|
||||
|
||||
// Old Route : Wait until refactor of mobile app to replace with path /reset-progress
|
||||
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
|
||||
this.router.patch('/user/audiobook/:id', this.updateUserAudiobookProgress.bind(this))
|
||||
this.router.patch('/user/audiobooks', this.batchUpdateUserAudiobooksProgress.bind(this))
|
||||
|
||||
this.router.patch('/user/audiobook/:id/reset-progress', this.resetUserAudiobookProgress.bind(this))
|
||||
this.router.patch('/user/audiobook/:id', this.updateUserAudiobookData.bind(this))
|
||||
this.router.patch('/user/audiobooks', this.batchUpdateUserAudiobookData.bind(this))
|
||||
|
||||
this.router.patch('/user/password', this.userChangePassword.bind(this))
|
||||
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))
|
||||
|
||||
@@ -84,8 +93,12 @@ class ApiController {
|
||||
this.router.get('/scantracks/:id', this.scanAudioTrackNums.bind(this))
|
||||
}
|
||||
|
||||
find(req, res) {
|
||||
this.scanner.find(req, res)
|
||||
async find(req, res) {
|
||||
var provider = req.query.provider || 'google'
|
||||
var title = req.query.title || ''
|
||||
var author = req.query.author || ''
|
||||
var results = await this.bookFinder.search(provider, title, author)
|
||||
res.json(results)
|
||||
}
|
||||
|
||||
findCovers(req, res) {
|
||||
@@ -314,8 +327,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())
|
||||
}
|
||||
|
||||
@@ -323,7 +345,7 @@ class ApiController {
|
||||
// Remove audiobook from users
|
||||
for (let i = 0; i < this.db.users.length; i++) {
|
||||
var user = this.db.users[i]
|
||||
var madeUpdates = user.deleteAudiobookProgress(audiobook.id)
|
||||
var madeUpdates = user.deleteAudiobookData(audiobook.id)
|
||||
if (madeUpdates) {
|
||||
await this.db.updateEntity('user', user)
|
||||
}
|
||||
@@ -487,6 +509,18 @@ class ApiController {
|
||||
else res.status(200).send('No update was made to cover')
|
||||
}
|
||||
|
||||
async matchAudiobookBook(req, res) {
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
|
||||
var provider = req.query.provider || 'google'
|
||||
var excludeAuthor = req.query.excludeAuthor === '1'
|
||||
var authorSearch = excludeAuthor ? null : audiobook.authorFL
|
||||
|
||||
var results = await this.bookFinder.search(provider, audiobook.title, authorSearch)
|
||||
res.json(results)
|
||||
}
|
||||
|
||||
async updateAudiobook(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.warn('User attempted to update without permission', req.user)
|
||||
@@ -522,20 +556,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)
|
||||
async updateUserAudiobookData(req, res) {
|
||||
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.updateAudiobookData(audiobook, req.body)
|
||||
if (wasUpdated) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
@@ -543,16 +580,19 @@ class ApiController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async batchUpdateUserAudiobooksProgress(req, res) {
|
||||
var abProgresses = req.body
|
||||
if (!abProgresses || !abProgresses.length) {
|
||||
async batchUpdateUserAudiobookData(req, res) {
|
||||
var userAbDataPayloads = req.body
|
||||
if (!userAbDataPayloads || !userAbDataPayloads.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var shouldUpdate = false
|
||||
abProgresses.forEach((progress) => {
|
||||
var wasUpdated = req.user.updateAudiobookProgress(progress.audiobookId, progress)
|
||||
if (wasUpdated) shouldUpdate = true
|
||||
userAbDataPayloads.forEach((userAbData) => {
|
||||
var audiobook = this.db.audiobooks.find(ab => ab.id === userAbData.audiobookId)
|
||||
if (audiobook) {
|
||||
var wasUpdated = req.user.updateAudiobookData(audiobook, userAbData)
|
||||
if (wasUpdated) shouldUpdate = true
|
||||
}
|
||||
})
|
||||
|
||||
if (shouldUpdate) {
|
||||
@@ -591,12 +631,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
|
||||
@@ -610,12 +681,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)
|
||||
@@ -628,6 +711,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)
|
||||
|
||||
+2
-2
@@ -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}`)
|
||||
|
||||
+16
-1
@@ -1,5 +1,6 @@
|
||||
const OpenLibrary = require('./providers/OpenLibrary')
|
||||
const LibGen = require('./providers/LibGen')
|
||||
const GoogleBooks = require('./providers/GoogleBooks')
|
||||
const Logger = require('./Logger')
|
||||
const { levenshteinDistance } = require('./utils/index')
|
||||
|
||||
@@ -7,6 +8,7 @@ class BookFinder {
|
||||
constructor() {
|
||||
this.openLibrary = new OpenLibrary()
|
||||
this.libGen = new LibGen()
|
||||
this.googleBooks = new GoogleBooks()
|
||||
|
||||
this.verbose = false
|
||||
}
|
||||
@@ -143,13 +145,26 @@ class BookFinder {
|
||||
return booksFiltered
|
||||
}
|
||||
|
||||
async getGoogleBooksResults(title, author, maxTitleDistance, maxAuthorDistance) {
|
||||
var books = await this.googleBooks.search(title, author)
|
||||
if (this.verbose) Logger.debug(`GoogleBooks Book Search Results: ${books.length || 0}`)
|
||||
if (books.errorCode) {
|
||||
Logger.error(`GoogleBooks Search Error ${books.errorCode}`)
|
||||
return []
|
||||
}
|
||||
// Google has good sort
|
||||
return books
|
||||
}
|
||||
|
||||
async search(provider, title, author, options = {}) {
|
||||
var books = []
|
||||
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
|
||||
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
|
||||
Logger.debug(`Cover Search: title: "${title}", author: "${author}", provider: ${provider}`)
|
||||
|
||||
if (provider === 'libgen') {
|
||||
if (provider === 'google') {
|
||||
return this.getGoogleBooksResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
} else if (provider === 'libgen') {
|
||||
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
} else if (provider === 'openlibrary') {
|
||||
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
|
||||
+6
-3
@@ -19,6 +19,8 @@ class RssFeeds {
|
||||
}
|
||||
|
||||
getFeed(req, res) {
|
||||
Logger.info('Get Feed', req.params.id, this.feeds[req.params.id])
|
||||
|
||||
var feed = this.feeds[req.params.id]
|
||||
if (!feed) return null
|
||||
var xml = feed.buildXml()
|
||||
@@ -27,15 +29,16 @@ class RssFeeds {
|
||||
}
|
||||
|
||||
openFeed(audiobook) {
|
||||
var serverAddress = 'http://' + ip.address('public', 'ipv4') + ':' + this.Port
|
||||
var ipAddress = ip.address('public', 'ipv4')
|
||||
var serverAddress = 'http://' + ipAddress + ':' + this.Port
|
||||
Logger.info('Open RSS Feed', 'Server address', serverAddress)
|
||||
|
||||
var feedId = (Date.now() + Math.floor(Math.random() * 1000)).toString(36)
|
||||
const feed = new Podcast({
|
||||
title: audiobook.title,
|
||||
description: 'AudioBookshelf RSS Feed',
|
||||
feedUrl: `${serverAddress}/feeds/${feedId}`,
|
||||
imageUrl: `${serverAddress}/Logo.png`,
|
||||
feed_url: `${serverAddress}/feeds/${feedId}`,
|
||||
image_url: `${serverAddress}/Logo.png`,
|
||||
author: 'advplyr',
|
||||
language: 'en'
|
||||
})
|
||||
|
||||
+9
-95
@@ -10,7 +10,6 @@ const { comparePaths, getIno } = require('./utils/index')
|
||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||
const { ScanResult, CoverDestination } = require('./utils/constants')
|
||||
|
||||
// Classes
|
||||
const BookFinder = require('./BookFinder')
|
||||
const Audiobook = require('./objects/Audiobook')
|
||||
|
||||
@@ -109,7 +108,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 +198,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 +587,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 +694,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)
|
||||
|
||||
+129
-16
@@ -11,6 +11,7 @@ const { version } = require('../package.json')
|
||||
// Utils
|
||||
const { ScanResult } = require('./utils/constants')
|
||||
const filePerms = require('./utils/filePerms')
|
||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
// Classes
|
||||
@@ -46,7 +47,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.clientEmitter.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 +58,6 @@ class Server {
|
||||
this.io = null
|
||||
|
||||
this.clients = {}
|
||||
|
||||
this.isScanningCovers = false
|
||||
}
|
||||
|
||||
get audiobooks() {
|
||||
@@ -70,6 +69,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)
|
||||
@@ -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()
|
||||
@@ -247,7 +247,7 @@ class Server {
|
||||
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
|
||||
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
|
||||
|
||||
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket.sheepClient, payload))
|
||||
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload))
|
||||
|
||||
// Downloading
|
||||
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
|
||||
@@ -260,6 +260,11 @@ class Server {
|
||||
socket.on('create_backup', () => this.backupManager.requestCreateBackup(socket))
|
||||
socket.on('apply_backup', (id) => this.backupManager.requestApplyBackup(socket, id))
|
||||
|
||||
// Bookmarks
|
||||
socket.on('create_bookmark', (payload) => this.createBookmark(socket, payload))
|
||||
socket.on('update_bookmark', (payload) => this.updateBookmark(socket, payload))
|
||||
socket.on('delete_bookmark', (payload) => this.deleteBookmark(socket, payload))
|
||||
|
||||
socket.on('test', () => {
|
||||
socket.emit('test_received', socket.id)
|
||||
})
|
||||
@@ -269,15 +274,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,15 +432,113 @@ 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)
|
||||
}
|
||||
|
||||
audiobookProgressUpdate(client, progressPayload) {
|
||||
async audiobookProgressUpdate(socket, progressPayload) {
|
||||
var client = socket.sheepClient
|
||||
if (!client || !client.user) {
|
||||
Logger.error('[Server] audiobookProgressUpdate invalid socket client')
|
||||
return
|
||||
}
|
||||
client.user.updateAudiobookProgress(progressPayload.audiobookId, progressPayload)
|
||||
var audiobookProgress = client.user.updateAudiobookData(progressPayload.audiobookId, progressPayload)
|
||||
if (audiobookProgress) {
|
||||
await this.db.updateEntity('user', client.user)
|
||||
this.clientEmitter(client.user.id, 'current_user_audiobook_update', {
|
||||
id: progressPayload.audiobookId,
|
||||
data: audiobookProgress || null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async createBookmark(socket, payload) {
|
||||
var client = socket.sheepClient
|
||||
if (!client || !client.user) {
|
||||
Logger.error('[Server] createBookmark invalid socket client')
|
||||
return
|
||||
}
|
||||
var userAudiobook = client.user.createBookmark(payload)
|
||||
if (!userAudiobook || userAudiobook.error) {
|
||||
var failMessage = (userAudiobook ? userAudiobook.error : null) || 'Unknown Error'
|
||||
socket.emit('show_error_toast', `Failed to create Bookmark: ${failMessage}`)
|
||||
return
|
||||
}
|
||||
|
||||
await this.db.updateEntity('user', client.user)
|
||||
|
||||
socket.emit('show_success_toast', `${secondsToTimestamp(payload.time)} Bookmarked`)
|
||||
|
||||
this.clientEmitter(client.user.id, 'current_user_audiobook_update', {
|
||||
id: userAudiobook.audiobookId,
|
||||
data: userAudiobook || null
|
||||
})
|
||||
}
|
||||
|
||||
async updateBookmark(socket, payload) {
|
||||
var client = socket.sheepClient
|
||||
if (!client || !client.user) {
|
||||
Logger.error('[Server] updateBookmark invalid socket client')
|
||||
return
|
||||
}
|
||||
var userAudiobook = client.user.updateBookmark(payload)
|
||||
if (!userAudiobook || userAudiobook.error) {
|
||||
var failMessage = (userAudiobook ? userAudiobook.error : null) || 'Unknown Error'
|
||||
socket.emit('show_error_toast', `Failed to update Bookmark: ${failMessage}`)
|
||||
return
|
||||
}
|
||||
|
||||
await this.db.updateEntity('user', client.user)
|
||||
|
||||
socket.emit('show_success_toast', `Bookmark ${secondsToTimestamp(payload.time)} Updated`)
|
||||
|
||||
this.clientEmitter(client.user.id, 'current_user_audiobook_update', {
|
||||
id: userAudiobook.audiobookId,
|
||||
data: userAudiobook || null
|
||||
})
|
||||
}
|
||||
|
||||
async deleteBookmark(socket, payload) {
|
||||
var client = socket.sheepClient
|
||||
if (!client || !client.user) {
|
||||
Logger.error('[Server] deleteBookmark invalid socket client')
|
||||
return
|
||||
}
|
||||
var userAudiobook = client.user.deleteBookmark(payload)
|
||||
if (!userAudiobook || userAudiobook.error) {
|
||||
var failMessage = (userAudiobook ? userAudiobook.error : null) || 'Unknown Error'
|
||||
socket.emit('show_error_toast', `Failed to delete Bookmark: ${failMessage}`)
|
||||
return
|
||||
}
|
||||
|
||||
await this.db.updateEntity('user', client.user)
|
||||
|
||||
socket.emit('show_success_toast', `Bookmark ${secondsToTimestamp(payload.time)} Removed`)
|
||||
|
||||
this.clientEmitter(client.user.id, 'current_user_audiobook_update', {
|
||||
id: userAudiobook.audiobookId,
|
||||
data: userAudiobook || null
|
||||
})
|
||||
}
|
||||
|
||||
async authenticateSocket(socket, token) {
|
||||
@@ -444,6 +548,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 +571,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 +587,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
|
||||
|
||||
+14
-21
@@ -5,9 +5,12 @@ const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
|
||||
class StreamManager {
|
||||
constructor(db, MetadataPath) {
|
||||
constructor(db, MetadataPath, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
|
||||
this.MetadataPath = MetadataPath
|
||||
this.streams = []
|
||||
this.StreamsPath = Path.join(this.MetadataPath, 'streams')
|
||||
@@ -112,7 +115,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 +132,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 }) {
|
||||
@@ -168,8 +154,15 @@ class StreamManager {
|
||||
Logger.error('Invalid User for client', client)
|
||||
return
|
||||
}
|
||||
client.user.updateAudiobookProgressFromStream(client.stream)
|
||||
var userAudiobook = client.user.updateAudiobookProgressFromStream(client.stream)
|
||||
this.db.updateEntity('user', client.user)
|
||||
|
||||
if (userAudiobook) {
|
||||
this.clientEmitter(client.user.id, 'current_user_audiobook_update', {
|
||||
id: userAudiobook.audiobookId,
|
||||
data: userAudiobook.toJSON()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = StreamManager
|
||||
@@ -0,0 +1,32 @@
|
||||
class AudioBookmark {
|
||||
constructor(bookmark) {
|
||||
this.title = null
|
||||
this.time = null
|
||||
this.createdAt = null
|
||||
|
||||
if (bookmark) {
|
||||
this.construct(bookmark)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
title: this.title || '',
|
||||
time: this.time,
|
||||
createdAt: this.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
construct(bookmark) {
|
||||
this.title = bookmark.title || ''
|
||||
this.time = bookmark.time || 0
|
||||
this.createdAt = bookmark.createdAt
|
||||
}
|
||||
|
||||
setData(time, title) {
|
||||
this.title = title
|
||||
this.time = time
|
||||
this.createdAt = Date.now()
|
||||
}
|
||||
}
|
||||
module.exports = AudioBookmark
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+12
-24
@@ -91,6 +91,10 @@ class Audiobook {
|
||||
return this.book ? this.book.authorLF : null
|
||||
}
|
||||
|
||||
get authorFL() {
|
||||
return this.book ? this.book.authorFL : null
|
||||
}
|
||||
|
||||
get genres() {
|
||||
return this.book ? this.book.genres || [] : []
|
||||
}
|
||||
@@ -127,22 +131,6 @@ class Audiobook {
|
||||
return this.otherFiles.filter(file => file.filetype === 'ebook')
|
||||
}
|
||||
|
||||
get hasEpub() {
|
||||
return this.ebooks.find(file => file.ext === '.epub')
|
||||
}
|
||||
|
||||
get hasMobi() {
|
||||
return this.ebooks.find(file => file.ext === '.mobi' || file.ext === '.azw3')
|
||||
}
|
||||
|
||||
get hasPdf() {
|
||||
return this.ebooks.find(file => file.ext === '.pdf')
|
||||
}
|
||||
|
||||
get hasComic() {
|
||||
return this.ebooks.find(file => file.ext === '.cbr' || file.ext === '.cbz')
|
||||
}
|
||||
|
||||
get hasMissingIno() {
|
||||
return !this.ino || this._audioFiles.find(abf => !abf.ino) || this._otherFiles.find(f => !f.ino) || this._tracks.find(t => !t.ino)
|
||||
}
|
||||
@@ -209,16 +197,14 @@ class Audiobook {
|
||||
lastUpdate: this.lastUpdate,
|
||||
duration: this.totalDuration,
|
||||
size: this.totalSize,
|
||||
hasBookMatch: !!this.book,
|
||||
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
||||
// numEbooks: this.ebooks.length,
|
||||
ebooks: this.ebooks.map(ebook => ebook.toJSON()),
|
||||
numEbooks: (this.hasEpub || this.hasMobi || this.hasPdf || this.hasComic) ? 1 : 0,
|
||||
numEbooks: this.ebooks.length,
|
||||
numTracks: this.tracks.length,
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing,
|
||||
isInvalid: !!this.isInvalid
|
||||
isInvalid: !!this.isInvalid,
|
||||
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,14 +227,16 @@ 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 || this.hasMobi || this.hasPdf || this.hasComic) ? 1 : 0,
|
||||
numEbooks: this.ebooks.length,
|
||||
numTracks: this.tracks.length,
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing,
|
||||
isInvalid: !!this.isInvalid
|
||||
isInvalid: !!this.isInvalid,
|
||||
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
-8
@@ -1,4 +1,3 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const parseAuthors = require('../utils/parseAuthors')
|
||||
@@ -16,6 +15,7 @@ class Book {
|
||||
this.publishYear = null
|
||||
this.publisher = null
|
||||
this.description = null
|
||||
this.isbn = null
|
||||
this.cover = null
|
||||
this.coverFullPath = null
|
||||
this.genres = []
|
||||
@@ -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
|
||||
}
|
||||
@@ -56,6 +56,7 @@ class Book {
|
||||
this.publishYear = book.publishYear
|
||||
this.publisher = book.publisher
|
||||
this.description = book.description
|
||||
this.isbn = book.isbn || null
|
||||
this.cover = book.cover
|
||||
this.coverFullPath = book.coverFullPath || null
|
||||
this.genres = book.genres
|
||||
@@ -78,6 +79,7 @@ class Book {
|
||||
publishYear: this.publishYear,
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
isbn: this.isbn,
|
||||
cover: this.cover,
|
||||
coverFullPath: this.coverFullPath,
|
||||
genres: this.genres,
|
||||
@@ -116,6 +118,7 @@ class Book {
|
||||
this.volumeNumber = data.volumeNumber || null
|
||||
this.publishYear = data.publishYear || null
|
||||
this.description = data.description || null
|
||||
this.isbn = data.isbn || null
|
||||
this.cover = data.cover || null
|
||||
this.coverFullPath = data.coverFullPath || null
|
||||
this.genres = data.genres || []
|
||||
@@ -180,7 +183,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
|
||||
}
|
||||
|
||||
@@ -225,7 +228,7 @@ class Book {
|
||||
var authorMatch = this._author.toLowerCase().includes(search)
|
||||
var seriesMatch = this._series.toLowerCase().includes(search)
|
||||
|
||||
var bookMatchKey = titleMatch ? 'title' : subtitleMatch ? 'subtitle' : authorMatch ? 'author' : seriesMatch ? 'series' : false
|
||||
var bookMatchKey = titleMatch ? 'title' : subtitleMatch ? 'subtitle' : authorMatch ? 'authorFL' : seriesMatch ? 'series' : false
|
||||
var bookMatchText = bookMatchKey ? this[bookMatchKey] : ''
|
||||
return {
|
||||
book: bookMatchKey,
|
||||
@@ -235,6 +238,17 @@ class Book {
|
||||
}
|
||||
}
|
||||
|
||||
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 = [
|
||||
{
|
||||
@@ -260,14 +274,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]}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+39
-17
@@ -16,6 +16,7 @@ class Stream extends EventEmitter {
|
||||
this.audiobook = audiobook
|
||||
|
||||
this.segmentLength = 6
|
||||
this.maxSeekBackTime = 30
|
||||
this.streamPath = Path.join(streamPath, this.id)
|
||||
this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
|
||||
this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
|
||||
@@ -67,7 +68,7 @@ class Stream extends EventEmitter {
|
||||
|
||||
get segmentStartNumber() {
|
||||
if (!this.startTime) return 0
|
||||
return Math.floor(this.startTime / this.segmentLength)
|
||||
return Math.floor(Math.max(this.startTime - this.maxSeekBackTime, 0) / this.segmentLength)
|
||||
}
|
||||
|
||||
get numSegments() {
|
||||
@@ -82,13 +83,26 @@ class Stream extends EventEmitter {
|
||||
return this.audiobook.tracks
|
||||
}
|
||||
|
||||
get clientUser() {
|
||||
return this.client.user || {}
|
||||
}
|
||||
|
||||
get clientUserAudiobooks() {
|
||||
return this.clientUser.audiobooks || {}
|
||||
}
|
||||
|
||||
get clientUserAudiobookData() {
|
||||
return this.clientUserAudiobooks[this.audiobookId]
|
||||
}
|
||||
|
||||
get clientPlaylistUri() {
|
||||
return `/hls/${this.id}/output.m3u8`
|
||||
}
|
||||
|
||||
get clientProgress() {
|
||||
if (!this.clientCurrentTime) return 0
|
||||
return Number((this.clientCurrentTime / this.totalDuration).toFixed(3))
|
||||
var prog = Math.min(1, this.clientCurrentTime / this.totalDuration)
|
||||
return Number(prog.toFixed(3))
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
@@ -103,18 +117,17 @@ class Stream extends EventEmitter {
|
||||
clientCurrentTime: this.clientCurrentTime,
|
||||
startTime: this.startTime,
|
||||
segmentStartNumber: this.segmentStartNumber,
|
||||
isTranscodeComplete: this.isTranscodeComplete
|
||||
isTranscodeComplete: this.isTranscodeComplete,
|
||||
lastUpdate: this.clientUserAudiobookData ? this.clientUserAudiobookData.lastUpdate : 0
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
var clientUserAudiobooks = this.client.user ? this.client.user.audiobooks || {} : {}
|
||||
var userAudiobook = clientUserAudiobooks[this.audiobookId] || null
|
||||
if (userAudiobook) {
|
||||
var timeRemaining = this.totalDuration - userAudiobook.currentTime
|
||||
Logger.info('[STREAM] User has progress for audiobook', userAudiobook.progress, `Time Remaining: ${timeRemaining}s`)
|
||||
if (this.clientUserAudiobookData) {
|
||||
var timeRemaining = this.totalDuration - this.clientUserAudiobookData.currentTime
|
||||
Logger.info('[STREAM] User has progress for audiobook', this.clientUserAudiobookData.progress, `Time Remaining: ${timeRemaining}s`)
|
||||
if (timeRemaining > 15) {
|
||||
this.startTime = userAudiobook.currentTime
|
||||
this.startTime = this.clientUserAudiobookData.currentTime
|
||||
this.clientCurrentTime = this.startTime
|
||||
}
|
||||
}
|
||||
@@ -238,20 +251,28 @@ class Stream extends EventEmitter {
|
||||
|
||||
this.ffmpeg = Ffmpeg()
|
||||
|
||||
var trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, this.startTime)
|
||||
var adjustedStartTime = Math.max(this.startTime - this.maxSeekBackTime, 0)
|
||||
var trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, adjustedStartTime)
|
||||
|
||||
this.ffmpeg.addInput(this.concatFilesPath)
|
||||
// seek_timestamp : https://ffmpeg.org/ffmpeg.html
|
||||
// the argument to the -ss option is considered an actual timestamp, and is not offset by the start time of the file
|
||||
// fixes https://github.com/advplyr/audiobookshelf/issues/116
|
||||
this.ffmpeg.inputOption('-seek_timestamp 1')
|
||||
this.ffmpeg.inputFormat('concat')
|
||||
this.ffmpeg.inputOption('-safe 0')
|
||||
|
||||
if (this.startTime > 0) {
|
||||
const shiftedStartTime = this.startTime - trackStartTime
|
||||
Logger.info(`[STREAM] Starting Stream at startTime ${secondsToTimestamp(this.startTime)} and Segment #${this.segmentStartNumber}`)
|
||||
this.ffmpeg.inputOption(`-ss ${shiftedStartTime}`)
|
||||
if (adjustedStartTime > 0) {
|
||||
const shiftedStartTime = adjustedStartTime - trackStartTime
|
||||
// Issues using exact fractional seconds i.e. 29.49814 - changing to 29.5s
|
||||
var startTimeS = Math.round(shiftedStartTime * 10) / 10 + 's'
|
||||
Logger.info(`[STREAM] Starting Stream at startTime ${secondsToTimestamp(adjustedStartTime)} (User startTime ${secondsToTimestamp(this.startTime)}) and Segment #${this.segmentStartNumber}`)
|
||||
this.ffmpeg.inputOption(`-ss ${startTimeS}`)
|
||||
|
||||
this.ffmpeg.inputOption('-noaccurate_seek')
|
||||
}
|
||||
|
||||
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
||||
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'error'
|
||||
const audioCodec = (this.hlsSegmentType === 'fmp4' || this.tracksAudioFileType === 'opus') ? 'aac' : 'copy'
|
||||
this.ffmpeg.addOption([
|
||||
`-loglevel ${logLevel}`,
|
||||
@@ -261,7 +282,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 +294,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)
|
||||
|
||||
+108
-16
@@ -1,4 +1,5 @@
|
||||
const AudiobookProgress = require('./AudiobookProgress')
|
||||
const Logger = require('../Logger')
|
||||
const UserAudiobookData = require('./UserAudiobookData')
|
||||
|
||||
class User {
|
||||
constructor(user) {
|
||||
@@ -16,6 +17,7 @@ class User {
|
||||
|
||||
this.settings = {}
|
||||
this.permissions = {}
|
||||
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
|
||||
|
||||
if (user) {
|
||||
this.construct(user)
|
||||
@@ -37,6 +39,9 @@ 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
|
||||
}
|
||||
@@ -59,7 +64,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +94,8 @@ class User {
|
||||
lastSeen: this.lastSeen,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings,
|
||||
permissions: this.permissions
|
||||
permissions: this.permissions,
|
||||
librariesAccessible: [...this.librariesAccessible]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +112,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 {
|
||||
@@ -132,7 +141,7 @@ class User {
|
||||
this.audiobooks = {}
|
||||
for (const key in user.audiobooks) {
|
||||
if (user.audiobooks[key]) {
|
||||
this.audiobooks[key] = new AudiobookProgress(user.audiobooks[key])
|
||||
this.audiobooks[key] = new UserAudiobookData(user.audiobooks[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +153,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) {
|
||||
@@ -169,24 +183,42 @@ 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
|
||||
}
|
||||
|
||||
updateAudiobookProgressFromStream(stream) {
|
||||
if (!this.audiobooks) this.audiobooks = {}
|
||||
if (!this.audiobooks[stream.audiobookId]) {
|
||||
this.audiobooks[stream.audiobookId] = new AudiobookProgress()
|
||||
this.audiobooks[stream.audiobookId] = new UserAudiobookData()
|
||||
}
|
||||
this.audiobooks[stream.audiobookId].updateFromStream(stream)
|
||||
this.audiobooks[stream.audiobookId].updateProgressFromStream(stream)
|
||||
return this.audiobooks[stream.audiobookId]
|
||||
}
|
||||
|
||||
updateAudiobookProgress(audiobookId, updatePayload) {
|
||||
updateAudiobookData(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 UserAudiobookData()
|
||||
this.audiobooks[audiobook.id].audiobookId = audiobook.id
|
||||
}
|
||||
return this.audiobooks[audiobookId].update(updatePayload)
|
||||
var wasUpdated = this.audiobooks[audiobook.id].update(updatePayload)
|
||||
if (wasUpdated) {
|
||||
Logger.debug(`[User] UserAudiobookData was updated ${JSON.stringify(this.audiobooks[audiobook.id])}`)
|
||||
return this.audiobooks[audiobook.id]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Returns Boolean If update was made
|
||||
@@ -215,11 +247,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.updateAudiobookData(audiobook, {
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
isRead: false,
|
||||
@@ -229,12 +261,72 @@ class User {
|
||||
})
|
||||
}
|
||||
|
||||
deleteAudiobookProgress(audiobookId) {
|
||||
deleteAudiobookData(audiobookId) {
|
||||
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
|
||||
return false
|
||||
}
|
||||
delete this.audiobooks[audiobookId]
|
||||
return true
|
||||
}
|
||||
|
||||
checkCanAccessLibrary(libraryId) {
|
||||
if (this.permissions.accessAllLibraries) return true
|
||||
if (!this.librariesAccessible) return false
|
||||
return this.librariesAccessible.includes(libraryId)
|
||||
}
|
||||
|
||||
getAudiobookJSON(audiobookId) {
|
||||
return this.audiobooks[audiobookId] ? this.audiobooks[audiobookId].toJSON() : null
|
||||
}
|
||||
|
||||
createBookmark({ audiobookId, time, title }) {
|
||||
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
|
||||
return {
|
||||
error: 'Invalid Audiobook'
|
||||
}
|
||||
}
|
||||
if (this.audiobooks[audiobookId].checkBookmarkExists(time)) {
|
||||
return {
|
||||
error: 'Bookmark already exists'
|
||||
}
|
||||
}
|
||||
|
||||
var success = this.audiobooks[audiobookId].createBookmark(time, title)
|
||||
if (success) return this.audiobooks[audiobookId]
|
||||
return null
|
||||
}
|
||||
|
||||
updateBookmark({ audiobookId, time, title }) {
|
||||
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
|
||||
return {
|
||||
error: 'Invalid Audiobook'
|
||||
}
|
||||
}
|
||||
if (!this.audiobooks[audiobookId].checkBookmarkExists(time)) {
|
||||
return {
|
||||
error: 'Bookmark does not exist'
|
||||
}
|
||||
}
|
||||
|
||||
var success = this.audiobooks[audiobookId].updateBookmark(time, title)
|
||||
if (success) return this.audiobooks[audiobookId]
|
||||
return null
|
||||
}
|
||||
|
||||
deleteBookmark({ audiobookId, time }) {
|
||||
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
|
||||
return {
|
||||
error: 'Invalid Audiobook'
|
||||
}
|
||||
}
|
||||
if (!this.audiobooks[audiobookId].checkBookmarkExists(time)) {
|
||||
return {
|
||||
error: 'Bookmark does not exist'
|
||||
}
|
||||
}
|
||||
|
||||
this.audiobooks[audiobookId].deleteBookmark(time)
|
||||
return this.audiobooks[audiobookId]
|
||||
}
|
||||
}
|
||||
module.exports = User
|
||||
@@ -1,7 +1,11 @@
|
||||
class AudiobookProgress {
|
||||
const Logger = require('../Logger')
|
||||
const AudioBookmark = require('./AudioBookmark')
|
||||
|
||||
class UserAudiobookData {
|
||||
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
|
||||
@@ -9,29 +13,40 @@ class AudiobookProgress {
|
||||
this.lastUpdate = null
|
||||
this.startedAt = null
|
||||
this.finishedAt = null
|
||||
this.bookmarks = []
|
||||
|
||||
if (progress) {
|
||||
this.construct(progress)
|
||||
}
|
||||
}
|
||||
|
||||
bookmarksToJSON() {
|
||||
if (!this.bookmarks) return []
|
||||
return this.bookmarks.filter((b) => {
|
||||
if (!b.toJSON) {
|
||||
Logger.error(`[UserAudiobookData] Invalid bookmark ${JSON.stringify(b)}`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}).map(b => b.toJSON())
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
audiobookId: this.audiobookId,
|
||||
audiobookTitle: this.audiobookTitle,
|
||||
totalDuration: this.totalDuration,
|
||||
progress: this.progress,
|
||||
currentTime: this.currentTime,
|
||||
isRead: this.isRead,
|
||||
lastUpdate: this.lastUpdate,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt
|
||||
finishedAt: this.finishedAt,
|
||||
bookmarks: this.bookmarksToJSON()
|
||||
}
|
||||
}
|
||||
|
||||
construct(progress) {
|
||||
this.audiobookId = progress.audiobookId
|
||||
this.audiobookTitle = progress.audiobookTitle || null
|
||||
this.totalDuration = progress.totalDuration
|
||||
this.progress = progress.progress
|
||||
this.currentTime = progress.currentTime
|
||||
@@ -39,11 +54,15 @@ class AudiobookProgress {
|
||||
this.lastUpdate = progress.lastUpdate
|
||||
this.startedAt = progress.startedAt
|
||||
this.finishedAt = progress.finishedAt || null
|
||||
if (progress.bookmarks) {
|
||||
this.bookmarks = progress.bookmarks.map(b => new AudioBookmark(b))
|
||||
} else {
|
||||
this.bookmarks = []
|
||||
}
|
||||
}
|
||||
|
||||
updateFromStream(stream) {
|
||||
updateProgressFromStream(stream) {
|
||||
this.audiobookId = stream.audiobookId
|
||||
this.audiobookTitle = stream.audiobookTitle
|
||||
this.totalDuration = stream.totalDuration
|
||||
this.progress = stream.clientProgress
|
||||
this.currentTime = stream.clientCurrentTime
|
||||
@@ -56,11 +75,9 @@ class AudiobookProgress {
|
||||
// If has < 10 seconds remaining mark as read
|
||||
var timeRemaining = this.totalDuration - this.currentTime
|
||||
if (timeRemaining < 10) {
|
||||
if (!this.isRead) {
|
||||
this.isRead = true
|
||||
this.progress = 1
|
||||
this.finishedAt = Date.now()
|
||||
}
|
||||
this.isRead = true
|
||||
this.progress = 1
|
||||
this.finishedAt = Date.now()
|
||||
} else {
|
||||
this.isRead = false
|
||||
this.finishedAt = null
|
||||
@@ -69,6 +86,7 @@ class AudiobookProgress {
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
Logger.debug(`[UserAudiobookData] Update called ${JSON.stringify(payload)}`)
|
||||
for (const key in payload) {
|
||||
if (payload[key] !== this[key]) {
|
||||
if (key === 'isRead') {
|
||||
@@ -89,7 +107,32 @@ class AudiobookProgress {
|
||||
if (!this.startedAt) {
|
||||
this.startedAt = Date.now()
|
||||
}
|
||||
if (hasUpdates) {
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
checkBookmarkExists(time) {
|
||||
return this.bookmarks.find(bm => bm.time === time)
|
||||
}
|
||||
|
||||
createBookmark(time, title) {
|
||||
var newBookmark = new AudioBookmark()
|
||||
newBookmark.setData(time, title)
|
||||
this.bookmarks.push(newBookmark)
|
||||
return newBookmark
|
||||
}
|
||||
|
||||
updateBookmark(time, title) {
|
||||
var bookmark = this.bookmarks.find(bm => bm.time === time)
|
||||
if (!bookmark) return false
|
||||
bookmark.title = title
|
||||
return bookmark
|
||||
}
|
||||
|
||||
deleteBookmark(time) {
|
||||
this.bookmarks = this.bookmarks.filter(bm => bm.time !== time)
|
||||
}
|
||||
}
|
||||
module.exports = AudiobookProgress
|
||||
module.exports = UserAudiobookData
|
||||
@@ -0,0 +1,50 @@
|
||||
const axios = require('axios')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
class GoogleBooks {
|
||||
constructor() { }
|
||||
|
||||
extractIsbn(industryIdentifiers) {
|
||||
if (!industryIdentifiers || !industryIdentifiers.length) return null
|
||||
|
||||
var isbnObj = industryIdentifiers.find(i => i.type === 'ISBN_13') || industryIdentifiers.find(i => i.type === 'ISBN_10')
|
||||
if (isbnObj && isbnObj.identifier) return isbnObj.identifier
|
||||
return null
|
||||
}
|
||||
|
||||
cleanResult(item) {
|
||||
var { id, volumeInfo } = item
|
||||
if (!volumeInfo) return null
|
||||
var { title, subtitle, authors, publisher, publisherDate, description, industryIdentifiers, categories, imageLinks } = volumeInfo
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
subtitle: subtitle || null,
|
||||
author: authors ? authors.join(', ') : null,
|
||||
publisher,
|
||||
publishYear: publisherDate ? publisherDate.split('-')[0] : null,
|
||||
description,
|
||||
cover: imageLinks && imageLinks.thumbnail ? imageLinks.thumbnail : null,
|
||||
genres: categories ? categories.join(', ') : null,
|
||||
isbn: this.extractIsbn(industryIdentifiers)
|
||||
}
|
||||
}
|
||||
|
||||
async search(title, author) {
|
||||
var queryString = `q=intitle:${title}`
|
||||
if (author) queryString += `+inauthor:${author}`
|
||||
var url = `https://www.googleapis.com/books/v1/volumes?${queryString}`
|
||||
Logger.debug(`[GoogleBooks] Search url: ${url}`)
|
||||
var items = await axios.get(url).then((res) => {
|
||||
if (!res || !res.data || !res.data.items) return []
|
||||
return res.data.items
|
||||
}).catch(error => {
|
||||
Logger.error('[GoogleBooks] Volume search error', error)
|
||||
return []
|
||||
})
|
||||
return items.map(item => this.cleanResult(item))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GoogleBooks
|
||||
@@ -51,12 +51,21 @@ class OpenLibrary {
|
||||
}
|
||||
}
|
||||
|
||||
parsePublishYear(doc, worksData) {
|
||||
if (doc.first_publish_year && !isNaN(doc.first_publish_year)) return doc.first_publish_year
|
||||
if (worksData.first_publish_date) {
|
||||
var year = worksData.first_publish_date.split('-')[0]
|
||||
if (!isNaN(year)) return year
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async cleanSearchDoc(doc) {
|
||||
var worksData = await this.getWorksData(doc.key)
|
||||
return {
|
||||
title: doc.title,
|
||||
author: doc.author_name ? doc.author_name.join(', ') : null,
|
||||
year: doc.first_publish_year,
|
||||
publishYear: this.parsePublishYear(doc, worksData),
|
||||
edition: doc.cover_edition_key,
|
||||
cover: doc.cover_edition_key ? `https://covers.openlibrary.org/b/OLID/${doc.cover_edition_key}-L.jpg` : null,
|
||||
...worksData
|
||||
|
||||
@@ -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)
|
||||
@@ -119,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,
|
||||
@@ -127,7 +163,8 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
ext: audioFile.ext,
|
||||
...scanData,
|
||||
trackNumFromMeta,
|
||||
trackNumFromFilename
|
||||
trackNumFromFilename,
|
||||
cdNumFromFilename
|
||||
}
|
||||
var audioFile = audiobook.addAudioFile(audioFileObj)
|
||||
|
||||
@@ -168,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
|
||||
@@ -205,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)
|
||||
@@ -239,7 +297,7 @@ async function scanTrackNumbers(audiobook) {
|
||||
var scannedTrackNumData = []
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
var track = tracks[i]
|
||||
var scanData = await scan(track.fullPath)
|
||||
var scanData = await scan(track.fullPath, true)
|
||||
|
||||
var trackNumFromMeta = getTrackNumberFromMeta(scanData)
|
||||
var book = audiobook.book || {}
|
||||
@@ -250,7 +308,8 @@ async function scanTrackNumbers(audiobook) {
|
||||
currentTrackNum: track.index,
|
||||
trackNumFromFilename,
|
||||
trackNumFromMeta,
|
||||
scanDataTrackNum: scanData.file_tag_track
|
||||
scanDataTrackNum: scanData.file_tag_track,
|
||||
rawTags: scanData.rawTags || null
|
||||
})
|
||||
}
|
||||
return scannedTrackNumData
|
||||
|
||||
@@ -69,7 +69,7 @@ function secondsToTimestamp(seconds) {
|
||||
_seconds -= _minutes * 60
|
||||
var _hours = Math.floor(_minutes / 60)
|
||||
_minutes -= _hours * 60
|
||||
_seconds = Math.round(_seconds)
|
||||
_seconds = Math.floor(_seconds)
|
||||
if (!_hours) {
|
||||
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -24,34 +24,42 @@ function checkIsALastName(name) {
|
||||
|
||||
module.exports = (author) => {
|
||||
if (!author) return null
|
||||
var splitByComma = author.split(', ')
|
||||
|
||||
var splitAuthors = []
|
||||
// Example &LF: Friedman, Milton & Friedman, Rose
|
||||
if (author.includes('&')) {
|
||||
author.split('&').forEach((asa) => splitAuthors = splitAuthors.concat(asa.split(',')))
|
||||
} else {
|
||||
splitAuthors = author.split(',')
|
||||
}
|
||||
if (splitAuthors.length) splitAuthors = splitAuthors.map(a => a.trim())
|
||||
|
||||
var authors = []
|
||||
|
||||
// 1 author FIRST LAST
|
||||
if (splitByComma.length === 1) {
|
||||
if (splitAuthors.length === 1) {
|
||||
authors.push(parseName(author))
|
||||
} else {
|
||||
var firstChunkIsALastName = checkIsALastName(splitByComma[0])
|
||||
var isEvenNum = splitByComma.length % 2 === 0
|
||||
var firstChunkIsALastName = checkIsALastName(splitAuthors[0])
|
||||
var isEvenNum = splitAuthors.length % 2 === 0
|
||||
|
||||
if (!isEvenNum && firstChunkIsALastName) {
|
||||
// console.error('Multi-author LAST,FIRST entry has a straggler (could be roman numerals or a suffix), ignore it', splitByComma[splitByComma.length - 1])
|
||||
splitByComma = splitByComma.slice(0, splitByComma.length - 1)
|
||||
splitAuthors = splitAuthors.slice(0, splitAuthors.length - 1)
|
||||
}
|
||||
|
||||
if (firstChunkIsALastName) {
|
||||
var numAuthors = splitByComma.length / 2
|
||||
var numAuthors = splitAuthors.length / 2
|
||||
for (let i = 0; i < numAuthors; i++) {
|
||||
var last = splitByComma.shift()
|
||||
var first = splitByComma.shift()
|
||||
var last = splitAuthors.shift()
|
||||
var first = splitAuthors.shift()
|
||||
authors.push({
|
||||
first_name: first,
|
||||
last_name: last
|
||||
})
|
||||
}
|
||||
} else {
|
||||
splitByComma.forEach((segment) => {
|
||||
splitAuthors.forEach((segment) => {
|
||||
authors.push(parseName(segment))
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user