This commit is contained in:
Mark Cooper 2021-08-17 17:01:11 -05:00
commit a0c60a93ba
106 changed files with 26925 additions and 0 deletions

View file

@ -0,0 +1,222 @@
<template>
<div class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''">
<div v-show="saving" class="absolute z-20 w-full h-full flex items-center justify-center">
<ui-loading-indicator />
</div>
<div class="w-full h-full overflow-y-auto p-8">
<div class="w-full flex justify-between items-center pb-6 pt-2">
<p class="text-lg">Drag files into correct track order</p>
<ui-btn color="success" @click="saveTracklist">Save Tracklist</ui-btn>
</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-mono w-20 text-center">Size</div>
<div class="font-mono w-20 text-center">Duration</div>
<div class="font-mono text-center w-20">Status</div>
<div class="font-mono w-56">Notes</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">
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
<li v-for="(audio, index) in files" :key="audio.path" class="w-full list-group-item item flex items-center">
<div class="font-book text-center px-4 py-1 w-12">
{{ index + 1 }}
</div>
<div class="font-book text-center px-4 w-12">
{{ audio.index }}
</div>
<div class="font-book text-center px-2 w-40">
{{ audio.trackNumFromFilename }}
</div>
<div class="font-book text-center w-40">
{{ audio.trackNumFromMeta }}
</div>
<div class="font-book truncate px-4 flex-grow">
{{ audio.filename }}
</div>
<div class="font-mono w-20 text-center">
{{ $bytesPretty(audio.size) }}
</div>
<div class="font-mono w-20">
{{ $secondsToTimestamp(audio.duration) }}
</div>
<div class="font-mono text-center w-20">
<span class="material-icons text-sm" :class="audio.invalid ? 'text-error' : 'text-success'">{{ getStatusIcon(audio) }}</span>
</div>
<div class="font-sans text-xs font-normal w-56">
{{ audio.error }}
</div>
</li>
</transition-group>
</draggable>
</div>
</div>
</template>
<script>
import draggable from 'vuedraggable'
export default {
components: {
draggable
},
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user) {
return redirect(`/login?redirect=${route.path}`)
}
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
console.error('Failed', error)
return false
})
if (!audiobook) {
console.error('No audiobook...', params.id)
return redirect('/')
}
let index = 0
return {
audiobook,
files: audiobook.audioFiles ? audiobook.audioFiles.map((af) => ({ ...af, index: ++index })) : []
}
},
data() {
return {
drag: false,
dragOptions: {
animation: 200,
group: 'description',
ghostClass: 'ghost'
},
saving: false
}
},
computed: {
audioFiles() {
return this.audiobook.audioFiles || []
},
missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0]
var chunks = []
var currentIndex = this.missingParts[0]
var currentChunk = [this.missingParts[0]]
for (let i = 1; i < this.missingParts.length; i++) {
var partIndex = this.missingParts[i]
if (currentIndex === partIndex - 1) {
currentChunk.push(partIndex)
currentIndex = partIndex
} else {
// console.log('Chunk ended', currentChunk.join(', '), currentIndex, partIndex)
if (currentChunk.length === 0) {
console.error('How is current chunk 0?', currentChunk.join(', '))
}
chunks.push(currentChunk)
currentChunk = [partIndex]
currentIndex = partIndex
}
}
if (currentChunk.length) {
chunks.push(currentChunk)
}
chunks = chunks.map((chunk) => {
if (chunk.length === 1) return chunk[0]
else return `${chunk[0]}-${chunk[chunk.length - 1]}`
})
return chunks
},
missingParts() {
return this.audiobook.missingParts || []
},
invalidParts() {
return this.audiobook.invalidParts || []
},
audiobookId() {
return this.audiobook.id
},
title() {
return this.book.title || 'No Title'
},
author() {
return this.book.author || 'Unknown'
},
tracks() {
return this.audiobook.tracks
},
durationPretty() {
return this.audiobook.durationPretty
},
sizePretty() {
return this.audiobook.sizePretty
},
book() {
return this.audiobook.book || {}
},
tracks() {
return this.audiobook.tracks || []
},
streamAudiobook() {
return this.$store.state.streamAudiobook
}
},
methods: {
saveTracklist() {
console.log('Tracklist', this.files)
this.saving = true
this.$axios
.$patch(`/api/audiobook/${this.audiobook.id}/tracks`, { files: this.files })
.then((data) => {
console.log('Finished patching files', data)
this.saving = false
// this.$router.go()
this.$toast.success('Tracks Updated')
this.$router.push(`/audiobook/${this.audiobookId}`)
})
.catch((error) => {
console.error('Failed', error)
this.saving = false
})
},
getStatusIcon(audio) {
if (audio.invalid) {
return 'error_outline'
} else {
return 'check_circle'
}
}
},
mounted() {}
}
</script>
<style>
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.ghost {
opacity: 0.5;
background-color: rgba(255, 255, 255, 0.25);
}
.list-group {
min-height: 30px;
}
.list-group-item {
cursor: n-resize;
}
.list-group-item:not(.ghost):hover {
background-color: rgba(0, 0, 0, 0.1);
}
.list-group-item:nth-child(even):not(.ghost) {
background-color: rgba(0, 0, 0, 0.25);
}
.list-group-item:nth-child(even):not(.ghost):hover {
background-color: rgba(0, 0, 0, 0.1);
}
</style>

View file

@ -0,0 +1,220 @@
<template>
<div class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full h-full overflow-y-auto p-8">
<div class="flex max-w-6xl mx-auto">
<div class="w-52" style="min-width: 208px">
<div class="relative">
<cards-book-cover :audiobook="audiobook" :width="208" />
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: 240 * progressPercent + 'px' }"></div>
</div>
</div>
<div class="flex-grow px-10">
<div class="flex">
<h1 class="text-2xl">{{ title }}</h1>
<div class="flex-grow" />
</div>
<p class="text-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
</p>
<div class="flex items-center pt-4">
<ui-btn color="success" :padding-x="4" class="flex items-center" @click="startStream">
<span class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
Play
</ui-btn>
<ui-btn :padding-x="4" class="flex items-center ml-4" @click="editClick"><span class="material-icons text-white pr-2" style="font-size: 18px">edit</span>Edit</ui-btn>
<div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 ml-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
</div>
</div>
</div>
<p class="text-sm my-4 text-gray-100">{{ description }}</p>
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2">
Missing Parts <span class="text-sm">({{ missingParts.length }})</span>
</p>
<p class="text-sm font-mono">{{ missingPartChunks.join(', ') }}</p>
</div>
<div v-if="invalidParts.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2">
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
</p>
<p class="text-sm font-mono">{{ invalidParts.join(', ') }}</p>
</div>
<app-tracks-table :tracks="tracks" :audiobook-id="audiobook.id" class="mt-6" />
</div>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user) {
return redirect(`/login?redirect=${route.path}`)
}
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
console.error('Failed', error)
return false
})
if (!audiobook) {
console.error('No audiobook...', params.id)
return redirect('/')
}
return {
audiobook
}
},
data() {
return {
resettingProgress: false
}
},
computed: {
missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0]
var chunks = []
var currentIndex = this.missingParts[0]
var currentChunk = [this.missingParts[0]]
for (let i = 1; i < this.missingParts.length; i++) {
var partIndex = this.missingParts[i]
if (currentIndex === partIndex - 1) {
currentChunk.push(partIndex)
currentIndex = partIndex
} else {
// console.log('Chunk ended', currentChunk.join(', '), currentIndex, partIndex)
if (currentChunk.length === 0) {
console.error('How is current chunk 0?', currentChunk.join(', '))
}
chunks.push(currentChunk)
currentChunk = [partIndex]
currentIndex = partIndex
}
}
if (currentChunk.length) {
chunks.push(currentChunk)
}
chunks = chunks.map((chunk) => {
if (chunk.length === 1) return chunk[0]
else return `${chunk[0]}-${chunk[chunk.length - 1]}`
})
return chunks
},
missingParts() {
return this.audiobook.missingParts || []
},
invalidParts() {
return this.audiobook.invalidParts || []
},
audiobookId() {
return this.audiobook.id
},
title() {
return this.book.title || 'No Title'
},
author() {
return this.book.author || 'Unknown'
},
durationPretty() {
return this.audiobook.durationPretty
},
duration() {
return this.audiobook.duration
},
sizePretty() {
return this.audiobook.sizePretty
},
book() {
return this.audiobook.book || {}
},
tracks() {
return this.audiobook.tracks || []
},
description() {
return this.book.description || 'No Description'
},
userAudiobooks() {
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
},
userAudiobook() {
return this.userAudiobooks[this.audiobookId] || null
},
userCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime : 0
},
userTimeRemaining() {
return this.duration - this.userCurrentTime
},
progressPercent() {
return this.userAudiobook ? this.userAudiobook.progress : 0
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
isStreaming() {
return this.streamAudiobook && this.streamAudiobook.id === this.audiobookId
}
},
methods: {
startStream() {
this.$store.commit('setStreamAudiobook', this.audiobook)
this.$root.socket.emit('open_stream', this.audiobook.id)
},
editClick() {
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
.$get(`/api/audiobook/${this.audiobookId}`)
.then((audiobook) => {
this.audiobook = audiobook
})
.catch((error) => {
console.error('Failed', error)
})
},
clearProgressClick() {
if (confirm(`Are you sure you want to reset your progress?`)) {
this.resettingProgress = true
this.$axios
.$delete(`/api/user/audiobook/${this.audiobookId}`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
this.resettingProgress = false
})
.catch((error) => {
console.error('Progress reset failed', error)
this.resettingProgress = false
})
}
}
},
mounted() {
this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.audiobookId, meth: this.audiobookUpdated })
},
beforeDestroy() {
this.$store.commit('audiobooks/removeListener', 'audiobook')
}
}
</script>

View file

@ -0,0 +1,32 @@
<template>
<div class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full max-w-4xl mx-auto">
<h1 class="text-2xl mb-2">Config</h1>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="p-4 text-center h-40">
<p>Nothing much here yet...</p>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4">
<p class="text-2xl">Scanner</p>
<div class="flex-grow" />
<ui-btn color="success" @click="scan">Scan</ui-btn>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {
scan() {
this.$root.socket.emit('scan')
}
},
mounted() {}
}
</script>

21
client/pages/index.vue Normal file
View file

@ -0,0 +1,21 @@
<template>
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
<app-book-shelf />
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
}
},
methods: {},
mounted() {},
beforeDestroy() {}
}
</script>

108
client/pages/login.vue Normal file
View file

@ -0,0 +1,108 @@
<template>
<div class="w-full h-screen bg-bg">
<div class="w-full flex h-1/2 items-center justify-center">
<div class="w-full max-w-md border border-opacity-0 rounded-xl px-8 pb-8 pt-4">
<p class="text-3xl text-white text-center mb-4">Login</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
<form @submit.prevent="submitForm">
<label class="text-xs text-gray-300 uppercase">Username</label>
<ui-text-input v-model="username" :disabled="processing" class="mb-3 w-full" />
<label class="text-xs text-gray-300 uppercase">Password</label>
<ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" />
<div class="w-full flex justify-end">
<button type="submit" :disabled="processing" class="bg-blue-600 hover:bg-blue-800 px-8 py-1 mt-3 rounded-md text-white text-center transition duration-300 ease-in-out focus:outline-none">{{ processing ? 'Checking...' : 'Submit' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
layout: 'blank',
data() {
return {
error: null,
processing: false,
username: 'root',
password: null
}
},
watch: {
user(newVal) {
if (newVal) {
// if (process.env.NODE_ENV !== 'production') {
if (this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect)
} else {
this.$router.replace('/')
}
// } else {
// window.location.reload()
// }
}
}
},
computed: {
user() {
return this.$store.state.user
}
},
methods: {
async submitForm() {
this.error = null
this.processing = true
// var uri = `${process.env.serverUrl}/auth`
var payload = {
username: this.username,
password: this.password || ''
}
var authRes = await this.$axios.$post('/login', payload).catch((error) => {
console.error('Failed', error)
return false
})
console.log('Auth res', authRes)
if (!authRes) {
this.error = 'Unknown Failure'
} else if (authRes.error) {
this.error = authRes.error
} else {
this.$store.commit('setUser', authRes.user)
}
this.processing = false
},
checkAuth() {
if (localStorage.getItem('token')) {
var token = localStorage.getItem('token')
if (token) {
this.processing = true
console.log('Authorize', token)
this.$axios
.$post('/api/authorize', null, {
headers: {
Authorization: `Bearer ${token}`
}
})
.then((res) => {
this.$store.commit('setUser', res.user)
this.processing = false
})
.catch((error) => {
console.error('Authorize error', error)
this.processing = false
})
}
}
}
},
mounted() {
this.checkAuth()
}
}
</script>