mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-02-04 20:40:00 +01:00
Init
This commit is contained in:
commit
6930e69b55
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
/config
|
||||||
|
/audiobooks
|
||||||
|
/metadata
|
||||||
|
dev.js
|
||||||
|
/test/
|
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
dev.js
|
||||||
|
node_modules/
|
||||||
|
/config/
|
||||||
|
/audiobooks/
|
||||||
|
/metadata/
|
||||||
|
/test/
|
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
### STAGE 0: FFMPEG ###
|
||||||
|
FROM jrottenberg/ffmpeg:4.1-alpine AS ffmpeg
|
||||||
|
# FROM alfg/ffmpeg AS ffmpeg
|
||||||
|
|
||||||
|
### STAGE 1: Build client ###
|
||||||
|
FROM node:12-alpine AS build
|
||||||
|
WORKDIR /client
|
||||||
|
COPY /client /client
|
||||||
|
RUN npm install
|
||||||
|
RUN npm run generate
|
||||||
|
|
||||||
|
### STAGE 2: Build server ###
|
||||||
|
FROM node:12-alpine
|
||||||
|
# RUN apk add --no-cache ffmpeg
|
||||||
|
# RUN apt-get install -y ffmpeg
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV LOG_LEVEL=INFO
|
||||||
|
COPY --from=build /client/dist /client/dist
|
||||||
|
COPY --from=ffmpeg / /
|
||||||
|
COPY index.js index.js
|
||||||
|
COPY package.json package.json
|
||||||
|
COPY server server
|
||||||
|
RUN npm install --production
|
||||||
|
EXPOSE 80
|
||||||
|
# CMD ["node", "index.js"]
|
||||||
|
CMD ["npm", "start"]
|
213
client/.nuxt/App.js
Normal file
213
client/.nuxt/App.js
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import { decode, parsePath, withoutBase, withoutTrailingSlash, normalizeURL } from 'ufo'
|
||||||
|
|
||||||
|
import { getMatchedComponentsInstances, getChildrenComponentInstancesUsingFetch, promisify, globalHandleError, urlJoin, sanitizeComponent } from './utils'
|
||||||
|
import NuxtError from './components/nuxt-error.vue'
|
||||||
|
import NuxtLoading from './components/nuxt-loading.vue'
|
||||||
|
import NuxtBuildIndicator from './components/nuxt-build-indicator'
|
||||||
|
|
||||||
|
import '..\\node_modules\\@nuxtjs\\tailwindcss\\dist\\runtime\\tailwind.css'
|
||||||
|
|
||||||
|
import '..\\assets\\app.css'
|
||||||
|
|
||||||
|
import _77180f1e from '..\\layouts\\blank.vue'
|
||||||
|
import _6f6c098b from '..\\layouts\\default.vue'
|
||||||
|
|
||||||
|
const layouts = { "_blank": sanitizeComponent(_77180f1e),"_default": sanitizeComponent(_6f6c098b) }
|
||||||
|
|
||||||
|
export default {
|
||||||
|
render (h, props) {
|
||||||
|
const loadingEl = h('NuxtLoading', { ref: 'loading' })
|
||||||
|
|
||||||
|
const layoutEl = h(this.layout || 'nuxt')
|
||||||
|
const templateEl = h('div', {
|
||||||
|
domProps: {
|
||||||
|
id: '__layout'
|
||||||
|
},
|
||||||
|
key: this.layoutName
|
||||||
|
}, [layoutEl])
|
||||||
|
|
||||||
|
const transitionEl = h('transition', {
|
||||||
|
props: {
|
||||||
|
name: 'layout',
|
||||||
|
mode: 'out-in'
|
||||||
|
},
|
||||||
|
on: {
|
||||||
|
beforeEnter (el) {
|
||||||
|
// Ensure to trigger scroll event after calling scrollBehavior
|
||||||
|
window.$nuxt.$nextTick(() => {
|
||||||
|
window.$nuxt.$emit('triggerScroll')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [templateEl])
|
||||||
|
|
||||||
|
return h('div', {
|
||||||
|
domProps: {
|
||||||
|
id: '__nuxt'
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
loadingEl,
|
||||||
|
h(NuxtBuildIndicator),
|
||||||
|
transitionEl
|
||||||
|
])
|
||||||
|
},
|
||||||
|
|
||||||
|
data: () => ({
|
||||||
|
isOnline: true,
|
||||||
|
|
||||||
|
layout: null,
|
||||||
|
layoutName: '',
|
||||||
|
|
||||||
|
nbFetching: 0
|
||||||
|
}),
|
||||||
|
|
||||||
|
beforeCreate () {
|
||||||
|
Vue.util.defineReactive(this, 'nuxt', this.$options.nuxt)
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
// Add this.$nuxt in child instances
|
||||||
|
this.$root.$options.$nuxt = this
|
||||||
|
|
||||||
|
if (process.client) {
|
||||||
|
// add to window so we can listen when ready
|
||||||
|
window.$nuxt = this
|
||||||
|
|
||||||
|
this.refreshOnlineStatus()
|
||||||
|
// Setup the listeners
|
||||||
|
window.addEventListener('online', this.refreshOnlineStatus)
|
||||||
|
window.addEventListener('offline', this.refreshOnlineStatus)
|
||||||
|
}
|
||||||
|
// Add $nuxt.error()
|
||||||
|
this.error = this.nuxt.error
|
||||||
|
// Add $nuxt.context
|
||||||
|
this.context = this.$options.context
|
||||||
|
},
|
||||||
|
|
||||||
|
async mounted () {
|
||||||
|
this.$loading = this.$refs.loading
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
'nuxt.err': 'errorChanged'
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
isOffline () {
|
||||||
|
return !this.isOnline
|
||||||
|
},
|
||||||
|
|
||||||
|
isFetching () {
|
||||||
|
return this.nbFetching > 0
|
||||||
|
},
|
||||||
|
|
||||||
|
isPreview () {
|
||||||
|
return Boolean(this.$options.previewData)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
refreshOnlineStatus () {
|
||||||
|
if (process.client) {
|
||||||
|
if (typeof window.navigator.onLine === 'undefined') {
|
||||||
|
// If the browser doesn't support connection status reports
|
||||||
|
// assume that we are online because most apps' only react
|
||||||
|
// when they now that the connection has been interrupted
|
||||||
|
this.isOnline = true
|
||||||
|
} else {
|
||||||
|
this.isOnline = window.navigator.onLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh () {
|
||||||
|
const pages = getMatchedComponentsInstances(this.$route)
|
||||||
|
|
||||||
|
if (!pages.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$loading.start()
|
||||||
|
|
||||||
|
const promises = pages.map((page) => {
|
||||||
|
const p = []
|
||||||
|
|
||||||
|
// Old fetch
|
||||||
|
if (page.$options.fetch && page.$options.fetch.length) {
|
||||||
|
p.push(promisify(page.$options.fetch, this.context))
|
||||||
|
}
|
||||||
|
if (page.$fetch) {
|
||||||
|
p.push(page.$fetch())
|
||||||
|
} else {
|
||||||
|
// Get all component instance to call $fetch
|
||||||
|
for (const component of getChildrenComponentInstancesUsingFetch(page.$vnode.componentInstance)) {
|
||||||
|
p.push(component.$fetch())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.$options.asyncData) {
|
||||||
|
p.push(
|
||||||
|
promisify(page.$options.asyncData, this.context)
|
||||||
|
.then((newData) => {
|
||||||
|
for (const key in newData) {
|
||||||
|
Vue.set(page.$data, key, newData[key])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(p)
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await Promise.all(promises)
|
||||||
|
} catch (error) {
|
||||||
|
this.$loading.fail(error)
|
||||||
|
globalHandleError(error)
|
||||||
|
this.error(error)
|
||||||
|
}
|
||||||
|
this.$loading.finish()
|
||||||
|
},
|
||||||
|
errorChanged () {
|
||||||
|
if (this.nuxt.err) {
|
||||||
|
if (this.$loading) {
|
||||||
|
if (this.$loading.fail) {
|
||||||
|
this.$loading.fail(this.nuxt.err)
|
||||||
|
}
|
||||||
|
if (this.$loading.finish) {
|
||||||
|
this.$loading.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorLayout = (NuxtError.options || NuxtError).layout;
|
||||||
|
|
||||||
|
if (typeof errorLayout === 'function') {
|
||||||
|
errorLayout = errorLayout(this.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setLayout(errorLayout)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setLayout (layout) {
|
||||||
|
if(layout && typeof layout !== 'string') {
|
||||||
|
throw new Error('[nuxt] Avoid using non-string value as layout property.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layout || !layouts['_' + layout]) {
|
||||||
|
layout = 'default'
|
||||||
|
}
|
||||||
|
this.layoutName = layout
|
||||||
|
this.layout = layouts['_' + layout]
|
||||||
|
return this.layout
|
||||||
|
},
|
||||||
|
loadLayout (layout) {
|
||||||
|
if (!layout || !layouts['_' + layout]) {
|
||||||
|
layout = 'default'
|
||||||
|
}
|
||||||
|
return Promise.resolve(layouts['_' + layout])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
NuxtLoading
|
||||||
|
}
|
||||||
|
}
|
193
client/.nuxt/axios.js
Normal file
193
client/.nuxt/axios.js
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import Axios from 'axios'
|
||||||
|
import defu from 'defu'
|
||||||
|
|
||||||
|
// Axios.prototype cannot be modified
|
||||||
|
const axiosExtra = {
|
||||||
|
setBaseURL (baseURL) {
|
||||||
|
this.defaults.baseURL = baseURL
|
||||||
|
},
|
||||||
|
setHeader (name, value, scopes = 'common') {
|
||||||
|
for (const scope of Array.isArray(scopes) ? scopes : [ scopes ]) {
|
||||||
|
if (!value) {
|
||||||
|
delete this.defaults.headers[scope][name];
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
this.defaults.headers[scope][name] = value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setToken (token, type, scopes = 'common') {
|
||||||
|
const value = !token ? null : (type ? type + ' ' : '') + token
|
||||||
|
this.setHeader('Authorization', value, scopes)
|
||||||
|
},
|
||||||
|
onRequest(fn) {
|
||||||
|
this.interceptors.request.use(config => fn(config) || config)
|
||||||
|
},
|
||||||
|
onResponse(fn) {
|
||||||
|
this.interceptors.response.use(response => fn(response) || response)
|
||||||
|
},
|
||||||
|
onRequestError(fn) {
|
||||||
|
this.interceptors.request.use(undefined, error => fn(error) || Promise.reject(error))
|
||||||
|
},
|
||||||
|
onResponseError(fn) {
|
||||||
|
this.interceptors.response.use(undefined, error => fn(error) || Promise.reject(error))
|
||||||
|
},
|
||||||
|
onError(fn) {
|
||||||
|
this.onRequestError(fn)
|
||||||
|
this.onResponseError(fn)
|
||||||
|
},
|
||||||
|
create(options) {
|
||||||
|
return createAxiosInstance(defu(options, this.defaults))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request helpers ($get, $post, ...)
|
||||||
|
for (const method of ['request', 'delete', 'get', 'head', 'options', 'post', 'put', 'patch']) {
|
||||||
|
axiosExtra['$' + method] = function () { return this[method].apply(this, arguments).then(res => res && res.data) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const extendAxiosInstance = axios => {
|
||||||
|
for (const key in axiosExtra) {
|
||||||
|
axios[key] = axiosExtra[key].bind(axios)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAxiosInstance = axiosOptions => {
|
||||||
|
// Create new axios instance
|
||||||
|
const axios = Axios.create(axiosOptions)
|
||||||
|
axios.CancelToken = Axios.CancelToken
|
||||||
|
axios.isCancel = Axios.isCancel
|
||||||
|
|
||||||
|
// Extend axios proto
|
||||||
|
extendAxiosInstance(axios)
|
||||||
|
|
||||||
|
// Intercept to apply default headers
|
||||||
|
axios.onRequest((config) => {
|
||||||
|
config.headers = { ...axios.defaults.headers.common, ...config.headers }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Setup interceptors
|
||||||
|
|
||||||
|
setupProgress(axios)
|
||||||
|
|
||||||
|
return axios
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupProgress = (axios) => {
|
||||||
|
if (process.server) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A noop loading inteterface for when $nuxt is not yet ready
|
||||||
|
const noopLoading = {
|
||||||
|
finish: () => { },
|
||||||
|
start: () => { },
|
||||||
|
fail: () => { },
|
||||||
|
set: () => { }
|
||||||
|
}
|
||||||
|
|
||||||
|
const $loading = () => {
|
||||||
|
const $nuxt = typeof window !== 'undefined' && window['$nuxt']
|
||||||
|
return ($nuxt && $nuxt.$loading && $nuxt.$loading.set) ? $nuxt.$loading : noopLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentRequests = 0
|
||||||
|
|
||||||
|
axios.onRequest(config => {
|
||||||
|
if (config && config.progress === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRequests++
|
||||||
|
})
|
||||||
|
|
||||||
|
axios.onResponse(response => {
|
||||||
|
if (response && response.config && response.config.progress === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRequests--
|
||||||
|
if (currentRequests <= 0) {
|
||||||
|
currentRequests = 0
|
||||||
|
$loading().finish()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
axios.onError(error => {
|
||||||
|
if (error && error.config && error.config.progress === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRequests--
|
||||||
|
|
||||||
|
if (Axios.isCancel(error)) {
|
||||||
|
if (currentRequests <= 0) {
|
||||||
|
currentRequests = 0
|
||||||
|
$loading().finish()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$loading().fail()
|
||||||
|
$loading().finish()
|
||||||
|
})
|
||||||
|
|
||||||
|
const onProgress = e => {
|
||||||
|
if (!currentRequests || !e.total) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const progress = ((e.loaded * 100) / (e.total * currentRequests))
|
||||||
|
$loading().set(Math.min(100, progress))
|
||||||
|
}
|
||||||
|
|
||||||
|
axios.defaults.onUploadProgress = onProgress
|
||||||
|
axios.defaults.onDownloadProgress = onProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (ctx, inject) => {
|
||||||
|
// runtimeConfig
|
||||||
|
const runtimeConfig = ctx.$config && ctx.$config.axios || {}
|
||||||
|
// baseURL
|
||||||
|
const baseURL = process.browser
|
||||||
|
? (runtimeConfig.browserBaseURL || runtimeConfig.browserBaseUrl || runtimeConfig.baseURL || runtimeConfig.baseUrl || '')
|
||||||
|
: (runtimeConfig.baseURL || runtimeConfig.baseUrl || process.env._AXIOS_BASE_URL_ || '')
|
||||||
|
|
||||||
|
// Create fresh objects for all default header scopes
|
||||||
|
// Axios creates only one which is shared across SSR requests!
|
||||||
|
// https://github.com/mzabriskie/axios/blob/master/lib/defaults.js
|
||||||
|
const headers = {
|
||||||
|
"common": {
|
||||||
|
"Accept": "application/json, text/plain, */*"
|
||||||
|
},
|
||||||
|
"delete": {},
|
||||||
|
"get": {},
|
||||||
|
"head": {},
|
||||||
|
"post": {},
|
||||||
|
"put": {},
|
||||||
|
"patch": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const axiosOptions = {
|
||||||
|
baseURL,
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy SSR request headers headers
|
||||||
|
if (process.server && ctx.req && ctx.req.headers) {
|
||||||
|
const reqHeaders = { ...ctx.req.headers }
|
||||||
|
for (const h of ["accept","cf-connecting-ip","cf-ray","content-length","content-md5","content-type","host","x-forwarded-host","x-forwarded-port","x-forwarded-proto"]) {
|
||||||
|
delete reqHeaders[h]
|
||||||
|
}
|
||||||
|
axiosOptions.headers.common = { ...reqHeaders, ...axiosOptions.headers.common }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.server) {
|
||||||
|
// Don't accept brotli encoding because Node can't parse it
|
||||||
|
axiosOptions.headers.common['accept-encoding'] = 'gzip, deflate'
|
||||||
|
}
|
||||||
|
|
||||||
|
const axios = createAxiosInstance(axiosOptions)
|
||||||
|
|
||||||
|
// Inject axios to the context as $axios
|
||||||
|
ctx.$axios = axios
|
||||||
|
inject('axios', axios)
|
||||||
|
}
|
817
client/.nuxt/client.js
Normal file
817
client/.nuxt/client.js
Normal file
@ -0,0 +1,817 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import fetch from 'unfetch'
|
||||||
|
import middleware from './middleware.js'
|
||||||
|
import {
|
||||||
|
applyAsyncData,
|
||||||
|
promisify,
|
||||||
|
middlewareSeries,
|
||||||
|
sanitizeComponent,
|
||||||
|
resolveRouteComponents,
|
||||||
|
getMatchedComponents,
|
||||||
|
getMatchedComponentsInstances,
|
||||||
|
flatMapComponents,
|
||||||
|
setContext,
|
||||||
|
getLocation,
|
||||||
|
compile,
|
||||||
|
getQueryDiff,
|
||||||
|
globalHandleError,
|
||||||
|
isSamePath,
|
||||||
|
urlJoin
|
||||||
|
} from './utils.js'
|
||||||
|
import { createApp, NuxtError } from './index.js'
|
||||||
|
import fetchMixin from './mixins/fetch.client'
|
||||||
|
import NuxtLink from './components/nuxt-link.client.js' // should be included after ./index.js
|
||||||
|
|
||||||
|
// Fetch mixin
|
||||||
|
if (!Vue.__nuxt__fetch__mixin__) {
|
||||||
|
Vue.mixin(fetchMixin)
|
||||||
|
Vue.__nuxt__fetch__mixin__ = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component: <NuxtLink>
|
||||||
|
Vue.component(NuxtLink.name, NuxtLink)
|
||||||
|
Vue.component('NLink', NuxtLink)
|
||||||
|
|
||||||
|
if (!global.fetch) { global.fetch = fetch }
|
||||||
|
|
||||||
|
// Global shared references
|
||||||
|
let _lastPaths = []
|
||||||
|
let app
|
||||||
|
let router
|
||||||
|
let store
|
||||||
|
|
||||||
|
// Try to rehydrate SSR data from window
|
||||||
|
const NUXT = window.__NUXT__ || {}
|
||||||
|
|
||||||
|
const $config = NUXT.config || {}
|
||||||
|
if ($config._app) {
|
||||||
|
__webpack_public_path__ = urlJoin($config._app.cdnURL, $config._app.assetsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(Vue.config, {"silent":false,"performance":true})
|
||||||
|
|
||||||
|
const logs = NUXT.logs || []
|
||||||
|
if (logs.length > 0) {
|
||||||
|
const ssrLogStyle = 'background: #2E495E;border-radius: 0.5em;color: white;font-weight: bold;padding: 2px 0.5em;'
|
||||||
|
console.group && console.group ('%cNuxt SSR', ssrLogStyle)
|
||||||
|
logs.forEach(logObj => (console[logObj.type] || console.log)(...logObj.args))
|
||||||
|
delete NUXT.logs
|
||||||
|
console.groupEnd && console.groupEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup global Vue error handler
|
||||||
|
if (!Vue.config.$nuxt) {
|
||||||
|
const defaultErrorHandler = Vue.config.errorHandler
|
||||||
|
Vue.config.errorHandler = async (err, vm, info, ...rest) => {
|
||||||
|
// Call other handler if exist
|
||||||
|
let handled = null
|
||||||
|
if (typeof defaultErrorHandler === 'function') {
|
||||||
|
handled = defaultErrorHandler(err, vm, info, ...rest)
|
||||||
|
}
|
||||||
|
if (handled === true) {
|
||||||
|
return handled
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vm && vm.$root) {
|
||||||
|
const nuxtApp = Object.keys(Vue.config.$nuxt)
|
||||||
|
.find(nuxtInstance => vm.$root[nuxtInstance])
|
||||||
|
|
||||||
|
// Show Nuxt Error Page
|
||||||
|
if (nuxtApp && vm.$root[nuxtApp].error && info !== 'render function') {
|
||||||
|
const currentApp = vm.$root[nuxtApp]
|
||||||
|
|
||||||
|
// Load error layout
|
||||||
|
let layout = (NuxtError.options || NuxtError).layout
|
||||||
|
if (typeof layout === 'function') {
|
||||||
|
layout = layout(currentApp.context)
|
||||||
|
}
|
||||||
|
if (layout) {
|
||||||
|
await currentApp.loadLayout(layout).catch(() => {})
|
||||||
|
}
|
||||||
|
currentApp.setLayout(layout)
|
||||||
|
|
||||||
|
currentApp.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof defaultErrorHandler === 'function') {
|
||||||
|
return handled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to console
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.error(err)
|
||||||
|
} else {
|
||||||
|
console.error(err.message || err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Vue.config.$nuxt = {}
|
||||||
|
}
|
||||||
|
Vue.config.$nuxt.$nuxt = true
|
||||||
|
|
||||||
|
const errorHandler = Vue.config.errorHandler || console.error
|
||||||
|
|
||||||
|
// Create and mount App
|
||||||
|
createApp(null, NUXT.config).then(mountApp).catch(errorHandler)
|
||||||
|
|
||||||
|
function componentOption (component, key, ...args) {
|
||||||
|
if (!component || !component.options || !component.options[key]) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const option = component.options[key]
|
||||||
|
if (typeof option === 'function') {
|
||||||
|
return option(...args)
|
||||||
|
}
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTransitions (toComponents, to, from) {
|
||||||
|
const componentTransitions = (component) => {
|
||||||
|
const transition = componentOption(component, 'transition', to, from) || {}
|
||||||
|
return (typeof transition === 'string' ? { name: transition } : transition)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromComponents = from ? getMatchedComponents(from) : []
|
||||||
|
const maxDepth = Math.max(toComponents.length, fromComponents.length)
|
||||||
|
|
||||||
|
const mergedTransitions = []
|
||||||
|
for (let i=0; i<maxDepth; i++) {
|
||||||
|
// Clone original objects to prevent overrides
|
||||||
|
const toTransitions = Object.assign({}, componentTransitions(toComponents[i]))
|
||||||
|
const transitions = Object.assign({}, componentTransitions(fromComponents[i]))
|
||||||
|
|
||||||
|
// Combine transitions & prefer `leave` properties of "from" route
|
||||||
|
Object.keys(toTransitions)
|
||||||
|
.filter(key => typeof toTransitions[key] !== 'undefined' && !key.toLowerCase().includes('leave'))
|
||||||
|
.forEach((key) => { transitions[key] = toTransitions[key] })
|
||||||
|
|
||||||
|
mergedTransitions.push(transitions)
|
||||||
|
}
|
||||||
|
return mergedTransitions
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAsyncComponents (to, from, next) {
|
||||||
|
// Check if route changed (this._routeChanged), only if the page is not an error (for validate())
|
||||||
|
this._routeChanged = Boolean(app.nuxt.err) || from.name !== to.name
|
||||||
|
this._paramChanged = !this._routeChanged && from.path !== to.path
|
||||||
|
this._queryChanged = !this._paramChanged && from.fullPath !== to.fullPath
|
||||||
|
this._diffQuery = (this._queryChanged ? getQueryDiff(to.query, from.query) : [])
|
||||||
|
|
||||||
|
if ((this._routeChanged || this._paramChanged) && this.$loading.start && !this.$loading.manual) {
|
||||||
|
this.$loading.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this._queryChanged) {
|
||||||
|
const Components = await resolveRouteComponents(
|
||||||
|
to,
|
||||||
|
(Component, instance) => ({ Component, instance })
|
||||||
|
)
|
||||||
|
// Add a marker on each component that it needs to refresh or not
|
||||||
|
const startLoader = Components.some(({ Component, instance }) => {
|
||||||
|
const watchQuery = Component.options.watchQuery
|
||||||
|
if (watchQuery === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (Array.isArray(watchQuery)) {
|
||||||
|
return watchQuery.some(key => this._diffQuery[key])
|
||||||
|
}
|
||||||
|
if (typeof watchQuery === 'function') {
|
||||||
|
return watchQuery.apply(instance, [to.query, from.query])
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (startLoader && this.$loading.start && !this.$loading.manual) {
|
||||||
|
this.$loading.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Call next()
|
||||||
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
const err = error || {}
|
||||||
|
const statusCode = err.statusCode || err.status || (err.response && err.response.status) || 500
|
||||||
|
const message = err.message || ''
|
||||||
|
|
||||||
|
// Handle chunk loading errors
|
||||||
|
// This may be due to a new deployment or a network problem
|
||||||
|
if (/^Loading( CSS)? chunk (\d)+ failed\./.test(message)) {
|
||||||
|
window.location.reload(true /* skip cache */)
|
||||||
|
return // prevent error page blinking for user
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error({ statusCode, message })
|
||||||
|
this.$nuxt.$emit('routeChanged', to, from, err)
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySSRData (Component, ssrData) {
|
||||||
|
if (NUXT.serverRendered && ssrData) {
|
||||||
|
applyAsyncData(Component, ssrData)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component._Ctor = Component
|
||||||
|
return Component
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get matched components
|
||||||
|
function resolveComponents (route) {
|
||||||
|
return flatMapComponents(route, async (Component, _, match, key, index) => {
|
||||||
|
// If component is not resolved yet, resolve it
|
||||||
|
if (typeof Component === 'function' && !Component.options) {
|
||||||
|
Component = await Component()
|
||||||
|
}
|
||||||
|
// Sanitize it and save it
|
||||||
|
const _Component = applySSRData(sanitizeComponent(Component), NUXT.data ? NUXT.data[index] : null)
|
||||||
|
match.components[key] = _Component
|
||||||
|
return _Component
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function callMiddleware (Components, context, layout) {
|
||||||
|
let midd = []
|
||||||
|
let unknownMiddleware = false
|
||||||
|
|
||||||
|
// If layout is undefined, only call global middleware
|
||||||
|
if (typeof layout !== 'undefined') {
|
||||||
|
midd = [] // Exclude global middleware if layout defined (already called before)
|
||||||
|
layout = sanitizeComponent(layout)
|
||||||
|
if (layout.options.middleware) {
|
||||||
|
midd = midd.concat(layout.options.middleware)
|
||||||
|
}
|
||||||
|
Components.forEach((Component) => {
|
||||||
|
if (Component.options.middleware) {
|
||||||
|
midd = midd.concat(Component.options.middleware)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
midd = midd.map((name) => {
|
||||||
|
if (typeof name === 'function') {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if (typeof middleware[name] !== 'function') {
|
||||||
|
unknownMiddleware = true
|
||||||
|
this.error({ statusCode: 500, message: 'Unknown middleware ' + name })
|
||||||
|
}
|
||||||
|
return middleware[name]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (unknownMiddleware) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return middlewareSeries(midd, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render (to, from, next) {
|
||||||
|
if (this._routeChanged === false && this._paramChanged === false && this._queryChanged === false) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
// Handle first render on SPA mode
|
||||||
|
let spaFallback = false
|
||||||
|
if (to === from) {
|
||||||
|
_lastPaths = []
|
||||||
|
spaFallback = true
|
||||||
|
} else {
|
||||||
|
const fromMatches = []
|
||||||
|
_lastPaths = getMatchedComponents(from, fromMatches).map((Component, i) => {
|
||||||
|
return compile(from.matched[fromMatches[i]].path)(from.params)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextCalled is true when redirected
|
||||||
|
let nextCalled = false
|
||||||
|
const _next = (path) => {
|
||||||
|
if (from.path === path.path && this.$loading.finish) {
|
||||||
|
this.$loading.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.path !== path.path && this.$loading.pause) {
|
||||||
|
this.$loading.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextCalled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCalled = true
|
||||||
|
next(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update context
|
||||||
|
await setContext(app, {
|
||||||
|
route: to,
|
||||||
|
from,
|
||||||
|
next: _next.bind(this)
|
||||||
|
})
|
||||||
|
this._dateLastError = app.nuxt.dateErr
|
||||||
|
this._hadError = Boolean(app.nuxt.err)
|
||||||
|
|
||||||
|
// Get route's matched components
|
||||||
|
const matches = []
|
||||||
|
const Components = getMatchedComponents(to, matches)
|
||||||
|
|
||||||
|
// If no Components matched, generate 404
|
||||||
|
if (!Components.length) {
|
||||||
|
// Default layout
|
||||||
|
await callMiddleware.call(this, Components, app.context)
|
||||||
|
if (nextCalled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load layout for error page
|
||||||
|
const errorLayout = (NuxtError.options || NuxtError).layout
|
||||||
|
const layout = await this.loadLayout(
|
||||||
|
typeof errorLayout === 'function'
|
||||||
|
? errorLayout.call(NuxtError, app.context)
|
||||||
|
: errorLayout
|
||||||
|
)
|
||||||
|
|
||||||
|
await callMiddleware.call(this, Components, app.context, layout)
|
||||||
|
if (nextCalled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error page
|
||||||
|
app.context.error({ statusCode: 404, message: 'This page could not be found' })
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ._data and other properties if hot reloaded
|
||||||
|
Components.forEach((Component) => {
|
||||||
|
if (Component._Ctor && Component._Ctor.options) {
|
||||||
|
Component.options.asyncData = Component._Ctor.options.asyncData
|
||||||
|
Component.options.fetch = Component._Ctor.options.fetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply transitions
|
||||||
|
this.setTransitions(mapTransitions(Components, to, from))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call middleware
|
||||||
|
await callMiddleware.call(this, Components, app.context)
|
||||||
|
if (nextCalled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (app.context._errored) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set layout
|
||||||
|
let layout = Components[0].options.layout
|
||||||
|
if (typeof layout === 'function') {
|
||||||
|
layout = layout(app.context)
|
||||||
|
}
|
||||||
|
layout = await this.loadLayout(layout)
|
||||||
|
|
||||||
|
// Call middleware for layout
|
||||||
|
await callMiddleware.call(this, Components, app.context, layout)
|
||||||
|
if (nextCalled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (app.context._errored) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call .validate()
|
||||||
|
let isValid = true
|
||||||
|
try {
|
||||||
|
for (const Component of Components) {
|
||||||
|
if (typeof Component.options.validate !== 'function') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid = await Component.options.validate(app.context)
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (validationError) {
|
||||||
|
// ...If .validate() threw an error
|
||||||
|
this.error({
|
||||||
|
statusCode: validationError.statusCode || '500',
|
||||||
|
message: validationError.message
|
||||||
|
})
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...If .validate() returned false
|
||||||
|
if (!isValid) {
|
||||||
|
this.error({ statusCode: 404, message: 'This page could not be found' })
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
let instances
|
||||||
|
// Call asyncData & fetch hooks on components matched by the route.
|
||||||
|
await Promise.all(Components.map(async (Component, i) => {
|
||||||
|
// Check if only children route changed
|
||||||
|
Component._path = compile(to.matched[matches[i]].path)(to.params)
|
||||||
|
Component._dataRefresh = false
|
||||||
|
const childPathChanged = Component._path !== _lastPaths[i]
|
||||||
|
// Refresh component (call asyncData & fetch) when:
|
||||||
|
// Route path changed part includes current component
|
||||||
|
// Or route param changed part includes current component and watchParam is not `false`
|
||||||
|
// Or route query is changed and watchQuery returns `true`
|
||||||
|
if (this._routeChanged && childPathChanged) {
|
||||||
|
Component._dataRefresh = true
|
||||||
|
} else if (this._paramChanged && childPathChanged) {
|
||||||
|
const watchParam = Component.options.watchParam
|
||||||
|
Component._dataRefresh = watchParam !== false
|
||||||
|
} else if (this._queryChanged) {
|
||||||
|
const watchQuery = Component.options.watchQuery
|
||||||
|
if (watchQuery === true) {
|
||||||
|
Component._dataRefresh = true
|
||||||
|
} else if (Array.isArray(watchQuery)) {
|
||||||
|
Component._dataRefresh = watchQuery.some(key => this._diffQuery[key])
|
||||||
|
} else if (typeof watchQuery === 'function') {
|
||||||
|
if (!instances) {
|
||||||
|
instances = getMatchedComponentsInstances(to)
|
||||||
|
}
|
||||||
|
Component._dataRefresh = watchQuery.apply(instances[i], [to.query, from.query])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this._hadError && this._isMounted && !Component._dataRefresh) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises = []
|
||||||
|
|
||||||
|
const hasAsyncData = (
|
||||||
|
Component.options.asyncData &&
|
||||||
|
typeof Component.options.asyncData === 'function'
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasFetch = Boolean(Component.options.fetch) && Component.options.fetch.length
|
||||||
|
|
||||||
|
const loadingIncrease = (hasAsyncData && hasFetch) ? 30 : 45
|
||||||
|
|
||||||
|
// Call asyncData(context)
|
||||||
|
if (hasAsyncData) {
|
||||||
|
const promise = promisify(Component.options.asyncData, app.context)
|
||||||
|
|
||||||
|
promise.then((asyncDataResult) => {
|
||||||
|
applyAsyncData(Component, asyncDataResult)
|
||||||
|
|
||||||
|
if (this.$loading.increase) {
|
||||||
|
this.$loading.increase(loadingIncrease)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
promises.push(promise)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check disabled page loading
|
||||||
|
this.$loading.manual = Component.options.loading === false
|
||||||
|
|
||||||
|
// Call fetch(context)
|
||||||
|
if (hasFetch) {
|
||||||
|
let p = Component.options.fetch(app.context)
|
||||||
|
if (!p || (!(p instanceof Promise) && (typeof p.then !== 'function'))) {
|
||||||
|
p = Promise.resolve(p)
|
||||||
|
}
|
||||||
|
p.then((fetchResult) => {
|
||||||
|
if (this.$loading.increase) {
|
||||||
|
this.$loading.increase(loadingIncrease)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
promises.push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(promises)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// If not redirected
|
||||||
|
if (!nextCalled) {
|
||||||
|
if (this.$loading.finish && !this.$loading.manual) {
|
||||||
|
this.$loading.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = err || {}
|
||||||
|
if (error.message === 'ERR_REDIRECT') {
|
||||||
|
return this.$nuxt.$emit('routeChanged', to, from, error)
|
||||||
|
}
|
||||||
|
_lastPaths = []
|
||||||
|
|
||||||
|
globalHandleError(error)
|
||||||
|
|
||||||
|
// Load error layout
|
||||||
|
let layout = (NuxtError.options || NuxtError).layout
|
||||||
|
if (typeof layout === 'function') {
|
||||||
|
layout = layout(app.context)
|
||||||
|
}
|
||||||
|
await this.loadLayout(layout)
|
||||||
|
|
||||||
|
this.error(error)
|
||||||
|
this.$nuxt.$emit('routeChanged', to, from, error)
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix components format in matched, it's due to code-splitting of vue-router
|
||||||
|
function normalizeComponents (to, ___) {
|
||||||
|
flatMapComponents(to, (Component, _, match, key) => {
|
||||||
|
if (typeof Component === 'object' && !Component.options) {
|
||||||
|
// Updated via vue-router resolveAsyncComponents()
|
||||||
|
Component = Vue.extend(Component)
|
||||||
|
Component._Ctor = Component
|
||||||
|
match.components[key] = Component
|
||||||
|
}
|
||||||
|
return Component
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLayoutForNextPage (to) {
|
||||||
|
// Set layout
|
||||||
|
let hasError = Boolean(this.$options.nuxt.err)
|
||||||
|
if (this._hadError && this._dateLastError === this.$options.nuxt.dateErr) {
|
||||||
|
hasError = false
|
||||||
|
}
|
||||||
|
let layout = hasError
|
||||||
|
? (NuxtError.options || NuxtError).layout
|
||||||
|
: to.matched[0].components.default.options.layout
|
||||||
|
|
||||||
|
if (typeof layout === 'function') {
|
||||||
|
layout = layout(app.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setLayout(layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkForErrors (app) {
|
||||||
|
// Hide error component if no error
|
||||||
|
if (app._hadError && app._dateLastError === app.$options.nuxt.dateErr) {
|
||||||
|
app.error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When navigating on a different route but the same component is used, Vue.js
|
||||||
|
// Will not update the instance data, so we have to update $data ourselves
|
||||||
|
function fixPrepatch (to, ___) {
|
||||||
|
if (this._routeChanged === false && this._paramChanged === false && this._queryChanged === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const instances = getMatchedComponentsInstances(to)
|
||||||
|
const Components = getMatchedComponents(to)
|
||||||
|
|
||||||
|
let triggerScroll = false
|
||||||
|
|
||||||
|
Vue.nextTick(() => {
|
||||||
|
instances.forEach((instance, i) => {
|
||||||
|
if (!instance || instance._isDestroyed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
instance.constructor._dataRefresh &&
|
||||||
|
Components[i] === instance.constructor &&
|
||||||
|
instance.$vnode.data.keepAlive !== true &&
|
||||||
|
typeof instance.constructor.options.data === 'function'
|
||||||
|
) {
|
||||||
|
const newData = instance.constructor.options.data.call(instance)
|
||||||
|
for (const key in newData) {
|
||||||
|
Vue.set(instance.$data, key, newData[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerScroll = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (triggerScroll) {
|
||||||
|
// Ensure to trigger scroll event after calling scrollBehavior
|
||||||
|
window.$nuxt.$nextTick(() => {
|
||||||
|
window.$nuxt.$emit('triggerScroll')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
checkForErrors(this)
|
||||||
|
|
||||||
|
// Hot reloading
|
||||||
|
setTimeout(() => hotReloadAPI(this), 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function nuxtReady (_app) {
|
||||||
|
window.onNuxtReadyCbs.forEach((cb) => {
|
||||||
|
if (typeof cb === 'function') {
|
||||||
|
cb(_app)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Special JSDOM
|
||||||
|
if (typeof window._onNuxtLoaded === 'function') {
|
||||||
|
window._onNuxtLoaded(_app)
|
||||||
|
}
|
||||||
|
// Add router hooks
|
||||||
|
router.afterEach((to, from) => {
|
||||||
|
// Wait for fixPrepatch + $data updates
|
||||||
|
Vue.nextTick(() => _app.$nuxt.$emit('routeChanged', to, from))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopData = () => { return {} }
|
||||||
|
const noopFetch = () => {}
|
||||||
|
|
||||||
|
// Special hot reload with asyncData(context)
|
||||||
|
function getNuxtChildComponents ($parent, $components = []) {
|
||||||
|
$parent.$children.forEach(($child) => {
|
||||||
|
if ($child.$vnode && $child.$vnode.data.nuxtChild && !$components.find(c =>(c.$options.__file === $child.$options.__file))) {
|
||||||
|
$components.push($child)
|
||||||
|
}
|
||||||
|
if ($child.$children && $child.$children.length) {
|
||||||
|
getNuxtChildComponents($child, $components)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return $components
|
||||||
|
}
|
||||||
|
|
||||||
|
function hotReloadAPI(_app) {
|
||||||
|
if (!module.hot) return
|
||||||
|
|
||||||
|
let $components = getNuxtChildComponents(_app.$nuxt, [])
|
||||||
|
|
||||||
|
$components.forEach(addHotReload.bind(_app))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHotReload ($component, depth) {
|
||||||
|
if ($component.$vnode.data._hasHotReload) return
|
||||||
|
$component.$vnode.data._hasHotReload = true
|
||||||
|
|
||||||
|
var _forceUpdate = $component.$forceUpdate.bind($component.$parent)
|
||||||
|
|
||||||
|
$component.$vnode.context.$forceUpdate = async () => {
|
||||||
|
let Components = getMatchedComponents(router.currentRoute)
|
||||||
|
let Component = Components[depth]
|
||||||
|
if (!Component) {
|
||||||
|
return _forceUpdate()
|
||||||
|
}
|
||||||
|
if (typeof Component === 'object' && !Component.options) {
|
||||||
|
// Updated via vue-router resolveAsyncComponents()
|
||||||
|
Component = Vue.extend(Component)
|
||||||
|
Component._Ctor = Component
|
||||||
|
}
|
||||||
|
this.error()
|
||||||
|
let promises = []
|
||||||
|
const next = function (path) {
|
||||||
|
this.$loading.finish && this.$loading.finish()
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
await setContext(app, {
|
||||||
|
route: router.currentRoute,
|
||||||
|
isHMR: true,
|
||||||
|
next: next.bind(this)
|
||||||
|
})
|
||||||
|
const context = app.context
|
||||||
|
|
||||||
|
if (this.$loading.start && !this.$loading.manual) {
|
||||||
|
this.$loading.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
callMiddleware.call(this, Components, context)
|
||||||
|
.then(() => {
|
||||||
|
// If layout changed
|
||||||
|
if (depth !== 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout = Component.options.layout || 'default'
|
||||||
|
if (typeof layout === 'function') {
|
||||||
|
layout = layout(context)
|
||||||
|
}
|
||||||
|
if (this.layoutName === layout) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let promise = this.loadLayout(layout)
|
||||||
|
promise.then(() => {
|
||||||
|
this.setLayout(layout)
|
||||||
|
Vue.nextTick(() => hotReloadAPI(this))
|
||||||
|
})
|
||||||
|
return promise
|
||||||
|
})
|
||||||
|
|
||||||
|
.then(() => {
|
||||||
|
return callMiddleware.call(this, Components, context, this.layout)
|
||||||
|
})
|
||||||
|
|
||||||
|
.then(() => {
|
||||||
|
// Call asyncData(context)
|
||||||
|
let pAsyncData = promisify(Component.options.asyncData || noopData, context)
|
||||||
|
pAsyncData.then((asyncDataResult) => {
|
||||||
|
applyAsyncData(Component, asyncDataResult)
|
||||||
|
this.$loading.increase && this.$loading.increase(30)
|
||||||
|
})
|
||||||
|
promises.push(pAsyncData)
|
||||||
|
|
||||||
|
// Call fetch()
|
||||||
|
Component.options.fetch = Component.options.fetch || noopFetch
|
||||||
|
let pFetch = Component.options.fetch.length && Component.options.fetch(context)
|
||||||
|
if (!pFetch || (!(pFetch instanceof Promise) && (typeof pFetch.then !== 'function'))) { pFetch = Promise.resolve(pFetch) }
|
||||||
|
pFetch.then(() => this.$loading.increase && this.$loading.increase(30))
|
||||||
|
promises.push(pFetch)
|
||||||
|
|
||||||
|
return Promise.all(promises)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.$loading.finish && this.$loading.finish()
|
||||||
|
_forceUpdate()
|
||||||
|
setTimeout(() => hotReloadAPI(this), 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountApp (__app) {
|
||||||
|
// Set global variables
|
||||||
|
app = __app.app
|
||||||
|
router = __app.router
|
||||||
|
store = __app.store
|
||||||
|
|
||||||
|
// Create Vue instance
|
||||||
|
const _app = new Vue(app)
|
||||||
|
|
||||||
|
// Mounts Vue app to DOM element
|
||||||
|
const mount = () => {
|
||||||
|
_app.$mount('#__nuxt')
|
||||||
|
|
||||||
|
// Add afterEach router hooks
|
||||||
|
router.afterEach(normalizeComponents)
|
||||||
|
|
||||||
|
router.afterEach(setLayoutForNextPage.bind(_app))
|
||||||
|
|
||||||
|
router.afterEach(fixPrepatch.bind(_app))
|
||||||
|
|
||||||
|
// Listen for first Vue update
|
||||||
|
Vue.nextTick(() => {
|
||||||
|
// Call window.{{globals.readyCallback}} callbacks
|
||||||
|
nuxtReady(_app)
|
||||||
|
|
||||||
|
// Enable hot reloading
|
||||||
|
hotReloadAPI(_app)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve route components
|
||||||
|
const Components = await Promise.all(resolveComponents(app.context.route))
|
||||||
|
|
||||||
|
// Enable transitions
|
||||||
|
_app.setTransitions = _app.$options.nuxt.setTransitions.bind(_app)
|
||||||
|
if (Components.length) {
|
||||||
|
_app.setTransitions(mapTransitions(Components, router.currentRoute))
|
||||||
|
_lastPaths = router.currentRoute.matched.map(route => compile(route.path)(router.currentRoute.params))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize error handler
|
||||||
|
_app.$loading = {} // To avoid error while _app.$nuxt does not exist
|
||||||
|
if (NUXT.error) {
|
||||||
|
_app.error(NUXT.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add beforeEach router hooks
|
||||||
|
router.beforeEach(loadAsyncComponents.bind(_app))
|
||||||
|
router.beforeEach(render.bind(_app))
|
||||||
|
|
||||||
|
// Fix in static: remove trailing slash to force hydration
|
||||||
|
// Full static, if server-rendered: hydrate, to allow custom redirect to generated page
|
||||||
|
|
||||||
|
// Fix in static: remove trailing slash to force hydration
|
||||||
|
if (NUXT.serverRendered && isSamePath(NUXT.routePath, _app.context.route.path)) {
|
||||||
|
return mount()
|
||||||
|
}
|
||||||
|
|
||||||
|
// First render on client-side
|
||||||
|
const clientFirstMount = () => {
|
||||||
|
normalizeComponents(router.currentRoute, router.currentRoute)
|
||||||
|
setLayoutForNextPage.call(_app, router.currentRoute)
|
||||||
|
checkForErrors(_app)
|
||||||
|
// Don't call fixPrepatch.call(_app, router.currentRoute, router.currentRoute) since it's first render
|
||||||
|
mount()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fix: force next tick to avoid having same timestamp when an error happen on spa fallback
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
render.call(_app, router.currentRoute, router.currentRoute, (path) => {
|
||||||
|
// If not redirected
|
||||||
|
if (!path) {
|
||||||
|
clientFirstMount()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a one-time afterEach hook to
|
||||||
|
// mount the app wait for redirect and route gets resolved
|
||||||
|
const unregisterHook = router.afterEach((to, from) => {
|
||||||
|
unregisterHook()
|
||||||
|
clientFirstMount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Push the path and let route to be resolved
|
||||||
|
router.push(path, undefined, (err) => {
|
||||||
|
if (err) {
|
||||||
|
errorHandler(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
49
client/.nuxt/components/index.js
Normal file
49
client/.nuxt/components/index.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { wrapFunctional } from './utils'
|
||||||
|
|
||||||
|
export { default as AudioPlayer } from '../..\\components\\AudioPlayer.vue'
|
||||||
|
export { default as AppAppbar } from '../..\\components\\app\\Appbar.vue'
|
||||||
|
export { default as AppBookShelf } from '../..\\components\\app\\BookShelf.vue'
|
||||||
|
export { default as AppStreamContainer } from '../..\\components\\app\\StreamContainer.vue'
|
||||||
|
export { default as AppTracksTable } from '../..\\components\\app\\TracksTable.vue'
|
||||||
|
export { default as CardsBookCard } from '../..\\components\\cards\\BookCard.vue'
|
||||||
|
export { default as CardsBookCover } from '../..\\components\\cards\\BookCover.vue'
|
||||||
|
export { default as ControlsVolumeControl } from '../..\\components\\controls\\VolumeControl.vue'
|
||||||
|
export { default as ModalsEditModal } from '../..\\components\\modals\\EditModal.vue'
|
||||||
|
export { default as ModalsModal } from '../..\\components\\modals\\Modal.vue'
|
||||||
|
export { default as UiBtn } from '../..\\components\\ui\\Btn.vue'
|
||||||
|
export { default as UiLoadingIndicator } from '../..\\components\\ui\\LoadingIndicator.vue'
|
||||||
|
export { default as UiMenu } from '../..\\components\\ui\\Menu.vue'
|
||||||
|
export { default as UiTextareaInput } from '../..\\components\\ui\\TextareaInput.vue'
|
||||||
|
export { default as UiTextareaWithLabel } from '../..\\components\\ui\\TextareaWithLabel.vue'
|
||||||
|
export { default as UiTextInput } from '../..\\components\\ui\\TextInput.vue'
|
||||||
|
export { default as UiTextInputWithLabel } from '../..\\components\\ui\\TextInputWithLabel.vue'
|
||||||
|
export { default as UiTooltip } from '../..\\components\\ui\\Tooltip.vue'
|
||||||
|
export { default as WidgetsScanAlert } from '../..\\components\\widgets\\ScanAlert.vue'
|
||||||
|
export { default as ModalsEditTabsCover } from '../..\\components\\modals\\edit-tabs\\Cover.vue'
|
||||||
|
export { default as ModalsEditTabsDetails } from '../..\\components\\modals\\edit-tabs\\Details.vue'
|
||||||
|
export { default as ModalsEditTabsMatch } from '../..\\components\\modals\\edit-tabs\\Match.vue'
|
||||||
|
export { default as ModalsEditTabsTracks } from '../..\\components\\modals\\edit-tabs\\Tracks.vue'
|
||||||
|
|
||||||
|
export const LazyAudioPlayer = import('../..\\components\\AudioPlayer.vue' /* webpackChunkName: "components/audio-player" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyAppAppbar = import('../..\\components\\app\\Appbar.vue' /* webpackChunkName: "components/app-appbar" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyAppBookShelf = import('../..\\components\\app\\BookShelf.vue' /* webpackChunkName: "components/app-book-shelf" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyAppStreamContainer = import('../..\\components\\app\\StreamContainer.vue' /* webpackChunkName: "components/app-stream-container" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyAppTracksTable = import('../..\\components\\app\\TracksTable.vue' /* webpackChunkName: "components/app-tracks-table" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyCardsBookCard = import('../..\\components\\cards\\BookCard.vue' /* webpackChunkName: "components/cards-book-card" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyCardsBookCover = import('../..\\components\\cards\\BookCover.vue' /* webpackChunkName: "components/cards-book-cover" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyControlsVolumeControl = import('../..\\components\\controls\\VolumeControl.vue' /* webpackChunkName: "components/controls-volume-control" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyModalsEditModal = import('../..\\components\\modals\\EditModal.vue' /* webpackChunkName: "components/modals-edit-modal" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyModalsModal = import('../..\\components\\modals\\Modal.vue' /* webpackChunkName: "components/modals-modal" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyUiBtn = import('../..\\components\\ui\\Btn.vue' /* webpackChunkName: "components/ui-btn" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyUiLoadingIndicator = import('../..\\components\\ui\\LoadingIndicator.vue' /* webpackChunkName: "components/ui-loading-indicator" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyUiMenu = import('../..\\components\\ui\\Menu.vue' /* webpackChunkName: "components/ui-menu" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyUiTextareaInput = import('../..\\components\\ui\\TextareaInput.vue' /* webpackChunkName: "components/ui-textarea-input" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyUiTextareaWithLabel = import('../..\\components\\ui\\TextareaWithLabel.vue' /* webpackChunkName: "components/ui-textarea-with-label" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyUiTextInput = import('../..\\components\\ui\\TextInput.vue' /* webpackChunkName: "components/ui-text-input" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyUiTextInputWithLabel = import('../..\\components\\ui\\TextInputWithLabel.vue' /* webpackChunkName: "components/ui-text-input-with-label" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyUiTooltip = import('../..\\components\\ui\\Tooltip.vue' /* webpackChunkName: "components/ui-tooltip" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyWidgetsScanAlert = import('../..\\components\\widgets\\ScanAlert.vue' /* webpackChunkName: "components/widgets-scan-alert" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyModalsEditTabsCover = import('../..\\components\\modals\\edit-tabs\\Cover.vue' /* webpackChunkName: "components/modals-edit-tabs-cover" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyModalsEditTabsDetails = import('../..\\components\\modals\\edit-tabs\\Details.vue' /* webpackChunkName: "components/modals-edit-tabs-details" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyModalsEditTabsMatch = import('../..\\components\\modals\\edit-tabs\\Match.vue' /* webpackChunkName: "components/modals-edit-tabs-match" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
export const LazyModalsEditTabsTracks = import('../..\\components\\modals\\edit-tabs\\Tracks.vue' /* webpackChunkName: "components/modals-edit-tabs-tracks" */).then(c => wrapFunctional(c.default || c))
|
143
client/.nuxt/components/nuxt-build-indicator.vue
Normal file
143
client/.nuxt/components/nuxt-build-indicator.vue
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<transition appear>
|
||||||
|
<div v-if="building" class="nuxt__build_indicator" :style="indicatorStyle">
|
||||||
|
<svg viewBox="0 0 96 72" version="1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M6 66h23l1-3 21-37L40 6 6 66zM79 66h11L62 17l-5 9 22 37v3zM54 31L35 66h38z" />
|
||||||
|
<path d="M29 69v-1-2H6L40 6l11 20 3-6L44 3s-2-3-4-3-3 1-5 3L1 63c0 1-2 3 0 6 0 1 2 2 5 2h28c-3 0-4-1-5-2z" fill="#00C58E" />
|
||||||
|
<path d="M95 63L67 14c0-1-2-3-5-3-1 0-3 0-4 3l-4 6 3 6 5-9 28 49H79a5 5 0 0 1 0 3c-2 2-5 2-5 2h16c1 0 4 0 5-2 1-1 2-3 0-6z" fill="#00C58E" />
|
||||||
|
<path d="M79 69v-1-2-3L57 26l-3-6-3 6-21 37-1 3a5 5 0 0 0 0 3c1 1 2 2 5 2h40s3 0 5-2zM54 31l19 35H35l19-35z" fill="#FFF" fill-rule="nonzero" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{{ animatedProgress }}%
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'NuxtBuildIndicator',
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
building: false,
|
||||||
|
progress: 0,
|
||||||
|
animatedProgress: 0,
|
||||||
|
reconnectAttempts: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
options: () => ({"position":"bottom-right","backgroundColor":"#2E495E","color":"#00C48D"}),
|
||||||
|
indicatorStyle () {
|
||||||
|
const [d1, d2] = this.options.position.split('-')
|
||||||
|
return {
|
||||||
|
[d1]: '20px',
|
||||||
|
[d2]: '20px',
|
||||||
|
'background-color': this.options.backgroundColor,
|
||||||
|
color: this.options.color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
progress (val, oldVal) {
|
||||||
|
// Average progress may decrease but ignore it!
|
||||||
|
if (val < oldVal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cancel old animation
|
||||||
|
clearInterval(this._progressAnimation)
|
||||||
|
// Jump to edge immediately
|
||||||
|
if (val < 10 || val > 90) {
|
||||||
|
this.animatedProgress = val
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Animate to value
|
||||||
|
this._progressAnimation = setInterval(() => {
|
||||||
|
const diff = this.progress - this.animatedProgress
|
||||||
|
if (diff > 0) {
|
||||||
|
this.animatedProgress++
|
||||||
|
} else {
|
||||||
|
clearInterval(this._progressAnimation)
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
if (EventSource === undefined) {
|
||||||
|
return // Unsupported
|
||||||
|
}
|
||||||
|
this.sseConnect()
|
||||||
|
},
|
||||||
|
beforeDestroy () {
|
||||||
|
this.sseClose()
|
||||||
|
clearInterval(this._progressAnimation)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
sseConnect () {
|
||||||
|
if (this._connecting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._connecting = true
|
||||||
|
this.sse = new EventSource('/_loading/sse')
|
||||||
|
this.sse.addEventListener('message', event => this.onSseMessage(event))
|
||||||
|
},
|
||||||
|
onSseMessage (message) {
|
||||||
|
const data = JSON.parse(message.data)
|
||||||
|
if (!data.states) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.progress = Math.round(data.states.reduce((p, s) => p + s.progress, 0) / data.states.length)
|
||||||
|
|
||||||
|
if (!data.allDone) {
|
||||||
|
this.building = true
|
||||||
|
} else {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.building = false
|
||||||
|
this.animatedProgress = 0
|
||||||
|
this.progress = 0
|
||||||
|
clearInterval(this._progressAnimation)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sseClose () {
|
||||||
|
if (this.sse) {
|
||||||
|
this.sse.close()
|
||||||
|
delete this.sse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nuxt__build_indicator {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: fixed;
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 1px 1px 2px 0px rgba(0,0,0,0.2);
|
||||||
|
width: 88px;
|
||||||
|
z-index: 2147483647;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.2rem;
|
||||||
|
}
|
||||||
|
.v-enter-active, .v-leave-active {
|
||||||
|
transition-delay: 0.2s;
|
||||||
|
transition-property: all;
|
||||||
|
transition-duration: 0.3s;
|
||||||
|
}
|
||||||
|
.v-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: baseline;
|
||||||
|
width: 1.1em;
|
||||||
|
height: 0.825em;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
122
client/.nuxt/components/nuxt-child.js
Normal file
122
client/.nuxt/components/nuxt-child.js
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'NuxtChild',
|
||||||
|
functional: true,
|
||||||
|
props: {
|
||||||
|
nuxtChildKey: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
keepAlive: Boolean,
|
||||||
|
keepAliveProps: {
|
||||||
|
type: Object,
|
||||||
|
default: undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
render (_, { parent, data, props }) {
|
||||||
|
const h = parent.$createElement
|
||||||
|
|
||||||
|
data.nuxtChild = true
|
||||||
|
const _parent = parent
|
||||||
|
const transitions = parent.$nuxt.nuxt.transitions
|
||||||
|
const defaultTransition = parent.$nuxt.nuxt.defaultTransition
|
||||||
|
|
||||||
|
let depth = 0
|
||||||
|
while (parent) {
|
||||||
|
if (parent.$vnode && parent.$vnode.data.nuxtChild) {
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
parent = parent.$parent
|
||||||
|
}
|
||||||
|
data.nuxtChildDepth = depth
|
||||||
|
const transition = transitions[depth] || defaultTransition
|
||||||
|
const transitionProps = {}
|
||||||
|
transitionsKeys.forEach((key) => {
|
||||||
|
if (typeof transition[key] !== 'undefined') {
|
||||||
|
transitionProps[key] = transition[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const listeners = {}
|
||||||
|
listenersKeys.forEach((key) => {
|
||||||
|
if (typeof transition[key] === 'function') {
|
||||||
|
listeners[key] = transition[key].bind(_parent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (process.client) {
|
||||||
|
// Add triggerScroll event on beforeEnter (fix #1376)
|
||||||
|
const beforeEnter = listeners.beforeEnter
|
||||||
|
listeners.beforeEnter = (el) => {
|
||||||
|
// Ensure to trigger scroll event after calling scrollBehavior
|
||||||
|
window.$nuxt.$nextTick(() => {
|
||||||
|
window.$nuxt.$emit('triggerScroll')
|
||||||
|
})
|
||||||
|
if (beforeEnter) {
|
||||||
|
return beforeEnter.call(_parent, el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure that leave is called asynchronous (fix #5703)
|
||||||
|
if (transition.css === false) {
|
||||||
|
const leave = listeners.leave
|
||||||
|
|
||||||
|
// only add leave listener when user didnt provide one
|
||||||
|
// or when it misses the done argument
|
||||||
|
if (!leave || leave.length < 2) {
|
||||||
|
listeners.leave = (el, done) => {
|
||||||
|
if (leave) {
|
||||||
|
leave.call(_parent, el)
|
||||||
|
}
|
||||||
|
|
||||||
|
_parent.$nextTick(done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let routerView = h('routerView', data)
|
||||||
|
|
||||||
|
if (props.keepAlive) {
|
||||||
|
routerView = h('keep-alive', { props: props.keepAliveProps }, [routerView])
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('transition', {
|
||||||
|
props: transitionProps,
|
||||||
|
on: listeners
|
||||||
|
}, [routerView])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitionsKeys = [
|
||||||
|
'name',
|
||||||
|
'mode',
|
||||||
|
'appear',
|
||||||
|
'css',
|
||||||
|
'type',
|
||||||
|
'duration',
|
||||||
|
'enterClass',
|
||||||
|
'leaveClass',
|
||||||
|
'appearClass',
|
||||||
|
'enterActiveClass',
|
||||||
|
'enterActiveClass',
|
||||||
|
'leaveActiveClass',
|
||||||
|
'appearActiveClass',
|
||||||
|
'enterToClass',
|
||||||
|
'leaveToClass',
|
||||||
|
'appearToClass'
|
||||||
|
]
|
||||||
|
|
||||||
|
const listenersKeys = [
|
||||||
|
'beforeEnter',
|
||||||
|
'enter',
|
||||||
|
'afterEnter',
|
||||||
|
'enterCancelled',
|
||||||
|
'beforeLeave',
|
||||||
|
'leave',
|
||||||
|
'afterLeave',
|
||||||
|
'leaveCancelled',
|
||||||
|
'beforeAppear',
|
||||||
|
'appear',
|
||||||
|
'afterAppear',
|
||||||
|
'appearCancelled'
|
||||||
|
]
|
98
client/.nuxt/components/nuxt-error.vue
Normal file
98
client/.nuxt/components/nuxt-error.vue
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="__nuxt-error-page">
|
||||||
|
<div class="error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="90" fill="#DBE1EC" viewBox="0 0 48 48">
|
||||||
|
<path d="M22 30h4v4h-4zm0-16h4v12h-4zm1.99-10C12.94 4 4 12.95 4 24s8.94 20 19.99 20S44 35.05 44 24 35.04 4 23.99 4zM24 40c-8.84 0-16-7.16-16-16S15.16 8 24 8s16 7.16 16 16-7.16 16-16 16z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="title">{{ message }}</div>
|
||||||
|
<p v-if="statusCode === 404" class="description">
|
||||||
|
<a v-if="typeof $route === 'undefined'" class="error-link" href="/"></a>
|
||||||
|
<NuxtLink v-else class="error-link" to="/">Back to the home page</NuxtLink>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="description" v-else>An error occurred while rendering the page. Check developer tools console for details.</p>
|
||||||
|
|
||||||
|
<div class="logo">
|
||||||
|
<a href="https://nuxtjs.org" target="_blank" rel="noopener">Nuxt</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'NuxtError',
|
||||||
|
props: {
|
||||||
|
error: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
statusCode () {
|
||||||
|
return (this.error && this.error.statusCode) || 500
|
||||||
|
},
|
||||||
|
message () {
|
||||||
|
return this.error.message || 'Error'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
head () {
|
||||||
|
return {
|
||||||
|
title: this.message,
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'viewport',
|
||||||
|
content: 'width=device-width,initial-scale=1.0,minimum-scale=1.0'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.__nuxt-error-page {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #F7F8FB;
|
||||||
|
color: #47494E;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-weight: 100 !important;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.__nuxt-error-page .error {
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
.__nuxt-error-page .title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-top: 15px;
|
||||||
|
color: #47494E;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.__nuxt-error-page .description {
|
||||||
|
color: #7F828B;
|
||||||
|
line-height: 21px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.__nuxt-error-page a {
|
||||||
|
color: #7F828B !important;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.__nuxt-error-page .logo {
|
||||||
|
position: fixed;
|
||||||
|
left: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
98
client/.nuxt/components/nuxt-link.client.js
Normal file
98
client/.nuxt/components/nuxt-link.client.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
|
const requestIdleCallback = window.requestIdleCallback ||
|
||||||
|
function (cb) {
|
||||||
|
const start = Date.now()
|
||||||
|
return setTimeout(function () {
|
||||||
|
cb({
|
||||||
|
didTimeout: false,
|
||||||
|
timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
|
||||||
|
})
|
||||||
|
}, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelIdleCallback = window.cancelIdleCallback || function (id) {
|
||||||
|
clearTimeout(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = window.IntersectionObserver && new window.IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(({ intersectionRatio, target: link }) => {
|
||||||
|
if (intersectionRatio <= 0 || !link.__prefetch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link.__prefetch()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'NuxtLink',
|
||||||
|
extends: Vue.component('RouterLink'),
|
||||||
|
props: {
|
||||||
|
prefetch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
noPrefetch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
if (this.prefetch && !this.noPrefetch) {
|
||||||
|
this.handleId = requestIdleCallback(this.observe, { timeout: 2e3 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy () {
|
||||||
|
cancelIdleCallback(this.handleId)
|
||||||
|
|
||||||
|
if (this.__observed) {
|
||||||
|
observer.unobserve(this.$el)
|
||||||
|
delete this.$el.__prefetch
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
observe () {
|
||||||
|
// If no IntersectionObserver, avoid prefetching
|
||||||
|
if (!observer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Add to observer
|
||||||
|
if (this.shouldPrefetch()) {
|
||||||
|
this.$el.__prefetch = this.prefetchLink.bind(this)
|
||||||
|
observer.observe(this.$el)
|
||||||
|
this.__observed = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shouldPrefetch () {
|
||||||
|
return this.getPrefetchComponents().length > 0
|
||||||
|
},
|
||||||
|
canPrefetch () {
|
||||||
|
const conn = navigator.connection
|
||||||
|
const hasBadConnection = this.$nuxt.isOffline || (conn && ((conn.effectiveType || '').includes('2g') || conn.saveData))
|
||||||
|
|
||||||
|
return !hasBadConnection
|
||||||
|
},
|
||||||
|
getPrefetchComponents () {
|
||||||
|
const ref = this.$router.resolve(this.to, this.$route, this.append)
|
||||||
|
const Components = ref.resolved.matched.map(r => r.components.default)
|
||||||
|
|
||||||
|
return Components.filter(Component => typeof Component === 'function' && !Component.options && !Component.__prefetched)
|
||||||
|
},
|
||||||
|
prefetchLink () {
|
||||||
|
if (!this.canPrefetch()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Stop observing this link (in case of internet connection changes)
|
||||||
|
observer.unobserve(this.$el)
|
||||||
|
const Components = this.getPrefetchComponents()
|
||||||
|
|
||||||
|
for (const Component of Components) {
|
||||||
|
const componentOrPromise = Component()
|
||||||
|
if (componentOrPromise instanceof Promise) {
|
||||||
|
componentOrPromise.catch(() => {})
|
||||||
|
}
|
||||||
|
Component.__prefetched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
client/.nuxt/components/nuxt-link.server.js
Normal file
16
client/.nuxt/components/nuxt-link.server.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'NuxtLink',
|
||||||
|
extends: Vue.component('RouterLink'),
|
||||||
|
props: {
|
||||||
|
prefetch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
noPrefetch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
177
client/.nuxt/components/nuxt-loading.vue
Normal file
177
client/.nuxt/components/nuxt-loading.vue
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'NuxtLoading',
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
percent: 0,
|
||||||
|
show: false,
|
||||||
|
canSucceed: true,
|
||||||
|
reversed: false,
|
||||||
|
skipTimerCount: 0,
|
||||||
|
rtl: false,
|
||||||
|
throttle: 200,
|
||||||
|
duration: 5000,
|
||||||
|
continuous: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
left () {
|
||||||
|
if (!this.continuous && !this.rtl) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return this.rtl
|
||||||
|
? (this.reversed ? '0px' : 'auto')
|
||||||
|
: (!this.reversed ? '0px' : 'auto')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy () {
|
||||||
|
this.clear()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clear () {
|
||||||
|
clearInterval(this._timer)
|
||||||
|
clearTimeout(this._throttle)
|
||||||
|
this._timer = null
|
||||||
|
},
|
||||||
|
start () {
|
||||||
|
this.clear()
|
||||||
|
this.percent = 0
|
||||||
|
this.reversed = false
|
||||||
|
this.skipTimerCount = 0
|
||||||
|
this.canSucceed = true
|
||||||
|
|
||||||
|
if (this.throttle) {
|
||||||
|
this._throttle = setTimeout(() => this.startTimer(), this.throttle)
|
||||||
|
} else {
|
||||||
|
this.startTimer()
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
set (num) {
|
||||||
|
this.show = true
|
||||||
|
this.canSucceed = true
|
||||||
|
this.percent = Math.min(100, Math.max(0, Math.floor(num)))
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
get () {
|
||||||
|
return this.percent
|
||||||
|
},
|
||||||
|
increase (num) {
|
||||||
|
this.percent = Math.min(100, Math.floor(this.percent + num))
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
decrease (num) {
|
||||||
|
this.percent = Math.max(0, Math.floor(this.percent - num))
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
pause () {
|
||||||
|
clearInterval(this._timer)
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
resume () {
|
||||||
|
this.startTimer()
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
finish () {
|
||||||
|
this.percent = this.reversed ? 0 : 100
|
||||||
|
this.hide()
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
hide () {
|
||||||
|
this.clear()
|
||||||
|
setTimeout(() => {
|
||||||
|
this.show = false
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.percent = 0
|
||||||
|
this.reversed = false
|
||||||
|
})
|
||||||
|
}, 500)
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
fail (error) {
|
||||||
|
this.canSucceed = false
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
startTimer () {
|
||||||
|
if (!this.show) {
|
||||||
|
this.show = true
|
||||||
|
}
|
||||||
|
if (typeof this._cut === 'undefined') {
|
||||||
|
this._cut = 10000 / Math.floor(this.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._timer = setInterval(() => {
|
||||||
|
/**
|
||||||
|
* When reversing direction skip one timers
|
||||||
|
* so 0, 100 are displayed for two iterations
|
||||||
|
* also disable css width transitioning
|
||||||
|
* which otherwise interferes and shows
|
||||||
|
* a jojo effect
|
||||||
|
*/
|
||||||
|
if (this.skipTimerCount > 0) {
|
||||||
|
this.skipTimerCount--
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.reversed) {
|
||||||
|
this.decrease(this._cut)
|
||||||
|
} else {
|
||||||
|
this.increase(this._cut)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.continuous) {
|
||||||
|
if (this.percent >= 100) {
|
||||||
|
this.skipTimerCount = 1
|
||||||
|
|
||||||
|
this.reversed = !this.reversed
|
||||||
|
} else if (this.percent <= 0) {
|
||||||
|
this.skipTimerCount = 1
|
||||||
|
|
||||||
|
this.reversed = !this.reversed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
render (h) {
|
||||||
|
let el = h(false)
|
||||||
|
if (this.show) {
|
||||||
|
el = h('div', {
|
||||||
|
staticClass: 'nuxt-progress',
|
||||||
|
class: {
|
||||||
|
'nuxt-progress-notransition': this.skipTimerCount > 0,
|
||||||
|
'nuxt-progress-failed': !this.canSucceed
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
width: this.percent + '%',
|
||||||
|
left: this.left
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.nuxt-progress {
|
||||||
|
position: fixed;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
right: 0px;
|
||||||
|
height: 2px;
|
||||||
|
width: 0%;
|
||||||
|
opacity: 1;
|
||||||
|
transition: width 0.1s, opacity 0.4s;
|
||||||
|
background-color: black;
|
||||||
|
z-index: 999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nuxt-progress.nuxt-progress-notransition {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nuxt-progress-failed {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
</style>
|
101
client/.nuxt/components/nuxt.js
Normal file
101
client/.nuxt/components/nuxt.js
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import { compile } from '../utils'
|
||||||
|
|
||||||
|
import NuxtError from './nuxt-error.vue'
|
||||||
|
|
||||||
|
import NuxtChild from './nuxt-child'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Nuxt',
|
||||||
|
components: {
|
||||||
|
NuxtChild,
|
||||||
|
NuxtError
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
nuxtChildKey: {
|
||||||
|
type: String,
|
||||||
|
default: undefined
|
||||||
|
},
|
||||||
|
keepAlive: Boolean,
|
||||||
|
keepAliveProps: {
|
||||||
|
type: Object,
|
||||||
|
default: undefined
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: 'default'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorCaptured (error) {
|
||||||
|
// if we receive and error while showing the NuxtError component
|
||||||
|
// capture the error and force an immediate update so we re-render
|
||||||
|
// without the NuxtError component
|
||||||
|
if (this.displayingNuxtError) {
|
||||||
|
this.errorFromNuxtError = error
|
||||||
|
this.$forceUpdate()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
routerViewKey () {
|
||||||
|
// If nuxtChildKey prop is given or current route has children
|
||||||
|
if (typeof this.nuxtChildKey !== 'undefined' || this.$route.matched.length > 1) {
|
||||||
|
return this.nuxtChildKey || compile(this.$route.matched[0].path)(this.$route.params)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [matchedRoute] = this.$route.matched
|
||||||
|
|
||||||
|
if (!matchedRoute) {
|
||||||
|
return this.$route.path
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = matchedRoute.components.default
|
||||||
|
|
||||||
|
if (Component && Component.options) {
|
||||||
|
const { options } = Component
|
||||||
|
|
||||||
|
if (options.key) {
|
||||||
|
return (typeof options.key === 'function' ? options.key(this.$route) : options.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const strict = /\/$/.test(matchedRoute.path)
|
||||||
|
return strict ? this.$route.path : this.$route.path.replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeCreate () {
|
||||||
|
Vue.util.defineReactive(this, 'nuxt', this.$root.$options.nuxt)
|
||||||
|
},
|
||||||
|
render (h) {
|
||||||
|
// if there is no error
|
||||||
|
if (!this.nuxt.err) {
|
||||||
|
// Directly return nuxt child
|
||||||
|
return h('NuxtChild', {
|
||||||
|
key: this.routerViewKey,
|
||||||
|
props: this.$props
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// if an error occurred within NuxtError show a simple
|
||||||
|
// error message instead to prevent looping
|
||||||
|
if (this.errorFromNuxtError) {
|
||||||
|
this.$nextTick(() => (this.errorFromNuxtError = false))
|
||||||
|
|
||||||
|
return h('div', {}, [
|
||||||
|
h('h2', 'An error occurred while showing the error page'),
|
||||||
|
h('p', 'Unfortunately an error occurred and while showing the error page another error occurred'),
|
||||||
|
h('p', `Error details: ${this.errorFromNuxtError.toString()}`),
|
||||||
|
h('nuxt-link', { props: { to: '/' } }, 'Go back to home')
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// track if we are showing the NuxtError component
|
||||||
|
this.displayingNuxtError = true
|
||||||
|
this.$nextTick(() => (this.displayingNuxtError = false))
|
||||||
|
|
||||||
|
return h(NuxtError, {
|
||||||
|
props: {
|
||||||
|
error: this.nuxt.err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
33
client/.nuxt/components/plugin.js
Normal file
33
client/.nuxt/components/plugin.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import { wrapFunctional } from './utils'
|
||||||
|
|
||||||
|
const components = {
|
||||||
|
AudioPlayer: () => import('../..\\components\\AudioPlayer.vue' /* webpackChunkName: "components/audio-player" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
AppAppbar: () => import('../..\\components\\app\\Appbar.vue' /* webpackChunkName: "components/app-appbar" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
AppBookShelf: () => import('../..\\components\\app\\BookShelf.vue' /* webpackChunkName: "components/app-book-shelf" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
AppStreamContainer: () => import('../..\\components\\app\\StreamContainer.vue' /* webpackChunkName: "components/app-stream-container" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
AppTracksTable: () => import('../..\\components\\app\\TracksTable.vue' /* webpackChunkName: "components/app-tracks-table" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
CardsBookCard: () => import('../..\\components\\cards\\BookCard.vue' /* webpackChunkName: "components/cards-book-card" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
CardsBookCover: () => import('../..\\components\\cards\\BookCover.vue' /* webpackChunkName: "components/cards-book-cover" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
ControlsVolumeControl: () => import('../..\\components\\controls\\VolumeControl.vue' /* webpackChunkName: "components/controls-volume-control" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
ModalsEditModal: () => import('../..\\components\\modals\\EditModal.vue' /* webpackChunkName: "components/modals-edit-modal" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
ModalsModal: () => import('../..\\components\\modals\\Modal.vue' /* webpackChunkName: "components/modals-modal" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
UiBtn: () => import('../..\\components\\ui\\Btn.vue' /* webpackChunkName: "components/ui-btn" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
UiLoadingIndicator: () => import('../..\\components\\ui\\LoadingIndicator.vue' /* webpackChunkName: "components/ui-loading-indicator" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
UiMenu: () => import('../..\\components\\ui\\Menu.vue' /* webpackChunkName: "components/ui-menu" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
UiTextareaInput: () => import('../..\\components\\ui\\TextareaInput.vue' /* webpackChunkName: "components/ui-textarea-input" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
UiTextareaWithLabel: () => import('../..\\components\\ui\\TextareaWithLabel.vue' /* webpackChunkName: "components/ui-textarea-with-label" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
UiTextInput: () => import('../..\\components\\ui\\TextInput.vue' /* webpackChunkName: "components/ui-text-input" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
UiTextInputWithLabel: () => import('../..\\components\\ui\\TextInputWithLabel.vue' /* webpackChunkName: "components/ui-text-input-with-label" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
UiTooltip: () => import('../..\\components\\ui\\Tooltip.vue' /* webpackChunkName: "components/ui-tooltip" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
WidgetsScanAlert: () => import('../..\\components\\widgets\\ScanAlert.vue' /* webpackChunkName: "components/widgets-scan-alert" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
ModalsEditTabsCover: () => import('../..\\components\\modals\\edit-tabs\\Cover.vue' /* webpackChunkName: "components/modals-edit-tabs-cover" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
ModalsEditTabsDetails: () => import('../..\\components\\modals\\edit-tabs\\Details.vue' /* webpackChunkName: "components/modals-edit-tabs-details" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
ModalsEditTabsMatch: () => import('../..\\components\\modals\\edit-tabs\\Match.vue' /* webpackChunkName: "components/modals-edit-tabs-match" */).then(c => wrapFunctional(c.default || c)),
|
||||||
|
ModalsEditTabsTracks: () => import('../..\\components\\modals\\edit-tabs\\Tracks.vue' /* webpackChunkName: "components/modals-edit-tabs-tracks" */).then(c => wrapFunctional(c.default || c))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name in components) {
|
||||||
|
Vue.component(name, components[name])
|
||||||
|
Vue.component('Lazy' + name, components[name])
|
||||||
|
}
|
31
client/.nuxt/components/readme.md
Normal file
31
client/.nuxt/components/readme.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Discovered Components
|
||||||
|
|
||||||
|
This is an auto-generated list of components discovered by [nuxt/components](https://github.com/nuxt/components).
|
||||||
|
|
||||||
|
You can directly use them in pages and other components without the need to import them.
|
||||||
|
|
||||||
|
**Tip:** If a component is conditionally rendered with `v-if` and is big, it is better to use `Lazy` or `lazy-` prefix to lazy load.
|
||||||
|
|
||||||
|
- `<AudioPlayer>` | `<audio-player>` (components/AudioPlayer.vue)
|
||||||
|
- `<AppAppbar>` | `<app-appbar>` (components/app/Appbar.vue)
|
||||||
|
- `<AppBookShelf>` | `<app-book-shelf>` (components/app/BookShelf.vue)
|
||||||
|
- `<AppStreamContainer>` | `<app-stream-container>` (components/app/StreamContainer.vue)
|
||||||
|
- `<AppTracksTable>` | `<app-tracks-table>` (components/app/TracksTable.vue)
|
||||||
|
- `<CardsBookCard>` | `<cards-book-card>` (components/cards/BookCard.vue)
|
||||||
|
- `<CardsBookCover>` | `<cards-book-cover>` (components/cards/BookCover.vue)
|
||||||
|
- `<ControlsVolumeControl>` | `<controls-volume-control>` (components/controls/VolumeControl.vue)
|
||||||
|
- `<ModalsEditModal>` | `<modals-edit-modal>` (components/modals/EditModal.vue)
|
||||||
|
- `<ModalsModal>` | `<modals-modal>` (components/modals/Modal.vue)
|
||||||
|
- `<UiBtn>` | `<ui-btn>` (components/ui/Btn.vue)
|
||||||
|
- `<UiLoadingIndicator>` | `<ui-loading-indicator>` (components/ui/LoadingIndicator.vue)
|
||||||
|
- `<UiMenu>` | `<ui-menu>` (components/ui/Menu.vue)
|
||||||
|
- `<UiTextareaInput>` | `<ui-textarea-input>` (components/ui/TextareaInput.vue)
|
||||||
|
- `<UiTextareaWithLabel>` | `<ui-textarea-with-label>` (components/ui/TextareaWithLabel.vue)
|
||||||
|
- `<UiTextInput>` | `<ui-text-input>` (components/ui/TextInput.vue)
|
||||||
|
- `<UiTextInputWithLabel>` | `<ui-text-input-with-label>` (components/ui/TextInputWithLabel.vue)
|
||||||
|
- `<UiTooltip>` | `<ui-tooltip>` (components/ui/Tooltip.vue)
|
||||||
|
- `<WidgetsScanAlert>` | `<widgets-scan-alert>` (components/widgets/ScanAlert.vue)
|
||||||
|
- `<ModalsEditTabsCover>` | `<modals-edit-tabs-cover>` (components/modals/edit-tabs/Cover.vue)
|
||||||
|
- `<ModalsEditTabsDetails>` | `<modals-edit-tabs-details>` (components/modals/edit-tabs/Details.vue)
|
||||||
|
- `<ModalsEditTabsMatch>` | `<modals-edit-tabs-match>` (components/modals/edit-tabs/Match.vue)
|
||||||
|
- `<ModalsEditTabsTracks>` | `<modals-edit-tabs-tracks>` (components/modals/edit-tabs/Tracks.vue)
|
30
client/.nuxt/components/utils.js
Normal file
30
client/.nuxt/components/utils.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// nuxt/nuxt.js#8607
|
||||||
|
export function wrapFunctional(options) {
|
||||||
|
if (!options || !options.functional) {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
const propKeys = Array.isArray(options.props) ? options.props : Object.keys(options.props || {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
render(h) {
|
||||||
|
const attrs = {}
|
||||||
|
const props = {}
|
||||||
|
|
||||||
|
for (const key in this.$attrs) {
|
||||||
|
if (propKeys.includes(key)) {
|
||||||
|
props[key] = this.$attrs[key]
|
||||||
|
} else {
|
||||||
|
attrs[key] = this.$attrs[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(options, {
|
||||||
|
on: this.$listeners,
|
||||||
|
attrs,
|
||||||
|
props,
|
||||||
|
scopedSlots: this.$scopedSlots,
|
||||||
|
}, this.$slots.default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
client/.nuxt/empty.js
Normal file
1
client/.nuxt/empty.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
// This file is intentionally left empty for noop aliases
|
279
client/.nuxt/index.js
Normal file
279
client/.nuxt/index.js
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import Vuex from 'vuex'
|
||||||
|
import Meta from 'vue-meta'
|
||||||
|
import ClientOnly from 'vue-client-only'
|
||||||
|
import NoSsr from 'vue-no-ssr'
|
||||||
|
import { createRouter } from './router.js'
|
||||||
|
import NuxtChild from './components/nuxt-child.js'
|
||||||
|
import NuxtError from './components/nuxt-error.vue'
|
||||||
|
import Nuxt from './components/nuxt.js'
|
||||||
|
import App from './App.js'
|
||||||
|
import { setContext, getLocation, getRouteData, normalizeError } from './utils'
|
||||||
|
import { createStore } from './store.js'
|
||||||
|
|
||||||
|
/* Plugins */
|
||||||
|
|
||||||
|
import nuxt_plugin_plugin_30872e99 from 'nuxt_plugin_plugin_30872e99' // Source: .\\components\\plugin.js (mode: 'all')
|
||||||
|
import nuxt_plugin_axios_65e5003c from 'nuxt_plugin_axios_65e5003c' // Source: .\\axios.js (mode: 'all')
|
||||||
|
import nuxt_plugin_nuxtsocketio_e6f92ab4 from 'nuxt_plugin_nuxtsocketio_e6f92ab4' // Source: .\\nuxt-socket-io.js (mode: 'all')
|
||||||
|
import nuxt_plugin_initclient_bdf7e43c from 'nuxt_plugin_initclient_bdf7e43c' // Source: ..\\plugins\\init.client.js (mode: 'client')
|
||||||
|
import nuxt_plugin_axios_397e53b5 from 'nuxt_plugin_axios_397e53b5' // Source: ..\\plugins\\axios.js (mode: 'all')
|
||||||
|
import nuxt_plugin_toast_05aea064 from 'nuxt_plugin_toast_05aea064' // Source: ..\\plugins\\toast.js (mode: 'all')
|
||||||
|
|
||||||
|
// Component: <ClientOnly>
|
||||||
|
Vue.component(ClientOnly.name, ClientOnly)
|
||||||
|
|
||||||
|
// TODO: Remove in Nuxt 3: <NoSsr>
|
||||||
|
Vue.component(NoSsr.name, {
|
||||||
|
...NoSsr,
|
||||||
|
render (h, ctx) {
|
||||||
|
if (process.client && !NoSsr._warned) {
|
||||||
|
NoSsr._warned = true
|
||||||
|
|
||||||
|
console.warn('<no-ssr> has been deprecated and will be removed in Nuxt 3, please use <client-only> instead')
|
||||||
|
}
|
||||||
|
return NoSsr.render(h, ctx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Component: <NuxtChild>
|
||||||
|
Vue.component(NuxtChild.name, NuxtChild)
|
||||||
|
Vue.component('NChild', NuxtChild)
|
||||||
|
|
||||||
|
// Component NuxtLink is imported in server.js or client.js
|
||||||
|
|
||||||
|
// Component: <Nuxt>
|
||||||
|
Vue.component(Nuxt.name, Nuxt)
|
||||||
|
|
||||||
|
Object.defineProperty(Vue.prototype, '$nuxt', {
|
||||||
|
get() {
|
||||||
|
const globalNuxt = this.$root.$options.$nuxt
|
||||||
|
if (process.client && !globalNuxt && typeof window !== 'undefined') {
|
||||||
|
return window.$nuxt
|
||||||
|
}
|
||||||
|
return globalNuxt
|
||||||
|
},
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.use(Meta, {"keyName":"head","attribute":"data-n-head","ssrAttribute":"data-n-head-ssr","tagIDKeyName":"hid"})
|
||||||
|
|
||||||
|
const defaultTransition = {"name":"page","mode":"out-in","appear":true,"appearClass":"appear","appearActiveClass":"appear-active","appearToClass":"appear-to"}
|
||||||
|
|
||||||
|
const originalRegisterModule = Vuex.Store.prototype.registerModule
|
||||||
|
|
||||||
|
function registerModule (path, rawModule, options = {}) {
|
||||||
|
const preserveState = process.client && (
|
||||||
|
Array.isArray(path)
|
||||||
|
? !!path.reduce((namespacedState, path) => namespacedState && namespacedState[path], this.state)
|
||||||
|
: path in this.state
|
||||||
|
)
|
||||||
|
return originalRegisterModule.call(this, path, rawModule, { preserveState, ...options })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApp(ssrContext, config = {}) {
|
||||||
|
const router = await createRouter(ssrContext, config)
|
||||||
|
|
||||||
|
const store = createStore(ssrContext)
|
||||||
|
// Add this.$router into store actions/mutations
|
||||||
|
store.$router = router
|
||||||
|
|
||||||
|
// Create Root instance
|
||||||
|
|
||||||
|
// here we inject the router and store to all child components,
|
||||||
|
// making them available everywhere as `this.$router` and `this.$store`.
|
||||||
|
const app = {
|
||||||
|
head: {"title":"AudioBookshelf","htmlAttrs":{"lang":"en"},"meta":[{"charset":"utf-8"},{"name":"viewport","content":"width=device-width, initial-scale=1"},{"hid":"description","name":"description","content":""}],"script":[{"src":"\u002F\u002Fcdn.jsdelivr.net\u002Fnpm\u002Fsortablejs@1.8.4\u002FSortable.min.js"}],"link":[{"rel":"icon","type":"image\u002Fx-icon","href":"\u002Ffavicon.ico"},{"rel":"stylesheet","href":"https:\u002F\u002Ffonts.googleapis.com\u002Fcss2?family=Fira+Mono&family=Ubuntu+Mono&family=Open+Sans:wght@600&family=Gentium+Book+Basic"},{"rel":"stylesheet","href":"https:\u002F\u002Ffonts.googleapis.com\u002Ficon?family=Material+Icons"}],"style":[]},
|
||||||
|
|
||||||
|
store,
|
||||||
|
router,
|
||||||
|
nuxt: {
|
||||||
|
defaultTransition,
|
||||||
|
transitions: [defaultTransition],
|
||||||
|
setTransitions (transitions) {
|
||||||
|
if (!Array.isArray(transitions)) {
|
||||||
|
transitions = [transitions]
|
||||||
|
}
|
||||||
|
transitions = transitions.map((transition) => {
|
||||||
|
if (!transition) {
|
||||||
|
transition = defaultTransition
|
||||||
|
} else if (typeof transition === 'string') {
|
||||||
|
transition = Object.assign({}, defaultTransition, { name: transition })
|
||||||
|
} else {
|
||||||
|
transition = Object.assign({}, defaultTransition, transition)
|
||||||
|
}
|
||||||
|
return transition
|
||||||
|
})
|
||||||
|
this.$options.nuxt.transitions = transitions
|
||||||
|
return transitions
|
||||||
|
},
|
||||||
|
|
||||||
|
err: null,
|
||||||
|
dateErr: null,
|
||||||
|
error (err) {
|
||||||
|
err = err || null
|
||||||
|
app.context._errored = Boolean(err)
|
||||||
|
err = err ? normalizeError(err) : null
|
||||||
|
let nuxt = app.nuxt // to work with @vue/composition-api, see https://github.com/nuxt/nuxt.js/issues/6517#issuecomment-573280207
|
||||||
|
if (this) {
|
||||||
|
nuxt = this.nuxt || this.$options.nuxt
|
||||||
|
}
|
||||||
|
nuxt.dateErr = Date.now()
|
||||||
|
nuxt.err = err
|
||||||
|
// Used in src/server.js
|
||||||
|
if (ssrContext) {
|
||||||
|
ssrContext.nuxt.error = err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...App
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make app available into store via this.app
|
||||||
|
store.app = app
|
||||||
|
|
||||||
|
const next = ssrContext ? ssrContext.next : location => app.router.push(location)
|
||||||
|
// Resolve route
|
||||||
|
let route
|
||||||
|
if (ssrContext) {
|
||||||
|
route = router.resolve(ssrContext.url).route
|
||||||
|
} else {
|
||||||
|
const path = getLocation(router.options.base, router.options.mode)
|
||||||
|
route = router.resolve(path).route
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set context to app.context
|
||||||
|
await setContext(app, {
|
||||||
|
store,
|
||||||
|
route,
|
||||||
|
next,
|
||||||
|
error: app.nuxt.error.bind(app),
|
||||||
|
payload: ssrContext ? ssrContext.payload : undefined,
|
||||||
|
req: ssrContext ? ssrContext.req : undefined,
|
||||||
|
res: ssrContext ? ssrContext.res : undefined,
|
||||||
|
beforeRenderFns: ssrContext ? ssrContext.beforeRenderFns : undefined,
|
||||||
|
ssrContext
|
||||||
|
})
|
||||||
|
|
||||||
|
function inject(key, value) {
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('inject(key, value) has no key provided')
|
||||||
|
}
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error(`inject('${key}', value) has no value provided`)
|
||||||
|
}
|
||||||
|
|
||||||
|
key = '$' + key
|
||||||
|
// Add into app
|
||||||
|
app[key] = value
|
||||||
|
// Add into context
|
||||||
|
if (!app.context[key]) {
|
||||||
|
app.context[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add into store
|
||||||
|
store[key] = app[key]
|
||||||
|
|
||||||
|
// Check if plugin not already installed
|
||||||
|
const installKey = '__nuxt_' + key + '_installed__'
|
||||||
|
if (Vue[installKey]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Vue[installKey] = true
|
||||||
|
// Call Vue.use() to install the plugin into vm
|
||||||
|
Vue.use(() => {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(Vue.prototype, key)) {
|
||||||
|
Object.defineProperty(Vue.prototype, key, {
|
||||||
|
get () {
|
||||||
|
return this.$root.$options[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject runtime config as $config
|
||||||
|
inject('config', config)
|
||||||
|
|
||||||
|
if (process.client) {
|
||||||
|
// Replace store state before plugins execution
|
||||||
|
if (window.__NUXT__ && window.__NUXT__.state) {
|
||||||
|
store.replaceState(window.__NUXT__.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add enablePreview(previewData = {}) in context for plugins
|
||||||
|
if (process.static && process.client) {
|
||||||
|
app.context.enablePreview = function (previewData = {}) {
|
||||||
|
app.previewData = Object.assign({}, previewData)
|
||||||
|
inject('preview', previewData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Plugin execution
|
||||||
|
|
||||||
|
if (typeof nuxt_plugin_plugin_30872e99 === 'function') {
|
||||||
|
await nuxt_plugin_plugin_30872e99(app.context, inject)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof nuxt_plugin_axios_65e5003c === 'function') {
|
||||||
|
await nuxt_plugin_axios_65e5003c(app.context, inject)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof nuxt_plugin_nuxtsocketio_e6f92ab4 === 'function') {
|
||||||
|
await nuxt_plugin_nuxtsocketio_e6f92ab4(app.context, inject)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.client && typeof nuxt_plugin_initclient_bdf7e43c === 'function') {
|
||||||
|
await nuxt_plugin_initclient_bdf7e43c(app.context, inject)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof nuxt_plugin_axios_397e53b5 === 'function') {
|
||||||
|
await nuxt_plugin_axios_397e53b5(app.context, inject)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof nuxt_plugin_toast_05aea064 === 'function') {
|
||||||
|
await nuxt_plugin_toast_05aea064(app.context, inject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock enablePreview in context
|
||||||
|
if (process.static && process.client) {
|
||||||
|
app.context.enablePreview = function () {
|
||||||
|
console.warn('You cannot call enablePreview() outside a plugin.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for async component to be resolved first
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const { route } = router.resolve(app.context.route.fullPath)
|
||||||
|
// Ignore 404s rather than blindly replacing URL
|
||||||
|
if (!route.matched.length && process.client) {
|
||||||
|
return resolve()
|
||||||
|
}
|
||||||
|
router.replace(route, resolve, (err) => {
|
||||||
|
// https://github.com/vuejs/vue-router/blob/v3.4.3/src/util/errors.js
|
||||||
|
if (!err._isRouter) return reject(err)
|
||||||
|
if (err.type !== 2 /* NavigationFailureType.redirected */) return resolve()
|
||||||
|
|
||||||
|
// navigated to a different route in router guard
|
||||||
|
const unregister = router.afterEach(async (to, from) => {
|
||||||
|
if (process.server && ssrContext && ssrContext.url) {
|
||||||
|
ssrContext.url = to.fullPath
|
||||||
|
}
|
||||||
|
app.context.route = await getRouteData(to)
|
||||||
|
app.context.params = to.params || {}
|
||||||
|
app.context.query = to.query || {}
|
||||||
|
unregister()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
app,
|
||||||
|
router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createApp, NuxtError }
|
82
client/.nuxt/jsonp.js
Normal file
82
client/.nuxt/jsonp.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
const chunks = {} // chunkId => exports
|
||||||
|
const chunksInstalling = {} // chunkId => Promise
|
||||||
|
const failedChunks = {}
|
||||||
|
|
||||||
|
function importChunk(chunkId, src) {
|
||||||
|
// Already installed
|
||||||
|
if (chunks[chunkId]) {
|
||||||
|
return Promise.resolve(chunks[chunkId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed loading
|
||||||
|
if (failedChunks[chunkId]) {
|
||||||
|
return Promise.reject(failedChunks[chunkId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installing
|
||||||
|
if (chunksInstalling[chunkId]) {
|
||||||
|
return chunksInstalling[chunkId]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a promise in chunk cache
|
||||||
|
let resolve, reject
|
||||||
|
const promise = chunksInstalling[chunkId] = new Promise((_resolve, _reject) => {
|
||||||
|
resolve = _resolve
|
||||||
|
reject = _reject
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear chunk data from cache
|
||||||
|
delete chunks[chunkId]
|
||||||
|
|
||||||
|
// Start chunk loading
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.charset = 'utf-8'
|
||||||
|
script.timeout = 120
|
||||||
|
script.src = src
|
||||||
|
let timeout
|
||||||
|
|
||||||
|
// Create error before stack unwound to get useful stacktrace later
|
||||||
|
const error = new Error()
|
||||||
|
|
||||||
|
// Complete handlers
|
||||||
|
const onScriptComplete = script.onerror = script.onload = (event) => {
|
||||||
|
// Cleanups
|
||||||
|
clearTimeout(timeout)
|
||||||
|
delete chunksInstalling[chunkId]
|
||||||
|
|
||||||
|
// Avoid mem leaks in IE
|
||||||
|
script.onerror = script.onload = null
|
||||||
|
|
||||||
|
// Verify chunk is loaded
|
||||||
|
if (chunks[chunkId]) {
|
||||||
|
return resolve(chunks[chunkId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Something bad happened
|
||||||
|
const errorType = event && (event.type === 'load' ? 'missing' : event.type)
|
||||||
|
const realSrc = event && event.target && event.target.src
|
||||||
|
error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')'
|
||||||
|
error.name = 'ChunkLoadError'
|
||||||
|
error.type = errorType
|
||||||
|
error.request = realSrc
|
||||||
|
failedChunks[chunkId] = error
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
onScriptComplete({ type: 'timeout', target: script })
|
||||||
|
}, 120000)
|
||||||
|
|
||||||
|
// Append script
|
||||||
|
document.head.appendChild(script)
|
||||||
|
|
||||||
|
// Return promise
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installJsonp() {
|
||||||
|
window.__NUXT_JSONP__ = function (chunkId, exports) { chunks[chunkId] = exports }
|
||||||
|
window.__NUXT_JSONP_CACHE__ = chunks
|
||||||
|
window.__NUXT_IMPORT__ = importChunk
|
||||||
|
}
|
110
client/.nuxt/loading.html
Normal file
110
client/.nuxt/loading.html
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<style>
|
||||||
|
#nuxt-loading {
|
||||||
|
background: white;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: nuxtLoadingIn 10s ease;
|
||||||
|
-webkit-animation: nuxtLoadingIn 10s ease;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nuxtLoadingIn {
|
||||||
|
0% {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes nuxtLoadingIn {
|
||||||
|
0% {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#nuxt-loading>div,
|
||||||
|
#nuxt-loading>div:after {
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nuxt-loading>div {
|
||||||
|
font-size: 10px;
|
||||||
|
position: relative;
|
||||||
|
text-indent: -9999em;
|
||||||
|
border: .5rem solid #F5F5F5;
|
||||||
|
border-left: .5rem solid black;
|
||||||
|
-webkit-transform: translateZ(0);
|
||||||
|
-ms-transform: translateZ(0);
|
||||||
|
transform: translateZ(0);
|
||||||
|
-webkit-animation: nuxtLoading 1.1s infinite linear;
|
||||||
|
animation: nuxtLoading 1.1s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nuxt-loading.error>div {
|
||||||
|
border-left: .5rem solid #ff4500;
|
||||||
|
animation-duration: 5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes nuxtLoading {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-transform: rotate(360deg);
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nuxtLoading {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-transform: rotate(360deg);
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.addEventListener('error', function () {
|
||||||
|
var e = document.getElementById('nuxt-loading');
|
||||||
|
if (e) {
|
||||||
|
e.className += ' error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="nuxt-loading" aria-live="polite" role="status"><div>Loading...</div></div>
|
||||||
|
|
||||||
|
<!-- https://projects.lukehaas.me/css-loaders -->
|
3
client/.nuxt/middleware.js
Normal file
3
client/.nuxt/middleware.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const middleware = {}
|
||||||
|
|
||||||
|
export default middleware
|
90
client/.nuxt/mixins/fetch.client.js
Normal file
90
client/.nuxt/mixins/fetch.client.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import { hasFetch, normalizeError, addLifecycleHook, createGetCounter } from '../utils'
|
||||||
|
|
||||||
|
const isSsrHydration = (vm) => vm.$vnode && vm.$vnode.elm && vm.$vnode.elm.dataset && vm.$vnode.elm.dataset.fetchKey
|
||||||
|
const nuxtState = window.__NUXT__
|
||||||
|
|
||||||
|
export default {
|
||||||
|
beforeCreate () {
|
||||||
|
if (!hasFetch(this)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._fetchDelay = typeof this.$options.fetchDelay === 'number' ? this.$options.fetchDelay : 200
|
||||||
|
|
||||||
|
Vue.util.defineReactive(this, '$fetchState', {
|
||||||
|
pending: false,
|
||||||
|
error: null,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.$fetch = $fetch.bind(this)
|
||||||
|
addLifecycleHook(this, 'created', created)
|
||||||
|
addLifecycleHook(this, 'beforeMount', beforeMount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeMount() {
|
||||||
|
if (!this._hydrated) {
|
||||||
|
return this.$fetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function created() {
|
||||||
|
if (!isSsrHydration(this)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hydrate component
|
||||||
|
this._hydrated = true
|
||||||
|
this._fetchKey = this.$vnode.elm.dataset.fetchKey
|
||||||
|
const data = nuxtState.fetch[this._fetchKey]
|
||||||
|
|
||||||
|
// If fetch error
|
||||||
|
if (data && data._error) {
|
||||||
|
this.$fetchState.error = data._error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge data
|
||||||
|
for (const key in data) {
|
||||||
|
Vue.set(this.$data, key, data[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function $fetch() {
|
||||||
|
if (!this._fetchPromise) {
|
||||||
|
this._fetchPromise = $_fetch.call(this)
|
||||||
|
.then(() => { delete this._fetchPromise })
|
||||||
|
}
|
||||||
|
return this._fetchPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function $_fetch() {
|
||||||
|
this.$nuxt.nbFetching++
|
||||||
|
this.$fetchState.pending = true
|
||||||
|
this.$fetchState.error = null
|
||||||
|
this._hydrated = false
|
||||||
|
let error = null
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.$options.fetch.call(this)
|
||||||
|
} catch (err) {
|
||||||
|
if (process.dev) {
|
||||||
|
console.error('Error in fetch():', err)
|
||||||
|
}
|
||||||
|
error = normalizeError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayLeft = this._fetchDelay - (Date.now() - startTime)
|
||||||
|
if (delayLeft > 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayLeft))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$fetchState.error = error
|
||||||
|
this.$fetchState.pending = false
|
||||||
|
this.$fetchState.timestamp = Date.now()
|
||||||
|
|
||||||
|
this.$nextTick(() => this.$nuxt.nbFetching--)
|
||||||
|
}
|
69
client/.nuxt/mixins/fetch.server.js
Normal file
69
client/.nuxt/mixins/fetch.server.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import { hasFetch, normalizeError, addLifecycleHook, purifyData, createGetCounter } from '../utils'
|
||||||
|
|
||||||
|
async function serverPrefetch() {
|
||||||
|
if (!this._fetchOnServer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call and await on $fetch
|
||||||
|
try {
|
||||||
|
await this.$options.fetch.call(this)
|
||||||
|
} catch (err) {
|
||||||
|
if (process.dev) {
|
||||||
|
console.error('Error in fetch():', err)
|
||||||
|
}
|
||||||
|
this.$fetchState.error = normalizeError(err)
|
||||||
|
}
|
||||||
|
this.$fetchState.pending = false
|
||||||
|
|
||||||
|
// Define an ssrKey for hydration
|
||||||
|
this._fetchKey = this._fetchKey || this.$ssrContext.fetchCounters['']++
|
||||||
|
|
||||||
|
// Add data-fetch-key on parent element of Component
|
||||||
|
const attrs = this.$vnode.data.attrs = this.$vnode.data.attrs || {}
|
||||||
|
attrs['data-fetch-key'] = this._fetchKey
|
||||||
|
|
||||||
|
// Add to ssrContext for window.__NUXT__.fetch
|
||||||
|
|
||||||
|
if (this.$ssrContext.nuxt.fetch[this._fetchKey] !== undefined) {
|
||||||
|
console.warn(`Duplicate fetch key detected (${this._fetchKey}). This may lead to unexpected results.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$ssrContext.nuxt.fetch[this._fetchKey] =
|
||||||
|
this.$fetchState.error ? { _error: this.$fetchState.error } : purifyData(this._data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
created() {
|
||||||
|
if (!hasFetch(this)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof this.$options.fetchOnServer === 'function') {
|
||||||
|
this._fetchOnServer = this.$options.fetchOnServer.call(this) !== false
|
||||||
|
} else {
|
||||||
|
this._fetchOnServer = this.$options.fetchOnServer !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultKey = this.$options._scopeId || this.$options.name || ''
|
||||||
|
const getCounter = createGetCounter(this.$ssrContext.fetchCounters, defaultKey)
|
||||||
|
|
||||||
|
if (typeof this.$options.fetchKey === 'function') {
|
||||||
|
this._fetchKey = this.$options.fetchKey.call(this, getCounter)
|
||||||
|
} else {
|
||||||
|
const key = 'string' === typeof this.$options.fetchKey ? this.$options.fetchKey : defaultKey
|
||||||
|
this._fetchKey = key ? key + ':' + getCounter(key) : String(getCounter(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Added for remove vue undefined warning while ssr
|
||||||
|
this.$fetch = () => {} // issue #8043
|
||||||
|
Vue.util.defineReactive(this, '$fetchState', {
|
||||||
|
pending: true,
|
||||||
|
error: null,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
addLifecycleHook(this, 'serverPrefetch', serverPrefetch)
|
||||||
|
}
|
||||||
|
}
|
1083
client/.nuxt/nuxt-socket-io.js
Normal file
1083
client/.nuxt/nuxt-socket-io.js
Normal file
File diff suppressed because it is too large
Load Diff
68
client/.nuxt/router.js
Normal file
68
client/.nuxt/router.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import Router from 'vue-router'
|
||||||
|
import { normalizeURL, decode } from 'ufo'
|
||||||
|
import { interopDefault } from './utils'
|
||||||
|
import scrollBehavior from './router.scrollBehavior.js'
|
||||||
|
|
||||||
|
const _e09ac034 = () => interopDefault(import('..\\pages\\config\\index.vue' /* webpackChunkName: "pages/config/index" */))
|
||||||
|
const _4bf07cff = () => interopDefault(import('..\\pages\\login.vue' /* webpackChunkName: "pages/login" */))
|
||||||
|
const _4885a1ad = () => interopDefault(import('..\\pages\\audiobook\\_id\\index.vue' /* webpackChunkName: "pages/audiobook/_id/index" */))
|
||||||
|
const _73d517ff = () => interopDefault(import('..\\pages\\audiobook\\_id\\edit.vue' /* webpackChunkName: "pages/audiobook/_id/edit" */))
|
||||||
|
const _fb6e4c30 = () => interopDefault(import('..\\pages\\index.vue' /* webpackChunkName: "pages/index" */))
|
||||||
|
|
||||||
|
const emptyFn = () => {}
|
||||||
|
|
||||||
|
Vue.use(Router)
|
||||||
|
|
||||||
|
export const routerOptions = {
|
||||||
|
mode: 'history',
|
||||||
|
base: '/',
|
||||||
|
linkActiveClass: 'nuxt-link-active',
|
||||||
|
linkExactActiveClass: 'nuxt-link-exact-active',
|
||||||
|
scrollBehavior,
|
||||||
|
|
||||||
|
routes: [{
|
||||||
|
path: "/config",
|
||||||
|
component: _e09ac034,
|
||||||
|
name: "config"
|
||||||
|
}, {
|
||||||
|
path: "/login",
|
||||||
|
component: _4bf07cff,
|
||||||
|
name: "login"
|
||||||
|
}, {
|
||||||
|
path: "/audiobook/:id",
|
||||||
|
component: _4885a1ad,
|
||||||
|
name: "audiobook-id"
|
||||||
|
}, {
|
||||||
|
path: "/audiobook/:id?/edit",
|
||||||
|
component: _73d517ff,
|
||||||
|
name: "audiobook-id-edit"
|
||||||
|
}, {
|
||||||
|
path: "/",
|
||||||
|
component: _fb6e4c30,
|
||||||
|
name: "index"
|
||||||
|
}],
|
||||||
|
|
||||||
|
fallback: false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRouter (ssrContext, config) {
|
||||||
|
const base = (config._app && config._app.basePath) || routerOptions.base
|
||||||
|
const router = new Router({ ...routerOptions, base })
|
||||||
|
|
||||||
|
// TODO: remove in Nuxt 3
|
||||||
|
const originalPush = router.push
|
||||||
|
router.push = function push (location, onComplete = emptyFn, onAbort) {
|
||||||
|
return originalPush.call(this, location, onComplete, onAbort)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolve = router.resolve.bind(router)
|
||||||
|
router.resolve = (to, current, append) => {
|
||||||
|
if (typeof to === 'string') {
|
||||||
|
to = normalizeURL(to)
|
||||||
|
}
|
||||||
|
return resolve(to, current, append)
|
||||||
|
}
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
76
client/.nuxt/router.scrollBehavior.js
Normal file
76
client/.nuxt/router.scrollBehavior.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { getMatchedComponents, setScrollRestoration } from './utils'
|
||||||
|
|
||||||
|
if (process.client) {
|
||||||
|
if ('scrollRestoration' in window.history) {
|
||||||
|
setScrollRestoration('manual')
|
||||||
|
|
||||||
|
// reset scrollRestoration to auto when leaving page, allowing page reload
|
||||||
|
// and back-navigation from other pages to use the browser to restore the
|
||||||
|
// scrolling position.
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
setScrollRestoration('auto')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Setting scrollRestoration to manual again when returning to this page.
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
setScrollRestoration('manual')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldScrollToTop(route) {
|
||||||
|
const Pages = getMatchedComponents(route)
|
||||||
|
if (Pages.length === 1) {
|
||||||
|
const { options = {} } = Pages[0]
|
||||||
|
return options.scrollToTop !== false
|
||||||
|
}
|
||||||
|
return Pages.some(({ options }) => options && options.scrollToTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (to, from, savedPosition) {
|
||||||
|
// If the returned position is falsy or an empty object, will retain current scroll position
|
||||||
|
let position = false
|
||||||
|
const isRouteChanged = to !== from
|
||||||
|
|
||||||
|
// savedPosition is only available for popstate navigations (back button)
|
||||||
|
if (savedPosition) {
|
||||||
|
position = savedPosition
|
||||||
|
} else if (isRouteChanged && shouldScrollToTop(to)) {
|
||||||
|
position = { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const nuxt = window.$nuxt
|
||||||
|
|
||||||
|
if (
|
||||||
|
// Initial load (vuejs/vue-router#3199)
|
||||||
|
!isRouteChanged ||
|
||||||
|
// Route hash changes
|
||||||
|
(to.path === from.path && to.hash !== from.hash)
|
||||||
|
) {
|
||||||
|
nuxt.$nextTick(() => nuxt.$emit('triggerScroll'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// wait for the out transition to complete (if necessary)
|
||||||
|
nuxt.$once('triggerScroll', () => {
|
||||||
|
// coords will be used if no selector is provided,
|
||||||
|
// or if the selector didn't match any element.
|
||||||
|
if (to.hash) {
|
||||||
|
let hash = to.hash
|
||||||
|
// CSS.escape() is not supported with IE and Edge.
|
||||||
|
if (typeof window.CSS !== 'undefined' && typeof window.CSS.escape !== 'undefined') {
|
||||||
|
hash = '#' + window.CSS.escape(hash.substr(1))
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (document.querySelector(hash)) {
|
||||||
|
// scroll to anchor by returning the selector
|
||||||
|
position = { selector: hash }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to save scroll position. Please add CSS.escape() polyfill (https://github.com/mathiasbynens/CSS.escape).')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(position)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
37
client/.nuxt/routes.json
Normal file
37
client/.nuxt/routes.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "config",
|
||||||
|
"path": "/config",
|
||||||
|
"component": "C:\\\\Users\\\\Coop\\\\Documents\\\\NodeProjects\\\\audiobookshelf\\\\client\\\\pages\\\\config\\\\index.vue",
|
||||||
|
"chunkName": "pages/config/index",
|
||||||
|
"_name": "_e09ac034"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "login",
|
||||||
|
"path": "/login",
|
||||||
|
"component": "C:\\\\Users\\\\Coop\\\\Documents\\\\NodeProjects\\\\audiobookshelf\\\\client\\\\pages\\\\login.vue",
|
||||||
|
"chunkName": "pages/login",
|
||||||
|
"_name": "_4bf07cff"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "audiobook-id",
|
||||||
|
"path": "/audiobook/:id",
|
||||||
|
"component": "C:\\\\Users\\\\Coop\\\\Documents\\\\NodeProjects\\\\audiobookshelf\\\\client\\\\pages\\\\audiobook\\\\_id\\\\index.vue",
|
||||||
|
"chunkName": "pages/audiobook/_id/index",
|
||||||
|
"_name": "_4885a1ad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "audiobook-id-edit",
|
||||||
|
"path": "/audiobook/:id?/edit",
|
||||||
|
"component": "C:\\\\Users\\\\Coop\\\\Documents\\\\NodeProjects\\\\audiobookshelf\\\\client\\\\pages\\\\audiobook\\\\_id\\\\edit.vue",
|
||||||
|
"chunkName": "pages/audiobook/_id/edit",
|
||||||
|
"_name": "_73d517ff"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index",
|
||||||
|
"path": "/",
|
||||||
|
"component": "C:\\\\Users\\\\Coop\\\\Documents\\\\NodeProjects\\\\audiobookshelf\\\\client\\\\pages\\\\index.vue",
|
||||||
|
"chunkName": "pages/index",
|
||||||
|
"_name": "_fb6e4c30"
|
||||||
|
}
|
||||||
|
]
|
312
client/.nuxt/server.js
Normal file
312
client/.nuxt/server.js
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import { joinURL, normalizeURL, withQuery } from 'ufo'
|
||||||
|
import fetch from 'node-fetch'
|
||||||
|
import middleware from './middleware.js'
|
||||||
|
import {
|
||||||
|
applyAsyncData,
|
||||||
|
middlewareSeries,
|
||||||
|
sanitizeComponent,
|
||||||
|
getMatchedComponents,
|
||||||
|
promisify
|
||||||
|
} from './utils.js'
|
||||||
|
import fetchMixin from './mixins/fetch.server'
|
||||||
|
import { createApp, NuxtError } from './index.js'
|
||||||
|
import NuxtLink from './components/nuxt-link.server.js' // should be included after ./index.js
|
||||||
|
|
||||||
|
// Update serverPrefetch strategy
|
||||||
|
Vue.config.optionMergeStrategies.serverPrefetch = Vue.config.optionMergeStrategies.created
|
||||||
|
|
||||||
|
// Fetch mixin
|
||||||
|
if (!Vue.__nuxt__fetch__mixin__) {
|
||||||
|
Vue.mixin(fetchMixin)
|
||||||
|
Vue.__nuxt__fetch__mixin__ = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Vue.__original_use__) {
|
||||||
|
Vue.__original_use__ = Vue.use
|
||||||
|
Vue.__install_times__ = 0
|
||||||
|
Vue.use = function (plugin, ...args) {
|
||||||
|
plugin.__nuxt_external_installed__ = Vue._installedPlugins.includes(plugin)
|
||||||
|
return Vue.__original_use__(plugin, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Vue.__install_times__ === 2) {
|
||||||
|
Vue.__install_times__ = 0
|
||||||
|
Vue._installedPlugins = Vue._installedPlugins.filter(plugin => {
|
||||||
|
return plugin.__nuxt_external_installed__ === true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Vue.__install_times__++
|
||||||
|
|
||||||
|
// Component: <NuxtLink>
|
||||||
|
Vue.component(NuxtLink.name, NuxtLink)
|
||||||
|
Vue.component('NLink', NuxtLink)
|
||||||
|
|
||||||
|
if (!global.fetch) { global.fetch = fetch }
|
||||||
|
|
||||||
|
const noopApp = () => new Vue({ render: h => h('div', { domProps: { id: '__nuxt' } }) })
|
||||||
|
|
||||||
|
const createNext = ssrContext => (opts) => {
|
||||||
|
// If static target, render on client-side
|
||||||
|
ssrContext.redirected = opts
|
||||||
|
if (ssrContext.target === 'static' || !ssrContext.res) {
|
||||||
|
ssrContext.nuxt.serverRendered = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let fullPath = withQuery(opts.path, opts.query)
|
||||||
|
const $config = ssrContext.runtimeConfig || {}
|
||||||
|
const routerBase = ($config._app && $config._app.basePath) || '/'
|
||||||
|
if (!fullPath.startsWith('http') && (routerBase !== '/' && !fullPath.startsWith(routerBase))) {
|
||||||
|
fullPath = joinURL(routerBase, fullPath)
|
||||||
|
}
|
||||||
|
// Avoid loop redirect
|
||||||
|
if (decodeURI(fullPath) === decodeURI(ssrContext.url)) {
|
||||||
|
ssrContext.redirected = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ssrContext.res.writeHead(opts.status, {
|
||||||
|
Location: normalizeURL(fullPath)
|
||||||
|
})
|
||||||
|
ssrContext.res.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
// This exported function will be called by `bundleRenderer`.
|
||||||
|
// This is where we perform data-prefetching to determine the
|
||||||
|
// state of our application before actually rendering it.
|
||||||
|
// Since data fetching is async, this function is expected to
|
||||||
|
// return a Promise that resolves to the app instance.
|
||||||
|
export default async (ssrContext) => {
|
||||||
|
// Create ssrContext.next for simulate next() of beforeEach() when wanted to redirect
|
||||||
|
ssrContext.redirected = false
|
||||||
|
ssrContext.next = createNext(ssrContext)
|
||||||
|
// Used for beforeNuxtRender({ Components, nuxtState })
|
||||||
|
ssrContext.beforeRenderFns = []
|
||||||
|
// Nuxt object (window.{{globals.context}}, defaults to window.__NUXT__)
|
||||||
|
ssrContext.nuxt = { layout: 'default', data: [], fetch: {}, error: null, state: null, serverRendered: true, routePath: '' }
|
||||||
|
|
||||||
|
ssrContext.fetchCounters = {}
|
||||||
|
|
||||||
|
// Remove query from url is static target
|
||||||
|
|
||||||
|
// Public runtime config
|
||||||
|
ssrContext.nuxt.config = ssrContext.runtimeConfig.public
|
||||||
|
if (ssrContext.nuxt.config._app) {
|
||||||
|
__webpack_public_path__ = joinURL(ssrContext.nuxt.config._app.cdnURL, ssrContext.nuxt.config._app.assetsPath)
|
||||||
|
}
|
||||||
|
// Create the app definition and the instance (created for each request)
|
||||||
|
const { app, router, store } = await createApp(ssrContext, ssrContext.runtimeConfig.private)
|
||||||
|
const _app = new Vue(app)
|
||||||
|
// Add ssr route path to nuxt context so we can account for page navigation between ssr and csr
|
||||||
|
ssrContext.nuxt.routePath = app.context.route.path
|
||||||
|
|
||||||
|
// Add meta infos (used in renderer.js)
|
||||||
|
ssrContext.meta = _app.$meta()
|
||||||
|
|
||||||
|
// Keep asyncData for each matched component in ssrContext (used in app/utils.js via this.$ssrContext)
|
||||||
|
ssrContext.asyncData = {}
|
||||||
|
|
||||||
|
const beforeRender = async () => {
|
||||||
|
// Call beforeNuxtRender() methods
|
||||||
|
await Promise.all(ssrContext.beforeRenderFns.map(fn => promisify(fn, { Components, nuxtState: ssrContext.nuxt })))
|
||||||
|
|
||||||
|
ssrContext.rendered = () => {
|
||||||
|
// Add the state from the vuex store
|
||||||
|
ssrContext.nuxt.state = store.state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderErrorPage = async () => {
|
||||||
|
// Don't server-render the page in static target
|
||||||
|
if (ssrContext.target === 'static') {
|
||||||
|
ssrContext.nuxt.serverRendered = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load layout for error page
|
||||||
|
const layout = (NuxtError.options || NuxtError).layout
|
||||||
|
const errLayout = typeof layout === 'function' ? layout.call(NuxtError, app.context) : layout
|
||||||
|
ssrContext.nuxt.layout = errLayout || 'default'
|
||||||
|
await _app.loadLayout(errLayout)
|
||||||
|
_app.setLayout(errLayout)
|
||||||
|
|
||||||
|
await beforeRender()
|
||||||
|
return _app
|
||||||
|
}
|
||||||
|
const render404Page = () => {
|
||||||
|
app.context.error({ statusCode: 404, path: ssrContext.url, message: 'This page could not be found' })
|
||||||
|
return renderErrorPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = Date.now()
|
||||||
|
|
||||||
|
// Components are already resolved by setContext -> getRouteData (app/utils.js)
|
||||||
|
const Components = getMatchedComponents(app.context.route)
|
||||||
|
|
||||||
|
/*
|
||||||
|
** Dispatch store nuxtServerInit
|
||||||
|
*/
|
||||||
|
if (store._actions && store._actions.nuxtServerInit) {
|
||||||
|
try {
|
||||||
|
await store.dispatch('nuxtServerInit', app.context)
|
||||||
|
} catch (err) {
|
||||||
|
console.debug('Error occurred when calling nuxtServerInit: ', err.message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ...If there is a redirect or an error, stop the process
|
||||||
|
if (ssrContext.redirected) {
|
||||||
|
return noopApp()
|
||||||
|
}
|
||||||
|
if (ssrContext.nuxt.error) {
|
||||||
|
return renderErrorPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
** Call global middleware (nuxt.config.js)
|
||||||
|
*/
|
||||||
|
let midd = []
|
||||||
|
midd = midd.map((name) => {
|
||||||
|
if (typeof name === 'function') {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if (typeof middleware[name] !== 'function') {
|
||||||
|
app.context.error({ statusCode: 500, message: 'Unknown middleware ' + name })
|
||||||
|
}
|
||||||
|
return middleware[name]
|
||||||
|
})
|
||||||
|
await middlewareSeries(midd, app.context)
|
||||||
|
// ...If there is a redirect or an error, stop the process
|
||||||
|
if (ssrContext.redirected) {
|
||||||
|
return noopApp()
|
||||||
|
}
|
||||||
|
if (ssrContext.nuxt.error) {
|
||||||
|
return renderErrorPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
** Set layout
|
||||||
|
*/
|
||||||
|
let layout = Components.length ? Components[0].options.layout : NuxtError.layout
|
||||||
|
if (typeof layout === 'function') {
|
||||||
|
layout = layout(app.context)
|
||||||
|
}
|
||||||
|
await _app.loadLayout(layout)
|
||||||
|
if (ssrContext.nuxt.error) {
|
||||||
|
return renderErrorPage()
|
||||||
|
}
|
||||||
|
layout = _app.setLayout(layout)
|
||||||
|
ssrContext.nuxt.layout = _app.layoutName
|
||||||
|
|
||||||
|
/*
|
||||||
|
** Call middleware (layout + pages)
|
||||||
|
*/
|
||||||
|
midd = []
|
||||||
|
|
||||||
|
layout = sanitizeComponent(layout)
|
||||||
|
if (layout.options.middleware) {
|
||||||
|
midd = midd.concat(layout.options.middleware)
|
||||||
|
}
|
||||||
|
|
||||||
|
Components.forEach((Component) => {
|
||||||
|
if (Component.options.middleware) {
|
||||||
|
midd = midd.concat(Component.options.middleware)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
midd = midd.map((name) => {
|
||||||
|
if (typeof name === 'function') {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if (typeof middleware[name] !== 'function') {
|
||||||
|
app.context.error({ statusCode: 500, message: 'Unknown middleware ' + name })
|
||||||
|
}
|
||||||
|
return middleware[name]
|
||||||
|
})
|
||||||
|
await middlewareSeries(midd, app.context)
|
||||||
|
// ...If there is a redirect or an error, stop the process
|
||||||
|
if (ssrContext.redirected) {
|
||||||
|
return noopApp()
|
||||||
|
}
|
||||||
|
if (ssrContext.nuxt.error) {
|
||||||
|
return renderErrorPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
** Call .validate()
|
||||||
|
*/
|
||||||
|
let isValid = true
|
||||||
|
try {
|
||||||
|
for (const Component of Components) {
|
||||||
|
if (typeof Component.options.validate !== 'function') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid = await Component.options.validate(app.context)
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (validationError) {
|
||||||
|
// ...If .validate() threw an error
|
||||||
|
app.context.error({
|
||||||
|
statusCode: validationError.statusCode || '500',
|
||||||
|
message: validationError.message
|
||||||
|
})
|
||||||
|
return renderErrorPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...If .validate() returned false
|
||||||
|
if (!isValid) {
|
||||||
|
// Render a 404 error page
|
||||||
|
return render404Page()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no Components found, returns 404
|
||||||
|
if (!Components.length) {
|
||||||
|
return render404Page()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call asyncData & fetch hooks on components matched by the route.
|
||||||
|
const asyncDatas = await Promise.all(Components.map((Component) => {
|
||||||
|
const promises = []
|
||||||
|
|
||||||
|
// Call asyncData(context)
|
||||||
|
if (Component.options.asyncData && typeof Component.options.asyncData === 'function') {
|
||||||
|
const promise = promisify(Component.options.asyncData, app.context)
|
||||||
|
promise.then((asyncDataResult) => {
|
||||||
|
ssrContext.asyncData[Component.cid] = asyncDataResult
|
||||||
|
applyAsyncData(Component)
|
||||||
|
return asyncDataResult
|
||||||
|
})
|
||||||
|
promises.push(promise)
|
||||||
|
} else {
|
||||||
|
promises.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call fetch(context)
|
||||||
|
if (Component.options.fetch && Component.options.fetch.length) {
|
||||||
|
promises.push(Component.options.fetch(app.context))
|
||||||
|
} else {
|
||||||
|
promises.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(promises)
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (process.env.DEBUG && asyncDatas.length) console.debug('Data fetching ' + ssrContext.url + ': ' + (Date.now() - s) + 'ms')
|
||||||
|
|
||||||
|
// datas are the first row of each
|
||||||
|
ssrContext.nuxt.data = asyncDatas.map(r => r[0] || {})
|
||||||
|
|
||||||
|
// ...If there is a redirect or an error, stop the process
|
||||||
|
if (ssrContext.redirected) {
|
||||||
|
return noopApp()
|
||||||
|
}
|
||||||
|
if (ssrContext.nuxt.error) {
|
||||||
|
return renderErrorPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call beforeNuxtRender methods & add store state
|
||||||
|
await beforeRender()
|
||||||
|
|
||||||
|
return _app
|
||||||
|
}
|
146
client/.nuxt/store.js
Normal file
146
client/.nuxt/store.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import Vuex from 'vuex'
|
||||||
|
|
||||||
|
Vue.use(Vuex)
|
||||||
|
|
||||||
|
const VUEX_PROPERTIES = ['state', 'getters', 'actions', 'mutations']
|
||||||
|
|
||||||
|
let store = {};
|
||||||
|
|
||||||
|
(function updateModules () {
|
||||||
|
store = normalizeRoot(require('..\\store\\index.js'), 'store/index.js')
|
||||||
|
|
||||||
|
// If store is an exported method = classic mode (deprecated)
|
||||||
|
|
||||||
|
if (typeof store === 'function') {
|
||||||
|
return console.warn('Classic mode for store/ is deprecated and will be removed in Nuxt 3.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce store modules
|
||||||
|
store.modules = store.modules || {}
|
||||||
|
|
||||||
|
resolveStoreModules(require('..\\store\\audiobooks.js'), 'audiobooks.js')
|
||||||
|
|
||||||
|
// If the environment supports hot reloading...
|
||||||
|
|
||||||
|
if (process.client && module.hot) {
|
||||||
|
// Whenever any Vuex module is updated...
|
||||||
|
module.hot.accept([
|
||||||
|
'..\\store\\audiobooks.js',
|
||||||
|
'..\\store\\index.js',
|
||||||
|
], () => {
|
||||||
|
// Update `root.modules` with the latest definitions.
|
||||||
|
updateModules()
|
||||||
|
// Trigger a hot update in the store.
|
||||||
|
window.$nuxt.$store.hotUpdate(store)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// createStore
|
||||||
|
export const createStore = store instanceof Function ? store : () => {
|
||||||
|
return new Vuex.Store(Object.assign({
|
||||||
|
strict: (process.env.NODE_ENV !== 'production')
|
||||||
|
}, store))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoot (moduleData, filePath) {
|
||||||
|
moduleData = moduleData.default || moduleData
|
||||||
|
|
||||||
|
if (moduleData.commit) {
|
||||||
|
throw new Error(`[nuxt] ${filePath} should export a method that returns a Vuex instance.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof moduleData !== 'function') {
|
||||||
|
// Avoid TypeError: setting a property that has only a getter when overwriting top level keys
|
||||||
|
moduleData = Object.assign({}, moduleData)
|
||||||
|
}
|
||||||
|
return normalizeModule(moduleData, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModule (moduleData, filePath) {
|
||||||
|
if (moduleData.state && typeof moduleData.state !== 'function') {
|
||||||
|
console.warn(`'state' should be a method that returns an object in ${filePath}`)
|
||||||
|
|
||||||
|
const state = Object.assign({}, moduleData.state)
|
||||||
|
// Avoid TypeError: setting a property that has only a getter when overwriting top level keys
|
||||||
|
moduleData = Object.assign({}, moduleData, { state: () => state })
|
||||||
|
}
|
||||||
|
return moduleData
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStoreModules (moduleData, filename) {
|
||||||
|
moduleData = moduleData.default || moduleData
|
||||||
|
// Remove store src + extension (./foo/index.js -> foo/index)
|
||||||
|
const namespace = filename.replace(/\.(js|mjs)$/, '')
|
||||||
|
const namespaces = namespace.split('/')
|
||||||
|
let moduleName = namespaces[namespaces.length - 1]
|
||||||
|
const filePath = `store/${filename}`
|
||||||
|
|
||||||
|
moduleData = moduleName === 'state'
|
||||||
|
? normalizeState(moduleData, filePath)
|
||||||
|
: normalizeModule(moduleData, filePath)
|
||||||
|
|
||||||
|
// If src is a known Vuex property
|
||||||
|
if (VUEX_PROPERTIES.includes(moduleName)) {
|
||||||
|
const property = moduleName
|
||||||
|
const propertyStoreModule = getStoreModule(store, namespaces, { isProperty: true })
|
||||||
|
|
||||||
|
// Replace state since it's a function
|
||||||
|
mergeProperty(propertyStoreModule, moduleData, property)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If file is foo/index.js, it should be saved as foo
|
||||||
|
const isIndexModule = (moduleName === 'index')
|
||||||
|
if (isIndexModule) {
|
||||||
|
namespaces.pop()
|
||||||
|
moduleName = namespaces[namespaces.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeModule = getStoreModule(store, namespaces)
|
||||||
|
|
||||||
|
for (const property of VUEX_PROPERTIES) {
|
||||||
|
mergeProperty(storeModule, moduleData[property], property)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleData.namespaced === false) {
|
||||||
|
delete storeModule.namespaced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeState (moduleData, filePath) {
|
||||||
|
if (typeof moduleData !== 'function') {
|
||||||
|
console.warn(`${filePath} should export a method that returns an object`)
|
||||||
|
const state = Object.assign({}, moduleData)
|
||||||
|
return () => state
|
||||||
|
}
|
||||||
|
return normalizeModule(moduleData, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoreModule (storeModule, namespaces, { isProperty = false } = {}) {
|
||||||
|
// If ./mutations.js
|
||||||
|
if (!namespaces.length || (isProperty && namespaces.length === 1)) {
|
||||||
|
return storeModule
|
||||||
|
}
|
||||||
|
|
||||||
|
const namespace = namespaces.shift()
|
||||||
|
|
||||||
|
storeModule.modules[namespace] = storeModule.modules[namespace] || {}
|
||||||
|
storeModule.modules[namespace].namespaced = true
|
||||||
|
storeModule.modules[namespace].modules = storeModule.modules[namespace].modules || {}
|
||||||
|
|
||||||
|
return getStoreModule(storeModule.modules[namespace], namespaces, { isProperty })
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeProperty (storeModule, moduleData, property) {
|
||||||
|
if (!moduleData) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (property === 'state') {
|
||||||
|
storeModule.state = moduleData || storeModule.state
|
||||||
|
} else {
|
||||||
|
storeModule[property] = Object.assign({}, storeModule[property], moduleData)
|
||||||
|
}
|
||||||
|
}
|
630
client/.nuxt/utils.js
Normal file
630
client/.nuxt/utils.js
Normal file
@ -0,0 +1,630 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import { isSamePath as _isSamePath, joinURL, normalizeURL, withQuery, withoutTrailingSlash } from 'ufo'
|
||||||
|
|
||||||
|
// window.{{globals.loadedCallback}} hook
|
||||||
|
// Useful for jsdom testing or plugins (https://github.com/tmpvar/jsdom#dealing-with-asynchronous-script-loading)
|
||||||
|
if (process.client) {
|
||||||
|
window.onNuxtReadyCbs = []
|
||||||
|
window.onNuxtReady = (cb) => {
|
||||||
|
window.onNuxtReadyCbs.push(cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGetCounter (counterObject, defaultKey = '') {
|
||||||
|
return function getCounter (id = defaultKey) {
|
||||||
|
if (counterObject[id] === undefined) {
|
||||||
|
counterObject[id] = 0
|
||||||
|
}
|
||||||
|
return counterObject[id]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function empty () {}
|
||||||
|
|
||||||
|
export function globalHandleError (error) {
|
||||||
|
if (Vue.config.errorHandler) {
|
||||||
|
Vue.config.errorHandler(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interopDefault (promise) {
|
||||||
|
return promise.then(m => m.default || m)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasFetch(vm) {
|
||||||
|
return vm.$options && typeof vm.$options.fetch === 'function' && !vm.$options.fetch.length
|
||||||
|
}
|
||||||
|
export function purifyData(data) {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(data).filter(
|
||||||
|
([key, value]) => {
|
||||||
|
const valid = !(value instanceof Function) && !(value instanceof Promise)
|
||||||
|
if (!valid) {
|
||||||
|
console.warn(`${key} is not able to be stringified. This will break in a production environment.`)
|
||||||
|
}
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
).reduce((obj, [key, value]) => {
|
||||||
|
obj[key] = value
|
||||||
|
return obj
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
export function getChildrenComponentInstancesUsingFetch(vm, instances = []) {
|
||||||
|
const children = vm.$children || []
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.$fetch) {
|
||||||
|
instances.push(child)
|
||||||
|
continue; // Don't get the children since it will reload the template
|
||||||
|
}
|
||||||
|
if (child.$children) {
|
||||||
|
getChildrenComponentInstancesUsingFetch(child, instances)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instances
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAsyncData (Component, asyncData) {
|
||||||
|
if (
|
||||||
|
// For SSR, we once all this function without second param to just apply asyncData
|
||||||
|
// Prevent doing this for each SSR request
|
||||||
|
!asyncData && Component.options.__hasNuxtData
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComponentData = Component.options._originDataFn || Component.options.data || function () { return {} }
|
||||||
|
Component.options._originDataFn = ComponentData
|
||||||
|
|
||||||
|
Component.options.data = function () {
|
||||||
|
const data = ComponentData.call(this, this)
|
||||||
|
if (this.$ssrContext) {
|
||||||
|
asyncData = this.$ssrContext.asyncData[Component.cid]
|
||||||
|
}
|
||||||
|
return { ...data, ...asyncData }
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.options.__hasNuxtData = true
|
||||||
|
|
||||||
|
if (Component._Ctor && Component._Ctor.options) {
|
||||||
|
Component._Ctor.options.data = Component.options.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeComponent (Component) {
|
||||||
|
// If Component already sanitized
|
||||||
|
if (Component.options && Component._Ctor === Component) {
|
||||||
|
return Component
|
||||||
|
}
|
||||||
|
if (!Component.options) {
|
||||||
|
Component = Vue.extend(Component) // fix issue #6
|
||||||
|
Component._Ctor = Component
|
||||||
|
} else {
|
||||||
|
Component._Ctor = Component
|
||||||
|
Component.extendOptions = Component.options
|
||||||
|
}
|
||||||
|
// If no component name defined, set file path as name, (also fixes #5703)
|
||||||
|
if (!Component.options.name && Component.options.__file) {
|
||||||
|
Component.options.name = Component.options.__file
|
||||||
|
}
|
||||||
|
return Component
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMatchedComponents (route, matches = false, prop = 'components') {
|
||||||
|
return Array.prototype.concat.apply([], route.matched.map((m, index) => {
|
||||||
|
return Object.keys(m[prop]).map((key) => {
|
||||||
|
matches && matches.push(index)
|
||||||
|
return m[prop][key]
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMatchedComponentsInstances (route, matches = false) {
|
||||||
|
return getMatchedComponents(route, matches, 'instances')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flatMapComponents (route, fn) {
|
||||||
|
return Array.prototype.concat.apply([], route.matched.map((m, index) => {
|
||||||
|
return Object.keys(m.components).reduce((promises, key) => {
|
||||||
|
if (m.components[key]) {
|
||||||
|
promises.push(fn(m.components[key], m.instances[key], m, key, index))
|
||||||
|
} else {
|
||||||
|
delete m.components[key]
|
||||||
|
}
|
||||||
|
return promises
|
||||||
|
}, [])
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRouteComponents (route, fn) {
|
||||||
|
return Promise.all(
|
||||||
|
flatMapComponents(route, async (Component, instance, match, key) => {
|
||||||
|
// If component is a function, resolve it
|
||||||
|
if (typeof Component === 'function' && !Component.options) {
|
||||||
|
try {
|
||||||
|
Component = await Component()
|
||||||
|
} catch (error) {
|
||||||
|
// Handle webpack chunk loading errors
|
||||||
|
// This may be due to a new deployment or a network problem
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
error.name === 'ChunkLoadError' &&
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.sessionStorage
|
||||||
|
) {
|
||||||
|
const timeNow = Date.now()
|
||||||
|
const previousReloadTime = parseInt(window.sessionStorage.getItem('nuxt-reload'))
|
||||||
|
|
||||||
|
// check for previous reload time not to reload infinitely
|
||||||
|
if (!previousReloadTime || previousReloadTime + 60000 < timeNow) {
|
||||||
|
window.sessionStorage.setItem('nuxt-reload', timeNow)
|
||||||
|
window.location.reload(true /* skip cache */)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match.components[key] = Component = sanitizeComponent(Component)
|
||||||
|
return typeof fn === 'function' ? fn(Component, instance, match, key) : Component
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRouteData (route) {
|
||||||
|
if (!route) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Make sure the components are resolved (code-splitting)
|
||||||
|
await resolveRouteComponents(route)
|
||||||
|
// Send back a copy of route with meta based on Component definition
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
meta: getMatchedComponents(route).map((Component, index) => {
|
||||||
|
return { ...Component.options.meta, ...(route.matched[index] || {}).meta }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setContext (app, context) {
|
||||||
|
// If context not defined, create it
|
||||||
|
if (!app.context) {
|
||||||
|
app.context = {
|
||||||
|
isStatic: process.static,
|
||||||
|
isDev: true,
|
||||||
|
isHMR: false,
|
||||||
|
app,
|
||||||
|
store: app.store,
|
||||||
|
payload: context.payload,
|
||||||
|
error: context.error,
|
||||||
|
base: app.router.options.base,
|
||||||
|
env: {"serverUrl":"http://localhost:3333","baseUrl":"http://0.0.0.0"}
|
||||||
|
}
|
||||||
|
// Only set once
|
||||||
|
|
||||||
|
if (context.req) {
|
||||||
|
app.context.req = context.req
|
||||||
|
}
|
||||||
|
if (context.res) {
|
||||||
|
app.context.res = context.res
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.ssrContext) {
|
||||||
|
app.context.ssrContext = context.ssrContext
|
||||||
|
}
|
||||||
|
app.context.redirect = (status, path, query) => {
|
||||||
|
if (!status) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.context._redirected = true
|
||||||
|
// if only 1 or 2 arguments: redirect('/') or redirect('/', { foo: 'bar' })
|
||||||
|
let pathType = typeof path
|
||||||
|
if (typeof status !== 'number' && (pathType === 'undefined' || pathType === 'object')) {
|
||||||
|
query = path || {}
|
||||||
|
path = status
|
||||||
|
pathType = typeof path
|
||||||
|
status = 302
|
||||||
|
}
|
||||||
|
if (pathType === 'object') {
|
||||||
|
path = app.router.resolve(path).route.fullPath
|
||||||
|
}
|
||||||
|
// "/absolute/route", "./relative/route" or "../relative/route"
|
||||||
|
if (/(^[.]{1,2}\/)|(^\/(?!\/))/.test(path)) {
|
||||||
|
app.context.next({
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
status
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
path = withQuery(path, query)
|
||||||
|
if (process.server) {
|
||||||
|
app.context.next({
|
||||||
|
path,
|
||||||
|
status
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (process.client) {
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Location/replace
|
||||||
|
window.location.replace(path)
|
||||||
|
|
||||||
|
// Throw a redirect error
|
||||||
|
throw new Error('ERR_REDIRECT')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (process.server) {
|
||||||
|
app.context.beforeNuxtRender = fn => context.beforeRenderFns.push(fn)
|
||||||
|
}
|
||||||
|
if (process.client) {
|
||||||
|
app.context.nuxtState = window.__NUXT__
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic keys
|
||||||
|
const [currentRouteData, fromRouteData] = await Promise.all([
|
||||||
|
getRouteData(context.route),
|
||||||
|
getRouteData(context.from)
|
||||||
|
])
|
||||||
|
|
||||||
|
if (context.route) {
|
||||||
|
app.context.route = currentRouteData
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.from) {
|
||||||
|
app.context.from = fromRouteData
|
||||||
|
}
|
||||||
|
|
||||||
|
app.context.next = context.next
|
||||||
|
app.context._redirected = false
|
||||||
|
app.context._errored = false
|
||||||
|
app.context.isHMR = Boolean(context.isHMR)
|
||||||
|
app.context.params = app.context.route.params || {}
|
||||||
|
app.context.query = app.context.route.query || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function middlewareSeries (promises, appContext) {
|
||||||
|
if (!promises.length || appContext._redirected || appContext._errored) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
return promisify(promises[0], appContext)
|
||||||
|
.then(() => {
|
||||||
|
return middlewareSeries(promises.slice(1), appContext)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promisify (fn, context) {
|
||||||
|
let promise
|
||||||
|
if (fn.length === 2) {
|
||||||
|
console.warn('Callback-based asyncData, fetch or middleware calls are deprecated. ' +
|
||||||
|
'Please switch to promises or async/await syntax')
|
||||||
|
|
||||||
|
// fn(context, callback)
|
||||||
|
promise = new Promise((resolve) => {
|
||||||
|
fn(context, function (err, data) {
|
||||||
|
if (err) {
|
||||||
|
context.error(err)
|
||||||
|
}
|
||||||
|
data = data || {}
|
||||||
|
resolve(data)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
promise = fn(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promise && promise instanceof Promise && typeof promise.then === 'function') {
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
return Promise.resolve(promise)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imported from vue-router
|
||||||
|
export function getLocation (base, mode) {
|
||||||
|
if (mode === 'hash') {
|
||||||
|
return window.location.hash.replace(/^#\//, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
base = decodeURI(base).slice(0, -1) // consideration is base is normalized with trailing slash
|
||||||
|
let path = decodeURI(window.location.pathname)
|
||||||
|
|
||||||
|
if (base && path.startsWith(base)) {
|
||||||
|
path = path.slice(base.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = (path || '/') + window.location.search + window.location.hash
|
||||||
|
|
||||||
|
return normalizeURL(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imported from path-to-regexp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile a string to a template function for the path.
|
||||||
|
*
|
||||||
|
* @param {string} str
|
||||||
|
* @param {Object=} options
|
||||||
|
* @return {!function(Object=, Object=)}
|
||||||
|
*/
|
||||||
|
export function compile (str, options) {
|
||||||
|
return tokensToFunction(parse(str, options), options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQueryDiff (toQuery, fromQuery) {
|
||||||
|
const diff = {}
|
||||||
|
const queries = { ...toQuery, ...fromQuery }
|
||||||
|
for (const k in queries) {
|
||||||
|
if (String(toQuery[k]) !== String(fromQuery[k])) {
|
||||||
|
diff[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeError (err) {
|
||||||
|
let message
|
||||||
|
if (!(err.message || typeof err === 'string')) {
|
||||||
|
try {
|
||||||
|
message = JSON.stringify(err, null, 2)
|
||||||
|
} catch (e) {
|
||||||
|
message = `[${err.constructor.name}]`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message = err.message || err
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...err,
|
||||||
|
message,
|
||||||
|
statusCode: (err.statusCode || err.status || (err.response && err.response.status) || 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main path matching regexp utility.
|
||||||
|
*
|
||||||
|
* @type {RegExp}
|
||||||
|
*/
|
||||||
|
const PATH_REGEXP = new RegExp([
|
||||||
|
// Match escaped characters that would otherwise appear in future matches.
|
||||||
|
// This allows the user to escape special characters that won't transform.
|
||||||
|
'(\\\\.)',
|
||||||
|
// Match Express-style parameters and un-named parameters with a prefix
|
||||||
|
// and optional suffixes. Matches appear as:
|
||||||
|
//
|
||||||
|
// "/:test(\\d+)?" => ["/", "test", "\d+", undefined, "?", undefined]
|
||||||
|
// "/route(\\d+)" => [undefined, undefined, undefined, "\d+", undefined, undefined]
|
||||||
|
// "/*" => ["/", undefined, undefined, undefined, undefined, "*"]
|
||||||
|
'([\\/.])?(?:(?:\\:(\\w+)(?:\\(((?:\\\\.|[^\\\\()])+)\\))?|\\(((?:\\\\.|[^\\\\()])+)\\))([+*?])?|(\\*))'
|
||||||
|
].join('|'), 'g')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a string for the raw tokens.
|
||||||
|
*
|
||||||
|
* @param {string} str
|
||||||
|
* @param {Object=} options
|
||||||
|
* @return {!Array}
|
||||||
|
*/
|
||||||
|
function parse (str, options) {
|
||||||
|
const tokens = []
|
||||||
|
let key = 0
|
||||||
|
let index = 0
|
||||||
|
let path = ''
|
||||||
|
const defaultDelimiter = (options && options.delimiter) || '/'
|
||||||
|
let res
|
||||||
|
|
||||||
|
while ((res = PATH_REGEXP.exec(str)) != null) {
|
||||||
|
const m = res[0]
|
||||||
|
const escaped = res[1]
|
||||||
|
const offset = res.index
|
||||||
|
path += str.slice(index, offset)
|
||||||
|
index = offset + m.length
|
||||||
|
|
||||||
|
// Ignore already escaped sequences.
|
||||||
|
if (escaped) {
|
||||||
|
path += escaped[1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = str[index]
|
||||||
|
const prefix = res[2]
|
||||||
|
const name = res[3]
|
||||||
|
const capture = res[4]
|
||||||
|
const group = res[5]
|
||||||
|
const modifier = res[6]
|
||||||
|
const asterisk = res[7]
|
||||||
|
|
||||||
|
// Push the current path onto the tokens.
|
||||||
|
if (path) {
|
||||||
|
tokens.push(path)
|
||||||
|
path = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const partial = prefix != null && next != null && next !== prefix
|
||||||
|
const repeat = modifier === '+' || modifier === '*'
|
||||||
|
const optional = modifier === '?' || modifier === '*'
|
||||||
|
const delimiter = res[2] || defaultDelimiter
|
||||||
|
const pattern = capture || group
|
||||||
|
|
||||||
|
tokens.push({
|
||||||
|
name: name || key++,
|
||||||
|
prefix: prefix || '',
|
||||||
|
delimiter,
|
||||||
|
optional,
|
||||||
|
repeat,
|
||||||
|
partial,
|
||||||
|
asterisk: Boolean(asterisk),
|
||||||
|
pattern: pattern ? escapeGroup(pattern) : (asterisk ? '.*' : '[^' + escapeString(delimiter) + ']+?')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match any characters still remaining.
|
||||||
|
if (index < str.length) {
|
||||||
|
path += str.substr(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the path exists, push it onto the end.
|
||||||
|
if (path) {
|
||||||
|
tokens.push(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prettier encoding of URI path segments.
|
||||||
|
*
|
||||||
|
* @param {string}
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
function encodeURIComponentPretty (str, slashAllowed) {
|
||||||
|
const re = slashAllowed ? /[?#]/g : /[/?#]/g
|
||||||
|
return encodeURI(str).replace(re, (c) => {
|
||||||
|
return '%' + c.charCodeAt(0).toString(16).toUpperCase()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode the asterisk parameter. Similar to `pretty`, but allows slashes.
|
||||||
|
*
|
||||||
|
* @param {string}
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
function encodeAsterisk (str) {
|
||||||
|
return encodeURIComponentPretty(str, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a regular expression string.
|
||||||
|
*
|
||||||
|
* @param {string} str
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
function escapeString (str) {
|
||||||
|
return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, '\\$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape the capturing group by escaping special characters and meaning.
|
||||||
|
*
|
||||||
|
* @param {string} group
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
function escapeGroup (group) {
|
||||||
|
return group.replace(/([=!:$/()])/g, '\\$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expose a method for transforming tokens into the path function.
|
||||||
|
*/
|
||||||
|
function tokensToFunction (tokens, options) {
|
||||||
|
// Compile all the tokens into regexps.
|
||||||
|
const matches = new Array(tokens.length)
|
||||||
|
|
||||||
|
// Compile all the patterns before compilation.
|
||||||
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
|
if (typeof tokens[i] === 'object') {
|
||||||
|
matches[i] = new RegExp('^(?:' + tokens[i].pattern + ')$', flags(options))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return function (obj, opts) {
|
||||||
|
let path = ''
|
||||||
|
const data = obj || {}
|
||||||
|
const options = opts || {}
|
||||||
|
const encode = options.pretty ? encodeURIComponentPretty : encodeURIComponent
|
||||||
|
|
||||||
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
|
const token = tokens[i]
|
||||||
|
|
||||||
|
if (typeof token === 'string') {
|
||||||
|
path += token
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = data[token.name || 'pathMatch']
|
||||||
|
let segment
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
if (token.optional) {
|
||||||
|
// Prepend partial segment prefixes.
|
||||||
|
if (token.partial) {
|
||||||
|
path += token.prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
throw new TypeError('Expected "' + token.name + '" to be defined')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (!token.repeat) {
|
||||||
|
throw new TypeError('Expected "' + token.name + '" to not repeat, but received `' + JSON.stringify(value) + '`')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length === 0) {
|
||||||
|
if (token.optional) {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
throw new TypeError('Expected "' + token.name + '" to not be empty')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let j = 0; j < value.length; j++) {
|
||||||
|
segment = encode(value[j])
|
||||||
|
|
||||||
|
if (!matches[i].test(segment)) {
|
||||||
|
throw new TypeError('Expected all "' + token.name + '" to match "' + token.pattern + '", but received `' + JSON.stringify(segment) + '`')
|
||||||
|
}
|
||||||
|
|
||||||
|
path += (j === 0 ? token.prefix : token.delimiter) + segment
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
segment = token.asterisk ? encodeAsterisk(value) : encode(value)
|
||||||
|
|
||||||
|
if (!matches[i].test(segment)) {
|
||||||
|
throw new TypeError('Expected "' + token.name + '" to match "' + token.pattern + '", but received "' + segment + '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
path += token.prefix + segment
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the flags for a regexp from the options.
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
function flags (options) {
|
||||||
|
return options && options.sensitive ? '' : 'i'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addLifecycleHook(vm, hook, fn) {
|
||||||
|
if (!vm.$options[hook]) {
|
||||||
|
vm.$options[hook] = []
|
||||||
|
}
|
||||||
|
if (!vm.$options[hook].includes(fn)) {
|
||||||
|
vm.$options[hook].push(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const urlJoin = joinURL
|
||||||
|
|
||||||
|
export const stripTrailingSlash = withoutTrailingSlash
|
||||||
|
|
||||||
|
export const isSamePath = _isSamePath
|
||||||
|
|
||||||
|
export function setScrollRestoration (newVal) {
|
||||||
|
try {
|
||||||
|
window.history.scrollRestoration = newVal;
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
71
client/.nuxt/vetur/tags.json
Normal file
71
client/.nuxt/vetur/tags.json
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"AudioPlayer": {
|
||||||
|
"description": "Auto imported from components/AudioPlayer.vue"
|
||||||
|
},
|
||||||
|
"AppAppbar": {
|
||||||
|
"description": "Auto imported from components/app/Appbar.vue"
|
||||||
|
},
|
||||||
|
"AppBookShelf": {
|
||||||
|
"description": "Auto imported from components/app/BookShelf.vue"
|
||||||
|
},
|
||||||
|
"AppStreamContainer": {
|
||||||
|
"description": "Auto imported from components/app/StreamContainer.vue"
|
||||||
|
},
|
||||||
|
"AppTracksTable": {
|
||||||
|
"description": "Auto imported from components/app/TracksTable.vue"
|
||||||
|
},
|
||||||
|
"CardsBookCard": {
|
||||||
|
"description": "Auto imported from components/cards/BookCard.vue"
|
||||||
|
},
|
||||||
|
"CardsBookCover": {
|
||||||
|
"description": "Auto imported from components/cards/BookCover.vue"
|
||||||
|
},
|
||||||
|
"ControlsVolumeControl": {
|
||||||
|
"description": "Auto imported from components/controls/VolumeControl.vue"
|
||||||
|
},
|
||||||
|
"ModalsEditModal": {
|
||||||
|
"description": "Auto imported from components/modals/EditModal.vue"
|
||||||
|
},
|
||||||
|
"ModalsModal": {
|
||||||
|
"description": "Auto imported from components/modals/Modal.vue"
|
||||||
|
},
|
||||||
|
"UiBtn": {
|
||||||
|
"description": "Auto imported from components/ui/Btn.vue"
|
||||||
|
},
|
||||||
|
"UiLoadingIndicator": {
|
||||||
|
"description": "Auto imported from components/ui/LoadingIndicator.vue"
|
||||||
|
},
|
||||||
|
"UiMenu": {
|
||||||
|
"description": "Auto imported from components/ui/Menu.vue"
|
||||||
|
},
|
||||||
|
"UiTextareaInput": {
|
||||||
|
"description": "Auto imported from components/ui/TextareaInput.vue"
|
||||||
|
},
|
||||||
|
"UiTextareaWithLabel": {
|
||||||
|
"description": "Auto imported from components/ui/TextareaWithLabel.vue"
|
||||||
|
},
|
||||||
|
"UiTextInput": {
|
||||||
|
"description": "Auto imported from components/ui/TextInput.vue"
|
||||||
|
},
|
||||||
|
"UiTextInputWithLabel": {
|
||||||
|
"description": "Auto imported from components/ui/TextInputWithLabel.vue"
|
||||||
|
},
|
||||||
|
"UiTooltip": {
|
||||||
|
"description": "Auto imported from components/ui/Tooltip.vue"
|
||||||
|
},
|
||||||
|
"WidgetsScanAlert": {
|
||||||
|
"description": "Auto imported from components/widgets/ScanAlert.vue"
|
||||||
|
},
|
||||||
|
"ModalsEditTabsCover": {
|
||||||
|
"description": "Auto imported from components/modals/edit-tabs/Cover.vue"
|
||||||
|
},
|
||||||
|
"ModalsEditTabsDetails": {
|
||||||
|
"description": "Auto imported from components/modals/edit-tabs/Details.vue"
|
||||||
|
},
|
||||||
|
"ModalsEditTabsMatch": {
|
||||||
|
"description": "Auto imported from components/modals/edit-tabs/Match.vue"
|
||||||
|
},
|
||||||
|
"ModalsEditTabsTracks": {
|
||||||
|
"description": "Auto imported from components/modals/edit-tabs/Tracks.vue"
|
||||||
|
}
|
||||||
|
}
|
9
client/.nuxt/views/app.template.html
Normal file
9
client/.nuxt/views/app.template.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html {{ HTML_ATTRS }}>
|
||||||
|
<head {{ HEAD_ATTRS }}>
|
||||||
|
{{ HEAD }}
|
||||||
|
</head>
|
||||||
|
<body {{ BODY_ATTRS }}>
|
||||||
|
{{ APP }}
|
||||||
|
</body>
|
||||||
|
</html>
|
23
client/.nuxt/views/error.html
Normal file
23
client/.nuxt/views/error.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Server error</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" name=viewport>
|
||||||
|
<style>
|
||||||
|
.__nuxt-error-page{padding: 1rem;background:#f7f8fb;color:#47494e;text-align:center;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;font-family:sans-serif;font-weight:100!important;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-font-smoothing:antialiased;position:absolute;top:0;left:0;right:0;bottom:0}.__nuxt-error-page .error{max-width:450px}.__nuxt-error-page .title{font-size:24px;font-size:1.5rem;margin-top:15px;color:#47494e;margin-bottom:8px}.__nuxt-error-page .description{color:#7f828b;line-height:21px;margin-bottom:10px}.__nuxt-error-page a{color:#7f828b!important;text-decoration:none}.__nuxt-error-page .logo{position:fixed;left:12px;bottom:12px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="__nuxt-error-page">
|
||||||
|
<div class="error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="90" fill="#DBE1EC" viewBox="0 0 48 48"><path d="M22 30h4v4h-4zm0-16h4v12h-4zm1.99-10C12.94 4 4 12.95 4 24s8.94 20 19.99 20S44 35.05 44 24 35.04 4 23.99 4zM24 40c-8.84 0-16-7.16-16-16S15.16 8 24 8s16 7.16 16 16-7.16 16-16 16z"/></svg>
|
||||||
|
<div class="title">Server error</div>
|
||||||
|
<div class="description">{{ message }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="logo">
|
||||||
|
<a href="https://nuxtjs.org" target="_blank" rel="noopener">Nuxt</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
52
client/assets/app.css
Normal file
52
client/assets/app.css
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
@import url('./transitions.css');
|
||||||
|
|
||||||
|
.page {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - 64px);
|
||||||
|
max-height: calc(100% - 64px);
|
||||||
|
}
|
||||||
|
.page.streaming {
|
||||||
|
height: calc(100% - 64px - 165px);
|
||||||
|
max-height: calc(100% - 64px - 165px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* width */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
/* Track */
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background-color: rgba(0,0,0,0);
|
||||||
|
}
|
||||||
|
/* Handle */
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #855620;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
/* Handle on hover */
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #704922;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tracksTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #474747;
|
||||||
|
}
|
||||||
|
.tracksTable tr:nth-child(even) {
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
}
|
||||||
|
.tracksTable tr {
|
||||||
|
background-color: #373838;
|
||||||
|
}
|
||||||
|
.tracksTable tr:hover {
|
||||||
|
background-color: #474747;
|
||||||
|
}
|
||||||
|
.tracksTable td {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.tracksTable th {
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
47
client/assets/transitions.css
Normal file
47
client/assets/transitions.css
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
.slide-enter-active {
|
||||||
|
-moz-transition-duration: 0.1s;
|
||||||
|
-webkit-transition-duration: 0.1s;
|
||||||
|
-o-transition-duration: 0.1s;
|
||||||
|
transition-duration: 0.1s;
|
||||||
|
-moz-transition-timing-function: ease-in;
|
||||||
|
-webkit-transition-timing-function: ease-in;
|
||||||
|
-o-transition-timing-function: ease-in;
|
||||||
|
transition-timing-function: ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-leave-active {
|
||||||
|
-moz-transition-duration: 0.2s;
|
||||||
|
-webkit-transition-duration: 0.2s;
|
||||||
|
-o-transition-duration: 0.2s;
|
||||||
|
transition-duration: 0.2s;
|
||||||
|
-moz-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||||
|
-webkit-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||||
|
-o-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||||
|
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-to, .slide-leave {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter, .slide-leave-to {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.menu-enter, .menu-leave-active {
|
||||||
|
transform: translateY(-15px);
|
||||||
|
}
|
||||||
|
.menu-enter-active {
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.menu-leave-active {
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.menu-enter,
|
||||||
|
.menu-leave-active {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
435
client/components/AudioPlayer.vue
Normal file
435
client/components/AudioPlayer.vue
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="absolute right-24 top-0 bottom-0">
|
||||||
|
<controls-volume-control v-model="volume" @input="updateVolume" />
|
||||||
|
</div>
|
||||||
|
<div class="flex my-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>
|
||||||
|
</div>
|
||||||
|
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="backward10">
|
||||||
|
<span class="material-icons text-3xl">replay_10</span>
|
||||||
|
</div>
|
||||||
|
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||||
|
<span class="material-icons">{{ seekLoading ? 'autorenew' : isPaused ? 'play_arrow' : 'pause' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="forward10">
|
||||||
|
<span class="material-icons text-3xl">forward_10</span>
|
||||||
|
</div>
|
||||||
|
<div class="cursor-pointer flex items-center justify-center text-gray-300 ml-8" @mousedown.prevent @mouseup.prevent>
|
||||||
|
<span class="font-mono text-lg uppercase">2x</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||||
|
<span class="material-icons">autorenew</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Track -->
|
||||||
|
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125" :class="loading ? 'animate-pulse' : ''" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
||||||
|
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||||
|
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
|
||||||
|
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||||
|
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hover timestamp -->
|
||||||
|
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||||
|
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5">00:00</p>
|
||||||
|
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
||||||
|
<div class="arrow-down" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Hls from 'hls.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
loading: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hlsInstance: null,
|
||||||
|
staleHlsInstance: null,
|
||||||
|
volume: 0.5,
|
||||||
|
trackWidth: 0,
|
||||||
|
isPaused: true,
|
||||||
|
url: null,
|
||||||
|
src: null,
|
||||||
|
playedTrackWidth: 0,
|
||||||
|
bufferTrackWidth: 0,
|
||||||
|
readyTrackWidth: 0,
|
||||||
|
audioEl: null,
|
||||||
|
totalDuration: 0,
|
||||||
|
seekedTime: 0,
|
||||||
|
seekLoading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
token() {
|
||||||
|
return this.$store.getters.getToken
|
||||||
|
},
|
||||||
|
totalDurationPretty() {
|
||||||
|
return this.$secondsToTimestamp(this.totalDuration)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
seek(time) {
|
||||||
|
if (this.seekLoading) {
|
||||||
|
console.error('Already seek loading', this.seekedTime)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.audioEl) {
|
||||||
|
console.error('No Audio el for seek', time)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.seekedTime = time
|
||||||
|
this.seekLoading = true
|
||||||
|
console.warn('SEEK TO', this.$secondsToTimestamp(time))
|
||||||
|
this.audioEl.currentTime = time
|
||||||
|
|
||||||
|
if (this.$refs.playedTrack) {
|
||||||
|
var perc = time / this.audioEl.duration
|
||||||
|
var ptWidth = Math.round(perc * this.trackWidth)
|
||||||
|
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||||
|
this.playedTrackWidth = ptWidth
|
||||||
|
|
||||||
|
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
||||||
|
this.$refs.playedTrack.classList.add('bg-yellow-300')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateVolume(volume) {
|
||||||
|
if (this.audioEl) {
|
||||||
|
this.audioEl.volume = 1 - volume
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mousemoveTrack(e) {
|
||||||
|
var offsetX = e.offsetX
|
||||||
|
var time = (offsetX / this.trackWidth) * this.totalDuration
|
||||||
|
if (this.$refs.hoverTimestamp) {
|
||||||
|
var width = this.$refs.hoverTimestamp.clientWidth
|
||||||
|
this.$refs.hoverTimestamp.style.opacity = 1
|
||||||
|
this.$refs.hoverTimestamp.style.left = offsetX - width / 2 + 'px'
|
||||||
|
}
|
||||||
|
if (this.$refs.hoverTimestampText) {
|
||||||
|
this.$refs.hoverTimestampText.innerText = this.$secondsToTimestamp(time)
|
||||||
|
}
|
||||||
|
if (this.$refs.trackCursor) {
|
||||||
|
this.$refs.trackCursor.style.opacity = 1
|
||||||
|
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mouseleaveTrack() {
|
||||||
|
if (this.$refs.hoverTimestamp) {
|
||||||
|
this.$refs.hoverTimestamp.style.opacity = 0
|
||||||
|
}
|
||||||
|
if (this.$refs.trackCursor) {
|
||||||
|
this.$refs.trackCursor.style.opacity = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restart() {
|
||||||
|
this.seek(0)
|
||||||
|
},
|
||||||
|
backward10() {
|
||||||
|
var newTime = this.audioEl.currentTime - 10
|
||||||
|
newTime = Math.max(0, newTime)
|
||||||
|
this.seek(newTime)
|
||||||
|
},
|
||||||
|
forward10() {
|
||||||
|
var newTime = this.audioEl.currentTime + 10
|
||||||
|
newTime = Math.min(this.audioEl.duration, newTime)
|
||||||
|
this.seek(newTime)
|
||||||
|
},
|
||||||
|
sendStreamUpdate() {
|
||||||
|
if (!this.audioEl) return
|
||||||
|
this.$emit('updateTime', this.audioEl.currentTime)
|
||||||
|
},
|
||||||
|
setStreamReady() {
|
||||||
|
this.readyTrackWidth = this.trackWidth
|
||||||
|
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
||||||
|
},
|
||||||
|
setChunksReady(chunks, numSegments) {
|
||||||
|
var largestSeg = 0
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
var chunk = chunks[i]
|
||||||
|
if (typeof chunk === 'string') {
|
||||||
|
var chunkRange = chunk.split('-').map((c) => Number(c))
|
||||||
|
if (chunkRange.length < 2) continue
|
||||||
|
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
|
||||||
|
} else if (chunk > largestSeg) {
|
||||||
|
largestSeg = chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var percentageReady = largestSeg / numSegments
|
||||||
|
var widthReady = Math.round(this.trackWidth * percentageReady)
|
||||||
|
if (this.readyTrackWidth === widthReady) return
|
||||||
|
this.readyTrackWidth = widthReady
|
||||||
|
this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||||
|
},
|
||||||
|
updateTimestamp() {
|
||||||
|
var ts = this.$refs.currentTimestamp
|
||||||
|
if (!ts) {
|
||||||
|
console.error('No timestamp el')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.audioEl) {
|
||||||
|
console.error('No Audio El')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var currTimeClean = this.$secondsToTimestamp(this.audioEl.currentTime)
|
||||||
|
ts.innerText = currTimeClean
|
||||||
|
},
|
||||||
|
clickTrack(e) {
|
||||||
|
var offsetX = e.offsetX
|
||||||
|
var perc = offsetX / this.trackWidth
|
||||||
|
var time = perc * this.audioEl.duration
|
||||||
|
if (isNaN(time) || time === null) {
|
||||||
|
console.error('Invalid time', perc, time)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.seek(time)
|
||||||
|
},
|
||||||
|
playPauseClick() {
|
||||||
|
if (this.isPaused) {
|
||||||
|
this.play()
|
||||||
|
} else {
|
||||||
|
this.pause()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isValidDuration(duration) {
|
||||||
|
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
getBufferedRanges() {
|
||||||
|
if (!this.audioEl) return []
|
||||||
|
|
||||||
|
const ranges = []
|
||||||
|
const seekable = this.audioEl.buffered || []
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
for (let i = 0, length = seekable.length; i < length; i++) {
|
||||||
|
let start = seekable.start(i)
|
||||||
|
let end = seekable.end(i)
|
||||||
|
if (!this.isValidDuration(start)) {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if (!this.isValidDuration(end)) {
|
||||||
|
end = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ranges.push({
|
||||||
|
start: start + offset,
|
||||||
|
end: end + offset
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges
|
||||||
|
},
|
||||||
|
getLastBufferedTime() {
|
||||||
|
var bufferedRanges = this.getBufferedRanges()
|
||||||
|
if (!bufferedRanges.length) return 0
|
||||||
|
|
||||||
|
var buff = bufferedRanges.find((buff) => buff.start < this.audioEl.currentTime && buff.end > this.audioEl.currentTime)
|
||||||
|
if (buff) return buff.end
|
||||||
|
|
||||||
|
var last = bufferedRanges[bufferedRanges.length - 1]
|
||||||
|
return last.end
|
||||||
|
},
|
||||||
|
progress() {
|
||||||
|
if (!this.audioEl) {
|
||||||
|
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
|
||||||
|
this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||||
|
this.bufferTrackWidth = bufferlen
|
||||||
|
},
|
||||||
|
timeupdate() {
|
||||||
|
// console.log('Time update', this.audioEl.currentTime)
|
||||||
|
if (!this.$refs.playedTrack) {
|
||||||
|
console.error('Invalid no played track ref')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.audioEl) {
|
||||||
|
console.error('No Audio El')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.seekLoading) {
|
||||||
|
this.seekLoading = false
|
||||||
|
if (this.$refs.playedTrack) {
|
||||||
|
this.$refs.playedTrack.classList.remove('bg-yellow-300')
|
||||||
|
this.$refs.playedTrack.classList.add('bg-gray-200')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateTimestamp()
|
||||||
|
|
||||||
|
var perc = this.audioEl.currentTime / this.audioEl.duration
|
||||||
|
var ptWidth = Math.round(perc * this.trackWidth)
|
||||||
|
if (this.playedTrackWidth === ptWidth) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||||
|
this.playedTrackWidth = ptWidth
|
||||||
|
},
|
||||||
|
paused() {
|
||||||
|
if (!this.$refs.audio) {
|
||||||
|
console.error('No audio on paused()')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('Paused')
|
||||||
|
this.isPaused = this.$refs.audio.paused
|
||||||
|
},
|
||||||
|
playing() {
|
||||||
|
if (!this.$refs.audio) {
|
||||||
|
console.error('No audio on playing()')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.isPaused = this.$refs.audio.paused
|
||||||
|
},
|
||||||
|
audioLoadedData() {
|
||||||
|
this.totalDuration = this.audioEl.duration
|
||||||
|
},
|
||||||
|
set(url, currentTime, playOnLoad = false) {
|
||||||
|
console.log('[AudioPlayer] SET PlayOnLoad ', playOnLoad)
|
||||||
|
if (this.hlsInstance) {
|
||||||
|
this.terminateStream()
|
||||||
|
}
|
||||||
|
if (!this.$refs.audio) {
|
||||||
|
console.error('No audio widget')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.url = url
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
url = `${process.env.serverUrl}${url}`
|
||||||
|
}
|
||||||
|
this.src = url
|
||||||
|
console.log('[AudioPlayer-Set] Set url', url)
|
||||||
|
|
||||||
|
var hlsOptions = {
|
||||||
|
startPosition: currentTime || -1,
|
||||||
|
xhrSetup: (xhr) => {
|
||||||
|
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('[AudioPlayer-Set] HLS Config', hlsOptions, this.$secondsToTimestamp(hlsOptions.startPosition))
|
||||||
|
this.hlsInstance = new Hls(hlsOptions)
|
||||||
|
var audio = this.$refs.audio
|
||||||
|
audio.volume = this.volume
|
||||||
|
this.hlsInstance.attachMedia(audio)
|
||||||
|
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||||
|
console.log('[HLS] MEDIA ATTACHED')
|
||||||
|
this.hlsInstance.loadSource(url)
|
||||||
|
|
||||||
|
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||||
|
console.log('[HLS] Manifest Parsed')
|
||||||
|
if (playOnLoad) {
|
||||||
|
audio.play()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
||||||
|
console.error('[HLS] Error', data.type, data.details)
|
||||||
|
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
|
||||||
|
console.error('[HLS] BUFFER STALLED ERROR')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.hlsInstance.on(Hls.Events.FRAG_LOADED, (e, data) => {
|
||||||
|
var frag = data.frag
|
||||||
|
console.log('[HLS] Frag Loaded', frag.sn, this.$secondsToTimestamp(frag.start), frag)
|
||||||
|
})
|
||||||
|
this.hlsInstance.on(Hls.Events.STREAM_STATE_TRANSITION, (e, data) => {
|
||||||
|
console.log('[HLS] Stream State Transition', data)
|
||||||
|
})
|
||||||
|
this.hlsInstance.on(Hls.Events.BUFFER_APPENDED, (e, data) => {
|
||||||
|
// console.log('[HLS] BUFFER', data)
|
||||||
|
})
|
||||||
|
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
|
||||||
|
console.warn('[HLS] Destroying HLS Instance')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
play() {
|
||||||
|
if (!this.$refs.audio) {
|
||||||
|
console.error('No Audio ref')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$refs.audio.play()
|
||||||
|
},
|
||||||
|
pause() {
|
||||||
|
if (!this.$refs.audio) return
|
||||||
|
this.$refs.audio.pause()
|
||||||
|
},
|
||||||
|
terminateStream() {
|
||||||
|
if (this.hlsInstance) {
|
||||||
|
if (!this.hlsInstance.destroy) {
|
||||||
|
console.error('HLS Instance has no destroy property', this.hlsInstance)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.staleHlsInstance = this.hlsInstance
|
||||||
|
this.staleHlsInstance.destroy()
|
||||||
|
this.hlsInstance = null
|
||||||
|
console.log('Terminated HLS Instance', this.staleHlsInstance)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async resetStream(startTime) {
|
||||||
|
if (this.$refs.audio) this.$refs.audio.pause()
|
||||||
|
this.terminateStream()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
console.log('Waited 1 second after terminating stream to start again')
|
||||||
|
this.set(this.url, startTime, true)
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.audioEl = this.$refs.audio
|
||||||
|
if (this.$refs.track) {
|
||||||
|
this.trackWidth = this.$refs.track.clientWidth
|
||||||
|
} else {
|
||||||
|
console.error('Track not loaded', this.$refs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$nextTick(this.init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.arrow-down {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 6px solid transparent;
|
||||||
|
border-right: 6px solid transparent;
|
||||||
|
border-top: 6px solid white;
|
||||||
|
}
|
||||||
|
</style>
|
88
client/components/app/Appbar.vue
Normal file
88
client/components/app/Appbar.vue
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-16 bg-primary relative">
|
||||||
|
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-10">
|
||||||
|
<div class="flex h-full items-center">
|
||||||
|
<img v-if="!showBack" src="/LogoTransparent.png" class="w-12 h-12 mr-4" />
|
||||||
|
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
|
||||||
|
<span class="material-icons text-4xl text-white">arrow_back</span>
|
||||||
|
</a>
|
||||||
|
<h1 class="text-2xl font-book">AudioBookshelf</h1>
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<!-- <button class="px-4 py-2 bg-blue-500 rounded-xs" @click="scan">Scan</button> -->
|
||||||
|
<nuxt-link to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
|
||||||
|
<span class="material-icons">settings</span>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
menuItems: [
|
||||||
|
// {
|
||||||
|
// value: 'settings',
|
||||||
|
// text: 'Settings'
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
value: 'logout',
|
||||||
|
text: 'Logout'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
showBack() {
|
||||||
|
return this.$route.name !== 'index'
|
||||||
|
},
|
||||||
|
user() {
|
||||||
|
return this.$store.state.user
|
||||||
|
},
|
||||||
|
username() {
|
||||||
|
return this.user ? this.user.username : 'err'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
back() {
|
||||||
|
if (this.$route.name === 'audiobook-id-edit') {
|
||||||
|
this.$router.push(`/audiobook/${this.$route.params.id}`)
|
||||||
|
} else {
|
||||||
|
this.$router.push('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scan() {
|
||||||
|
console.log('Call Start Init')
|
||||||
|
this.$root.socket.emit('scan')
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
this.$axios.$post('/logout').catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
|
if (localStorage.getItem('token')) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
this.$router.push('/login')
|
||||||
|
},
|
||||||
|
menuAction(action) {
|
||||||
|
if (action === 'logout') {
|
||||||
|
this.logout()
|
||||||
|
} else if (action === 'settings') {
|
||||||
|
// Show settings modal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#appbar {
|
||||||
|
box-shadow: 0px 8px 8px #111111aa;
|
||||||
|
}
|
||||||
|
</style>
|
102
client/components/app/BookShelf.vue
Normal file
102
client/components/app/BookShelf.vue
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto">
|
||||||
|
<div class="w-full flex flex-col items-center">
|
||||||
|
<template v-for="(shelf, index) in groupedBooks">
|
||||||
|
<div :key="index" class="w-full bookshelfRow relative">
|
||||||
|
<div class="flex justify-center items-center">
|
||||||
|
<template v-for="audiobook in shelf">
|
||||||
|
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
width: 0,
|
||||||
|
bookWidth: 176,
|
||||||
|
booksPerRow: 0,
|
||||||
|
groupedBooks: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userAudiobooks() {
|
||||||
|
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
|
||||||
|
},
|
||||||
|
audiobooks() {
|
||||||
|
return this.$store.state.audiobooks.audiobooks
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setGroupedBooks() {
|
||||||
|
var groups = []
|
||||||
|
var currentRow = 0
|
||||||
|
var currentGroup = []
|
||||||
|
for (let i = 0; i < this.audiobooks.length; i++) {
|
||||||
|
var row = Math.floor(i / this.booksPerRow)
|
||||||
|
if (row > currentRow) {
|
||||||
|
groups.push([...currentGroup])
|
||||||
|
currentRow = row
|
||||||
|
currentGroup = []
|
||||||
|
}
|
||||||
|
currentGroup.push(this.audiobooks[i])
|
||||||
|
}
|
||||||
|
if (currentGroup.length) {
|
||||||
|
groups.push([...currentGroup])
|
||||||
|
}
|
||||||
|
this.groupedBooks = groups
|
||||||
|
},
|
||||||
|
calculateBookshelf() {
|
||||||
|
this.width = this.$refs.wrapper.clientWidth
|
||||||
|
var booksPerRow = Math.floor(this.width / this.bookWidth)
|
||||||
|
this.booksPerRow = booksPerRow
|
||||||
|
},
|
||||||
|
getAudiobookCard(id) {
|
||||||
|
if (this.$refs[`audiobookCard-${id}`] && this.$refs[`audiobookCard-${id}`].length) {
|
||||||
|
return this.$refs[`audiobookCard-${id}`][0]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.calculateBookshelf()
|
||||||
|
},
|
||||||
|
resize() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.calculateBookshelf()
|
||||||
|
this.setGroupedBooks()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
audiobooksUpdated() {
|
||||||
|
console.log('[AudioBookshelf] Audiobooks Updated')
|
||||||
|
this.setGroupedBooks()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
|
||||||
|
this.$store.dispatch('audiobooks/load')
|
||||||
|
this.init()
|
||||||
|
window.addEventListener('resize', this.resize)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$store.commit('audiobooks/removeListener', 'bookshelf')
|
||||||
|
window.removeEventListener('resize', this.resize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bookshelfRow {
|
||||||
|
background-image: url(/wood_panels.jpg);
|
||||||
|
}
|
||||||
|
.bookshelfDivider {
|
||||||
|
background: rgb(149, 119, 90);
|
||||||
|
background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%);
|
||||||
|
box-shadow: 2px 14px 8px #111111aa;
|
||||||
|
}
|
||||||
|
</style>
|
142
client/components/app/StreamContainer.vue
Normal file
142
client/components/app/StreamContainer.vue
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-20 bg-primary p-4">
|
||||||
|
<div class="absolute -top-16 left-4">
|
||||||
|
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center pl-24">
|
||||||
|
<div>
|
||||||
|
<h1>
|
||||||
|
{{ title }} <span v-if="stream" class="text-xs text-gray-400">({{ stream.id }})</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-400 text-sm">by {{ author }}</p>
|
||||||
|
</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" :loading="!stream" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
lastServerUpdateSentSeconds: 0,
|
||||||
|
stream: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
cover() {
|
||||||
|
if (this.streamAudiobook && this.streamAudiobook.cover) return this.streamAudiobook.cover
|
||||||
|
return 'Logo.png'
|
||||||
|
},
|
||||||
|
user() {
|
||||||
|
return this.$store.state.user
|
||||||
|
},
|
||||||
|
streamAudiobook() {
|
||||||
|
return this.$store.state.streamAudiobook
|
||||||
|
},
|
||||||
|
book() {
|
||||||
|
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.book.title || 'No Title'
|
||||||
|
},
|
||||||
|
author() {
|
||||||
|
return this.book.author || 'Unknown'
|
||||||
|
},
|
||||||
|
streamId() {
|
||||||
|
return this.stream ? this.stream.id : null
|
||||||
|
},
|
||||||
|
playlistUrl() {
|
||||||
|
return this.stream ? this.stream.clientPlaylistUri : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
audioPlayerMounted() {
|
||||||
|
if (this.stream) {
|
||||||
|
// this.$refs.audioPlayer.set(this.playlistUrl)
|
||||||
|
this.openStream()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelStream() {
|
||||||
|
this.$root.socket.emit('close_stream')
|
||||||
|
},
|
||||||
|
terminateStream() {
|
||||||
|
if (this.$refs.audioPlayer) {
|
||||||
|
this.$refs.audioPlayer.terminateStream()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openStream() {
|
||||||
|
var playOnLoad = this.$store.state.playOnLoad
|
||||||
|
console.log(`[StreamContainer] openStream PlayOnLoad`, playOnLoad)
|
||||||
|
if (!this.$refs.audioPlayer) {
|
||||||
|
console.error('NO Audio Player')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var currentTime = this.stream.clientCurrentTime || 0
|
||||||
|
this.$refs.audioPlayer.set(this.playlistUrl, currentTime, playOnLoad)
|
||||||
|
},
|
||||||
|
streamProgress(data) {
|
||||||
|
if (!data.numSegments) return
|
||||||
|
var chunks = data.chunks
|
||||||
|
if (this.$refs.audioPlayer) {
|
||||||
|
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
streamOpen(stream) {
|
||||||
|
this.stream = stream
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.openStream()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
streamClosed(streamId) {
|
||||||
|
if (this.stream && this.stream.id === streamId) {
|
||||||
|
this.terminateStream()
|
||||||
|
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
streamReady() {
|
||||||
|
if (this.$refs.audioPlayer) {
|
||||||
|
this.$refs.audioPlayer.setStreamReady()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateTime(currentTime) {
|
||||||
|
var diff = currentTime - this.lastServerUpdateSentSeconds
|
||||||
|
if (diff > 4 || diff < 0) {
|
||||||
|
this.lastServerUpdateSentSeconds = currentTime
|
||||||
|
var updatePayload = {
|
||||||
|
currentTime,
|
||||||
|
streamId: this.streamId
|
||||||
|
}
|
||||||
|
this.$root.socket.emit('stream_update', updatePayload)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
streamReset({ startTime, streamId }) {
|
||||||
|
if (streamId !== this.streamId) {
|
||||||
|
console.error('resetStream StreamId Mismatch', streamId, this.streamId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.$refs.audioPlayer) {
|
||||||
|
console.log(`[STREAM-CONTAINER] streamReset Received for time ${startTime}`)
|
||||||
|
this.$refs.audioPlayer.resetStream(startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.stream) {
|
||||||
|
console.log('[STREAM_CONTAINER] Mounted with STREAM', this.stream)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.openStream()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#streamContainer {
|
||||||
|
box-shadow: 0px -6px 8px #1111113f;
|
||||||
|
}
|
||||||
|
</style>
|
67
client/components/app/TracksTable.vue
Normal file
67
client/components/app/TracksTable.vue
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full my-2">
|
||||||
|
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
|
<p class="pr-4">Audio Tracks</p>
|
||||||
|
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||||
|
<ui-btn small color="primary">Edit Track Order</ui-btn>
|
||||||
|
</nuxt-link>
|
||||||
|
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
||||||
|
<span class="material-icons text-4xl">expand_more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<transition name="slide">
|
||||||
|
<div class="w-full" v-show="showTracks">
|
||||||
|
<table class="text-sm tracksTable">
|
||||||
|
<tr class="font-book">
|
||||||
|
<th>#</th>
|
||||||
|
<th class="text-left">Filename</th>
|
||||||
|
<th class="text-left">Size</th>
|
||||||
|
<th class="text-left">Duration</th>
|
||||||
|
</tr>
|
||||||
|
<template v-for="track in tracks">
|
||||||
|
<tr :key="track.index">
|
||||||
|
<td class="text-center">
|
||||||
|
<p>{{ track.index }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="font-book">
|
||||||
|
{{ track.filename }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono">
|
||||||
|
{{ $bytesPretty(track.size) }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono">
|
||||||
|
{{ $secondsToTimestamp(track.duration) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
tracks: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
audiobookId: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showTracks: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
clickBar() {
|
||||||
|
this.showTracks = !this.showTracks
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
110
client/components/cards/BookCard.vue
Normal file
110
client/components/cards/BookCard.vue
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ height: height + 32 + 'px', width: width + 32 + 'px' }" class="cursor-pointer p-4">
|
||||||
|
<div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||||
|
<div class="w-full relative" :style="{ height: width * 1.6 + 'px' }">
|
||||||
|
<cards-book-cover :audiobook="audiobook" />
|
||||||
|
|
||||||
|
<div v-show="isHovering" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
||||||
|
<div class="h-full flex items-center justify-center">
|
||||||
|
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
|
||||||
|
<span class="material-icons text-5xl">play_circle_filled</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-1.5 right-1.5 cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" @click.stop.prevent="editClick">
|
||||||
|
<span class="material-icons" style="font-size: 16px">edit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
</div>
|
||||||
|
<ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0">
|
||||||
|
<div class="h-6 w-10 bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||||
|
<span class="material-icons text-sm text-red-100 pr-1">priority_high</span>
|
||||||
|
</div>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
audiobook: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
userProgress: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isHovering: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
audiobookId() {
|
||||||
|
return this.audiobook.id
|
||||||
|
},
|
||||||
|
book() {
|
||||||
|
return this.audiobook.book || {}
|
||||||
|
},
|
||||||
|
width() {
|
||||||
|
return 120
|
||||||
|
},
|
||||||
|
height() {
|
||||||
|
return this.width * 1.6
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.book.title
|
||||||
|
},
|
||||||
|
author() {
|
||||||
|
return this.book.author
|
||||||
|
},
|
||||||
|
userProgressPercent() {
|
||||||
|
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||||
|
},
|
||||||
|
showError() {
|
||||||
|
return this.hasMissingParts || this.hasInvalidParts
|
||||||
|
},
|
||||||
|
hasMissingParts() {
|
||||||
|
return this.audiobook.hasMissingParts
|
||||||
|
},
|
||||||
|
hasInvalidParts() {
|
||||||
|
return this.audiobook.hasInvalidParts
|
||||||
|
},
|
||||||
|
errorText() {
|
||||||
|
var txt = ''
|
||||||
|
if (this.hasMissingParts) {
|
||||||
|
txt = `${this.hasMissingParts} missing parts.`
|
||||||
|
}
|
||||||
|
if (this.hasInvalidParts) {
|
||||||
|
if (this.hasMissingParts) txt += ' '
|
||||||
|
txt += `${this.hasInvalidParts} invalid parts.`
|
||||||
|
}
|
||||||
|
return txt || 'Unknown Error'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickError(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
this.$router.push(`/audiobook/${this.audiobookId}`)
|
||||||
|
},
|
||||||
|
play() {
|
||||||
|
this.$store.commit('setStreamAudiobook', this.audiobook)
|
||||||
|
this.$root.socket.emit('open_stream', this.audiobookId)
|
||||||
|
},
|
||||||
|
editClick() {
|
||||||
|
this.$store.commit('showEditModal', this.audiobook)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bookCard {
|
||||||
|
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||||
|
}
|
||||||
|
</style>
|
72
client/components/cards/BookCover.vue
Normal file
72
client/components/cards/BookCover.vue
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px' }">
|
||||||
|
<img ref="cover" :src="cover" class="w-full h-full object-cover" />
|
||||||
|
|
||||||
|
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||||
|
<div>
|
||||||
|
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||||
|
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ author }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
audiobook: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: Number,
|
||||||
|
default: 120
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
book() {
|
||||||
|
return this.audiobook.book || {}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.book.title || 'No Title'
|
||||||
|
},
|
||||||
|
titleCleaned() {
|
||||||
|
if (this.title.length > 75) {
|
||||||
|
return this.title.slice(0, 47) + '...'
|
||||||
|
}
|
||||||
|
return this.title
|
||||||
|
},
|
||||||
|
author() {
|
||||||
|
return this.book.author || 'Unknown'
|
||||||
|
},
|
||||||
|
cover() {
|
||||||
|
return this.book.cover || '/book_placeholder.jpg'
|
||||||
|
},
|
||||||
|
hasCover() {
|
||||||
|
return !!this.book.cover
|
||||||
|
},
|
||||||
|
fontSizeMultiplier() {
|
||||||
|
return this.width / 120
|
||||||
|
},
|
||||||
|
titleFontSize() {
|
||||||
|
return 0.75 * this.fontSizeMultiplier
|
||||||
|
},
|
||||||
|
authorFontSize() {
|
||||||
|
return 0.6 * this.fontSizeMultiplier
|
||||||
|
},
|
||||||
|
placeholderCoverPadding() {
|
||||||
|
return 0.8 * this.fontSizeMultiplier
|
||||||
|
},
|
||||||
|
authorBottom() {
|
||||||
|
return 0.75 * this.fontSizeMultiplier
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
89
client/components/controls/VolumeControl.vue
Normal file
89
client/components/controls/VolumeControl.vue
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative" v-click-outside="clickOutside">
|
||||||
|
<div class="cursor-pointer" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||||
|
<span class="material-icons text-3xl">volume_up</span>
|
||||||
|
</div>
|
||||||
|
<div v-show="isOpen" class="absolute bottom-10 left-0 h-28 py-2 bg-white shadow-sm rounded-lg">
|
||||||
|
<div ref="volumeTrack" class="w-2 border-2 border-white h-full bg-gray-400 mx-4 relative cursor-pointer" @mousedown="mousedownTrack" @click="clickVolumeTrack">
|
||||||
|
<div class="w-3 h-3 bg-gray-500 shadow-sm rounded-full absolute -left-1 bottom-0 pointer-events-none" :class="isDragging ? 'transform scale-150' : ''" :style="{ top: cursorTop + 'px' }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isOpen: false,
|
||||||
|
isDragging: false,
|
||||||
|
posY: 0,
|
||||||
|
trackHeight: 112 - 16
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
volume: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cursorTop() {
|
||||||
|
var top = this.trackHeight * this.volume
|
||||||
|
return top - 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
mousemove(e) {
|
||||||
|
var diff = this.posY - e.y
|
||||||
|
this.posY = e.y
|
||||||
|
var volShift = 0
|
||||||
|
if (diff < 0) {
|
||||||
|
// Volume up
|
||||||
|
volShift = diff / this.trackHeight
|
||||||
|
} else {
|
||||||
|
// volume down
|
||||||
|
volShift = diff / this.trackHeight
|
||||||
|
}
|
||||||
|
var newVol = this.volume - volShift
|
||||||
|
newVol = Math.min(Math.max(0, newVol), 1)
|
||||||
|
this.volume = newVol
|
||||||
|
e.preventDefault()
|
||||||
|
},
|
||||||
|
mouseup(e) {
|
||||||
|
if (this.isDragging) {
|
||||||
|
this.isDragging = false
|
||||||
|
document.body.removeEventListener('mousemove', this.mousemove)
|
||||||
|
document.body.removeEventListener('mouseup', this.mouseup)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mousedownTrack(e) {
|
||||||
|
this.isDragging = true
|
||||||
|
this.posY = e.y
|
||||||
|
var vol = e.offsetY / e.target.clientHeight
|
||||||
|
vol = Math.min(Math.max(vol, 0), 1)
|
||||||
|
this.volume = vol
|
||||||
|
document.body.addEventListener('mousemove', this.mousemove)
|
||||||
|
document.body.addEventListener('mouseup', this.mouseup)
|
||||||
|
e.preventDefault()
|
||||||
|
},
|
||||||
|
clickOutside() {
|
||||||
|
this.isOpen = false
|
||||||
|
},
|
||||||
|
clickVolumeIcon() {
|
||||||
|
this.isOpen = !this.isOpen
|
||||||
|
},
|
||||||
|
clickVolumeTrack(e) {
|
||||||
|
var vol = e.offsetY / e.target.clientHeight
|
||||||
|
vol = Math.min(Math.max(vol, 0), 1)
|
||||||
|
this.volume = vol
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
86
client/components/modals/EditModal.vue
Normal file
86
client/components/modals/EditModal.vue
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" :width="800" :height="500" :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 class="absolute -top-10 left-0 w-full flex">
|
||||||
|
<div class="h-10 w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300" :class="selectedTab === 'details' ? 'bg-bg' : 'bg-primary text-gray-400'" @click="selectTab('details')">Details</div>
|
||||||
|
<div class="h-10 w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300" :class="selectedTab === 'cover' ? 'bg-bg' : 'bg-primary text-gray-400'" @click="selectTab('cover')">Cover</div>
|
||||||
|
<div class="h-10 w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300" :class="selectedTab === 'match' ? 'bg-bg' : 'bg-primary text-gray-400'" @click="selectTab('match')">Match</div>
|
||||||
|
<div class="h-10 w-28 rounded-t-lg flex items-center justify-center cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300" :class="selectedTab === 'tracks' ? 'bg-bg' : 'bg-primary text-gray-400'" @click="selectTab('tracks')">Tracks</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 w-full h-full text-sm py-6 rounded-b-lg rounded-tr-lg bg-bg shadow-lg">
|
||||||
|
<keep-alive>
|
||||||
|
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
|
||||||
|
</keep-alive>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedTab: 'details',
|
||||||
|
processing: false,
|
||||||
|
audiobook: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) return
|
||||||
|
this.audiobook = null
|
||||||
|
this.fetchFull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.showEditModal
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('setShowEditModal', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tabName() {
|
||||||
|
if (this.selectedTab === 'details') return 'modals-edit-tabs-details'
|
||||||
|
else if (this.selectedTab === 'cover') return 'modals-edit-tabs-cover'
|
||||||
|
else if (this.selectedTab === 'match') return 'modals-edit-tabs-match'
|
||||||
|
else if (this.selectedTab === 'tracks') return 'modals-edit-tabs-tracks'
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
selectedAudiobook() {
|
||||||
|
return this.$store.state.selectedAudiobook || {}
|
||||||
|
},
|
||||||
|
selectedAudiobookId() {
|
||||||
|
return this.selectedAudiobook.id
|
||||||
|
},
|
||||||
|
book() {
|
||||||
|
return this.audiobook ? this.audiobook.book || {} : {}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.book.title || 'No Title'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
selectTab(tab) {
|
||||||
|
this.selectedTab = tab
|
||||||
|
},
|
||||||
|
async fetchFull() {
|
||||||
|
try {
|
||||||
|
this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
100
client/components/modals/Modal.vue
Normal file
100
client/components/modals/Modal.vue
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-50 flex items-center justify-center z-20 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">
|
||||||
|
<span class="material-icons text-4xl">close</span>
|
||||||
|
</div>
|
||||||
|
<slot name="outer" />
|
||||||
|
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickBg">
|
||||||
|
<slot />
|
||||||
|
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||||
|
<ui-loading-indicator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
processing: Boolean,
|
||||||
|
persistent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 500
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 'unset'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
el: null,
|
||||||
|
content: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.setShow()
|
||||||
|
} else {
|
||||||
|
this.setHide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modalHeight() {
|
||||||
|
if (typeof this.height === 'string') {
|
||||||
|
return this.height
|
||||||
|
} else {
|
||||||
|
return this.height + 'px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modalWidth() {
|
||||||
|
return typeof this.width === 'string' ? this.width : this.width + 'px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickBg(vm, ev) {
|
||||||
|
if (this.processing && this.persistent) return
|
||||||
|
if (vm.srcElement.classList.contains('modal-bg')) {
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setShow() {
|
||||||
|
document.body.appendChild(this.el)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.content.style.transform = 'scale(1)'
|
||||||
|
}, 10)
|
||||||
|
document.documentElement.classList.add('modal-open')
|
||||||
|
},
|
||||||
|
setHide() {
|
||||||
|
this.content.style.transform = 'scale(0)'
|
||||||
|
this.el.remove()
|
||||||
|
document.documentElement.classList.remove('modal-open')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.el = this.$refs.wrapper
|
||||||
|
this.content = this.$refs.content
|
||||||
|
this.content.style.transform = 'scale(0)'
|
||||||
|
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||||
|
this.el.style.opacity = 1
|
||||||
|
this.el.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
42
client/components/modals/edit-tabs/Cover.vue
Normal file
42
client/components/modals/edit-tabs/Cover.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<!-- <img :src="cover" class="w-40 h-60" /> -->
|
||||||
|
<div class="flex">
|
||||||
|
<cards-book-cover :audiobook="audiobook" />
|
||||||
|
<div class="flex-grow px-8">
|
||||||
|
<ui-text-input-with-label v-model="imageUrl" label="Image URL" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
audiobook: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cover: null,
|
||||||
|
imageUrl: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
audiobook: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
init() {
|
||||||
|
this.cover = this.audiobook.cover || '/book_placeholder.jpg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
135
client/components/modals/edit-tabs/Details.vue
Normal file
135
client/components/modals/edit-tabs/Details.vue
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
|
||||||
|
<div v-if="userProgress" class="bg-primary rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50">
|
||||||
|
<div class="w-full flex items-center">
|
||||||
|
<p>
|
||||||
|
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
|
||||||
|
</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
<ui-text-input-with-label v-model="details.title" label="Title" />
|
||||||
|
|
||||||
|
<ui-text-input-with-label v-model="details.author" label="Author" class="mt-4" />
|
||||||
|
|
||||||
|
<ui-textarea-with-label v-model="details.description" :rows="6" label="Description" class="mt-4" />
|
||||||
|
|
||||||
|
<div class="flex py-4">
|
||||||
|
<ui-btn color="error" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn type="submit">Submit</ui-btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
processing: Boolean,
|
||||||
|
audiobook: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
details: {
|
||||||
|
title: null,
|
||||||
|
description: null,
|
||||||
|
author: null
|
||||||
|
},
|
||||||
|
resettingProgress: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
audiobook: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isProcessing: {
|
||||||
|
get() {
|
||||||
|
return this.processing
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:processing', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
audiobookId() {
|
||||||
|
return this.audiobook ? this.audiobook.id : null
|
||||||
|
},
|
||||||
|
book() {
|
||||||
|
return this.audiobook ? this.audiobook.book || {} : {}
|
||||||
|
},
|
||||||
|
userAudiobook() {
|
||||||
|
return this.$store.getters['getUserAudiobook'](this.audiobookId)
|
||||||
|
},
|
||||||
|
userProgress() {
|
||||||
|
return this.userAudiobook ? this.userAudiobook.progress : 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async submitForm() {
|
||||||
|
console.log('Submit form', this.details)
|
||||||
|
this.isProcessing = true
|
||||||
|
const updatePayload = {
|
||||||
|
book: this.details
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
console.log('Update Successful', updatedAudiobook)
|
||||||
|
this.$toast.success('Update Successful')
|
||||||
|
this.$emit('close')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.details.title = this.book.title
|
||||||
|
this.details.description = this.book.description
|
||||||
|
this.details.author = this.book.author
|
||||||
|
},
|
||||||
|
resetProgress() {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteAudiobook() {
|
||||||
|
if (confirm(`Are you sure you want to remove this audiobook?`)) {
|
||||||
|
this.isProcessing = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/audiobook/${this.audiobookId}`)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Audiobook removed')
|
||||||
|
this.$toast.success('Audiobook Removed')
|
||||||
|
this.$emit('close')
|
||||||
|
this.isProcessing = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Remove Audiobook failed', error)
|
||||||
|
this.isProcessing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
100
client/components/modals/edit-tabs/Match.vue
Normal file
100
client/components/modals/edit-tabs/Match.vue
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-hidden">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="w-72">
|
||||||
|
<form @submit.prevent="submitSearch">
|
||||||
|
<ui-text-input-with-label v-model="search" label="Search Title" placeholder="Search" :disabled="processing" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
</div>
|
||||||
|
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden">
|
||||||
|
<template v-for="(res, index) in searchResults">
|
||||||
|
<div :key="index" class="w-full border-b border-gray-700 pb-2 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch(res)">
|
||||||
|
<div class="flex py-1">
|
||||||
|
<img :src="res.cover || '/book_placeholder.jpg'" class="h-24 object-cover" style="width: 60px" />
|
||||||
|
<div class="px-4 flex-grow">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1>{{ res.title }}</h1>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p>{{ res.first_publish_year || res.first_publish_date }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400">{{ res.author }}</p>
|
||||||
|
<div class="w-full max-h-12 overflow-hidden">
|
||||||
|
<p class="text-gray-500 text-xs" v-html="res.description"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="res.covers && res.covers.length > 1" class="flex">
|
||||||
|
<template v-for="cover in res.covers.slice(1)">
|
||||||
|
<img :key="cover" :src="cover" class="h-20 w-12 object-cover mr-1" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
audiobook: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
search: null,
|
||||||
|
lastSearch: null,
|
||||||
|
processing: false,
|
||||||
|
searchResults: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
audiobook: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
submitSearch() {
|
||||||
|
this.runSearch()
|
||||||
|
},
|
||||||
|
async runSearch() {
|
||||||
|
if (this.lastSearch === this.search) return
|
||||||
|
console.log('Search', this.lastSearch, this.search)
|
||||||
|
|
||||||
|
this.searchResults = []
|
||||||
|
this.processing = true
|
||||||
|
this.lastSearch = this.search
|
||||||
|
var results = await this.$axios.$get(`/api/find/search?title=${this.search}`)
|
||||||
|
console.log('Got results', results)
|
||||||
|
this.searchResults = results
|
||||||
|
this.processing = false
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
if (!this.audiobook.book || !this.audiobook.book.title) {
|
||||||
|
this.search = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.searchResults.length) {
|
||||||
|
console.log('Already hav ereuslts', this.searchResults, this.lastSearch)
|
||||||
|
}
|
||||||
|
this.search = this.audiobook.book.title
|
||||||
|
this.runSearch()
|
||||||
|
},
|
||||||
|
selectMatch(match) {}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
console.log('Match mounted')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
66
client/components/modals/edit-tabs/Tracks.vue
Normal file
66
client/components/modals/edit-tabs/Tracks.vue
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden">
|
||||||
|
<div class="flex mb-4">
|
||||||
|
<nuxt-link :to="`/audiobook/${audiobook.id}/edit`">
|
||||||
|
<ui-btn color="primary">Edit Track Order</ui-btn>
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
<table class="text-sm tracksTable">
|
||||||
|
<tr class="font-book">
|
||||||
|
<th>#</th>
|
||||||
|
<th class="text-left">Filename</th>
|
||||||
|
<th class="text-left">Size</th>
|
||||||
|
<th class="text-left">Duration</th>
|
||||||
|
</tr>
|
||||||
|
<template v-for="track in tracks">
|
||||||
|
<tr :key="track.index">
|
||||||
|
<td class="text-center">
|
||||||
|
<p>{{ track.index }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="font-book">
|
||||||
|
{{ track.filename }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono">
|
||||||
|
{{ $bytesPretty(track.size) }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono">
|
||||||
|
{{ $secondsToTimestamp(track.duration) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
audiobook: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tracks: null,
|
||||||
|
audioFiles: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
audiobook: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
init() {
|
||||||
|
this.audioFiles = this.audiobook.audioFiles
|
||||||
|
this.tracks = this.audiobook.tracks
|
||||||
|
console.log('INIT', this.audiobook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
67
client/components/ui/Btn.vue
Normal file
67
client/components/ui/Btn.vue
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :type="type" :class="classList" @click="click">
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: 'primary'
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
paddingX: Number,
|
||||||
|
small: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
classList() {
|
||||||
|
var list = []
|
||||||
|
list.push('text-white')
|
||||||
|
list.push(`bg-${this.color}`)
|
||||||
|
if (this.small) {
|
||||||
|
list.push('text-sm')
|
||||||
|
if (this.paddingX === undefined) list.push('px-4')
|
||||||
|
list.push('py-1')
|
||||||
|
} else {
|
||||||
|
if (this.paddingX === undefined) list.push('px-8')
|
||||||
|
list.push('py-2')
|
||||||
|
}
|
||||||
|
if (this.paddingX !== undefined) {
|
||||||
|
list.push(`px-${this.paddingX}`)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
click(e) {
|
||||||
|
this.$emit('click', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
button.btn::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 6px;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, 0);
|
||||||
|
transition: all 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
button.btn:hover::before {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
70
client/components/ui/LoadingIndicator.vue
Normal file
70
client/components/ui/LoadingIndicator.vue
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-40">
|
||||||
|
<div class="bg-white border py-2 px-5 rounded-lg flex items-center flex-col">
|
||||||
|
<div class="loader-dots block relative w-20 h-5 mt-2">
|
||||||
|
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||||
|
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||||
|
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||||
|
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500 text-xs font-light mt-2 text-center">{{ text }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: 'Please Wait...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loader-dots div {
|
||||||
|
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||||
|
}
|
||||||
|
.loader-dots div:nth-child(1) {
|
||||||
|
left: 8px;
|
||||||
|
animation: loader-dots1 0.6s infinite;
|
||||||
|
}
|
||||||
|
.loader-dots div:nth-child(2) {
|
||||||
|
left: 8px;
|
||||||
|
animation: loader-dots2 0.6s infinite;
|
||||||
|
}
|
||||||
|
.loader-dots div:nth-child(3) {
|
||||||
|
left: 32px;
|
||||||
|
animation: loader-dots2 0.6s infinite;
|
||||||
|
}
|
||||||
|
.loader-dots div:nth-child(4) {
|
||||||
|
left: 56px;
|
||||||
|
animation: loader-dots3 0.6s infinite;
|
||||||
|
}
|
||||||
|
@keyframes loader-dots1 {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes loader-dots3 {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes loader-dots2 {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(24px, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
54
client/components/ui/Menu.vue
Normal file
54
client/components/ui/Menu.vue
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative" v-click-outside="clickOutside">
|
||||||
|
<button type="button" class="relative w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="showMenu = !showMenu">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="block truncate">{{ label }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<span class="material-icons text-gray-100">person</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<transition name="menu">
|
||||||
|
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
|
||||||
|
<template v-for="item in items">
|
||||||
|
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Menu'
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showMenu: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickOutside() {
|
||||||
|
this.showMenu = false
|
||||||
|
},
|
||||||
|
clickedOption(itemValue) {
|
||||||
|
this.$emit('action', itemValue)
|
||||||
|
this.showMenu = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
47
client/components/ui/TextInput.vue
Normal file
47
client/components/ui/TextInput.vue
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: [String, Number],
|
||||||
|
placeholder: String,
|
||||||
|
readonly: Boolean,
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'text'
|
||||||
|
},
|
||||||
|
transparent: Boolean,
|
||||||
|
disabled: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
inputValue: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
change(e) {
|
||||||
|
this.$emit('change', e.target.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
input {
|
||||||
|
border-style: inherit !important;
|
||||||
|
}
|
||||||
|
input:read-only {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
</style>
|
31
client/components/ui/TextInputWithLabel.vue
Normal file
31
client/components/ui/TextInputWithLabel.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<p class="px-1">{{ label }}</p>
|
||||||
|
<ui-text-input v-model="inputValue" :disabled="disabled" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: [String, Number],
|
||||||
|
label: String,
|
||||||
|
disabled: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
inputValue: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
47
client/components/ui/TextareaInput.vue
Normal file
47
client/components/ui/TextareaInput.vue
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<textarea v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: [String, Number],
|
||||||
|
placeholder: String,
|
||||||
|
readonly: Boolean,
|
||||||
|
rows: {
|
||||||
|
type: Number,
|
||||||
|
default: 2
|
||||||
|
},
|
||||||
|
transparent: Boolean,
|
||||||
|
disabled: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
inputValue: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
change(e) {
|
||||||
|
this.$emit('change', e.target.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
input {
|
||||||
|
border-style: inherit !important;
|
||||||
|
}
|
||||||
|
input:read-only {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
</style>
|
34
client/components/ui/TextareaWithLabel.vue
Normal file
34
client/components/ui/TextareaWithLabel.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<p class="px-1">{{ label }}</p>
|
||||||
|
<ui-textarea-input v-model="inputValue" :rows="rows" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: [String, Number],
|
||||||
|
label: String,
|
||||||
|
rows: {
|
||||||
|
type: Number,
|
||||||
|
default: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
inputValue: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
55
client/components/ui/Tooltip.vue
Normal file
55
client/components/ui/Tooltip.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="box" class="tooltip-box" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tooltip: null,
|
||||||
|
isShowing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
createTooltip() {
|
||||||
|
var boxChow = this.$refs.box.getBoundingClientRect()
|
||||||
|
var top = boxChow.top
|
||||||
|
var left = boxChow.left + boxChow.width + 4
|
||||||
|
|
||||||
|
var tooltip = document.createElement('div')
|
||||||
|
tooltip.className = 'absolute px-2 bg-black bg-opacity-60 py-1 text-white pointer-events-none text-xs'
|
||||||
|
tooltip.style.top = top + 'px'
|
||||||
|
tooltip.style.left = left + 'px'
|
||||||
|
tooltip.style.zIndex = 100
|
||||||
|
tooltip.innerText = this.text
|
||||||
|
this.tooltip = tooltip
|
||||||
|
},
|
||||||
|
showTooltip() {
|
||||||
|
if (!this.tooltip) {
|
||||||
|
this.createTooltip()
|
||||||
|
}
|
||||||
|
document.body.appendChild(this.tooltip)
|
||||||
|
this.isShowing = true
|
||||||
|
},
|
||||||
|
hideTooltip() {
|
||||||
|
if (!this.tooltip) return
|
||||||
|
this.tooltip.remove()
|
||||||
|
this.isShowing = false
|
||||||
|
},
|
||||||
|
mouseover() {
|
||||||
|
if (!this.isShowing) this.showTooltip()
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
if (this.isShowing) this.hideTooltip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
37
client/components/widgets/ScanAlert.vue
Normal file
37
client/components/widgets/ScanAlert.vue
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<div v-show="isScanning" class="fixed bottom-0 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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
text() {
|
||||||
|
return `Scanning... <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.$store.state.isScanning
|
||||||
|
},
|
||||||
|
scanProgress() {
|
||||||
|
return this.$store.state.scanProgress
|
||||||
|
},
|
||||||
|
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: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
9
client/layouts/blank.vue
Normal file
9
client/layouts/blank.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
|
||||||
|
<Nuxt />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {}
|
||||||
|
</script>
|
150
client/layouts/default.vue
Normal file
150
client/layouts/default.vue
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
|
||||||
|
<app-appbar />
|
||||||
|
<Nuxt />
|
||||||
|
<app-stream-container ref="streamContainer" />
|
||||||
|
<modals-edit-modal />
|
||||||
|
<widgets-scan-alert />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
socket: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
$route(newVal) {
|
||||||
|
if (this.$store.state.showEditModal) {
|
||||||
|
this.$store.commit('setShowEditModal', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
user() {
|
||||||
|
return this.$store.state.user
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
connect() {
|
||||||
|
console.log('[SOCKET] Connected')
|
||||||
|
var token = this.$store.getters.getToken
|
||||||
|
this.socket.emit('auth', token)
|
||||||
|
},
|
||||||
|
connectError() {},
|
||||||
|
disconnect() {
|
||||||
|
console.log('[SOCKET] Disconnected')
|
||||||
|
},
|
||||||
|
reconnect() {},
|
||||||
|
reconnectError() {},
|
||||||
|
reconnectFailed() {},
|
||||||
|
init(payload) {
|
||||||
|
console.log('Init Payload', payload)
|
||||||
|
if (payload.stream) {
|
||||||
|
if (this.$refs.streamContainer) {
|
||||||
|
this.$store.commit('setStream', payload.stream)
|
||||||
|
this.$refs.streamContainer.streamOpen(payload.stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload.user) {
|
||||||
|
this.$store.commit('setUser', payload.user)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
streamOpen(stream) {
|
||||||
|
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
|
||||||
|
},
|
||||||
|
streamClosed(streamId) {
|
||||||
|
if (this.$refs.streamContainer) this.$refs.streamContainer.streamClosed(streamId)
|
||||||
|
},
|
||||||
|
streamProgress(data) {
|
||||||
|
if (this.$refs.streamContainer) this.$refs.streamContainer.streamProgress(data)
|
||||||
|
},
|
||||||
|
streamReady() {
|
||||||
|
if (this.$refs.streamContainer) this.$refs.streamContainer.streamReady()
|
||||||
|
},
|
||||||
|
streamReset(payload) {
|
||||||
|
if (this.$refs.streamContainer) this.$refs.streamContainer.streamReset(payload)
|
||||||
|
},
|
||||||
|
audiobookAdded(audiobook) {
|
||||||
|
this.$store.commit('audiobooks/addUpdate', audiobook)
|
||||||
|
},
|
||||||
|
audiobookUpdated(audiobook) {
|
||||||
|
this.$store.commit('audiobooks/addUpdate', audiobook)
|
||||||
|
},
|
||||||
|
audiobookRemoved(audiobook) {
|
||||||
|
if (this.$route.name.startsWith('audiobook')) {
|
||||||
|
if (this.$route.params.id === audiobook.id) {
|
||||||
|
this.$router.replace('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.$store.commit('audiobooks/remove', audiobook)
|
||||||
|
},
|
||||||
|
scanComplete() {
|
||||||
|
this.$store.commit('setIsScanning', false)
|
||||||
|
this.$toast.success('Scan Finished')
|
||||||
|
},
|
||||||
|
scanStart() {
|
||||||
|
this.$store.commit('setIsScanning', true)
|
||||||
|
},
|
||||||
|
scanProgress(progress) {
|
||||||
|
this.$store.commit('setScanProgress', progress)
|
||||||
|
},
|
||||||
|
userUpdated(user) {
|
||||||
|
if (this.$store.state.user.id === user.id) {
|
||||||
|
this.$store.commit('setUser', user)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initializeSocket() {
|
||||||
|
this.socket = this.$nuxtSocket({
|
||||||
|
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||||
|
persist: 'main',
|
||||||
|
teardown: true,
|
||||||
|
transports: ['websocket'],
|
||||||
|
upgrade: false
|
||||||
|
})
|
||||||
|
this.$root.socket = this.socket
|
||||||
|
|
||||||
|
// Connection Listeners
|
||||||
|
this.socket.on('connect', this.connect)
|
||||||
|
this.socket.on('connect_error', this.connectError)
|
||||||
|
this.socket.on('disconnect', this.disconnect)
|
||||||
|
this.socket.on('reconnecting', this.reconnecting)
|
||||||
|
this.socket.on('reconnect', this.reconnect)
|
||||||
|
this.socket.on('reconnect_error', this.reconnectError)
|
||||||
|
this.socket.on('reconnect_failed', this.reconnectFailed)
|
||||||
|
|
||||||
|
this.socket.on('init', this.init)
|
||||||
|
|
||||||
|
// Stream Listeners
|
||||||
|
this.socket.on('stream_open', this.streamOpen)
|
||||||
|
this.socket.on('stream_closed', this.streamClosed)
|
||||||
|
this.socket.on('stream_progress', this.streamProgress)
|
||||||
|
this.socket.on('stream_ready', this.streamReady)
|
||||||
|
this.socket.on('stream_reset', this.streamReset)
|
||||||
|
|
||||||
|
// Audiobook Listeners
|
||||||
|
this.socket.on('audiobook_updated', this.audiobookUpdated)
|
||||||
|
this.socket.on('audiobook_added', this.audiobookAdded)
|
||||||
|
this.socket.on('audiobook_removed', this.audiobookRemoved)
|
||||||
|
|
||||||
|
// User Listeners
|
||||||
|
this.socket.on('user_updated', this.userUpdated)
|
||||||
|
|
||||||
|
// Scan Listeners
|
||||||
|
this.socket.on('scan_start', this.scanStart)
|
||||||
|
this.socket.on('scan_complete', this.scanComplete)
|
||||||
|
this.socket.on('scan_progress', this.scanProgress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeMount() {
|
||||||
|
if (!this.$store.state.user) {
|
||||||
|
this.$router.replace(`/login?redirect=${this.$route.path}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.initializeSocket()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
103
client/nuxt.config.js
Normal file
103
client/nuxt.config.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
const pkg = require('./package.json')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Disable server-side rendering: https://go.nuxtjs.dev/ssr-mode
|
||||||
|
ssr: false,
|
||||||
|
target: 'static',
|
||||||
|
dev: process.env.NODE_ENV !== 'production',
|
||||||
|
env: {
|
||||||
|
serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333',
|
||||||
|
// serverUrl: '',
|
||||||
|
baseUrl: process.env.BASE_URL || 'http://0.0.0.0'
|
||||||
|
},
|
||||||
|
// rootDir: process.env.NODE_ENV !== 'production' ? 'client/' : '',
|
||||||
|
telemetry: false,
|
||||||
|
|
||||||
|
publicRuntimeConfig: {
|
||||||
|
version: pkg.version
|
||||||
|
},
|
||||||
|
|
||||||
|
// Global page headers: https://go.nuxtjs.dev/config-head
|
||||||
|
head: {
|
||||||
|
title: 'AudioBookshelf',
|
||||||
|
htmlAttrs: {
|
||||||
|
lang: 'en'
|
||||||
|
},
|
||||||
|
meta: [
|
||||||
|
{ charset: 'utf-8' },
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
|
{ hid: 'description', name: 'description', content: '' }
|
||||||
|
],
|
||||||
|
script: [
|
||||||
|
{
|
||||||
|
src: '//cdn.jsdelivr.net/npm/sortablejs@1.8.4/Sortable.min.js'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
link: [
|
||||||
|
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||||
|
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fira+Mono&family=Ubuntu+Mono&family=Open+Sans:wght@600&family=Gentium+Book+Basic' },
|
||||||
|
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||||
|
css: [
|
||||||
|
'@/assets/app.css'
|
||||||
|
],
|
||||||
|
|
||||||
|
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
|
||||||
|
plugins: [
|
||||||
|
'@/plugins/init.client.js',
|
||||||
|
'@/plugins/axios.js',
|
||||||
|
'@/plugins/toast.js'
|
||||||
|
],
|
||||||
|
|
||||||
|
// Auto import components: https://go.nuxtjs.dev/config-components
|
||||||
|
components: true,
|
||||||
|
|
||||||
|
// Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
|
||||||
|
buildModules: [
|
||||||
|
// https://go.nuxtjs.dev/tailwindcss
|
||||||
|
'@nuxtjs/tailwindcss',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Modules: https://go.nuxtjs.dev/config-modules
|
||||||
|
modules: [
|
||||||
|
'nuxt-socket-io',
|
||||||
|
'@nuxtjs/axios',
|
||||||
|
'@nuxtjs/proxy'
|
||||||
|
],
|
||||||
|
|
||||||
|
proxy: {
|
||||||
|
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } }
|
||||||
|
},
|
||||||
|
|
||||||
|
io: {
|
||||||
|
sockets: [{
|
||||||
|
name: 'dev',
|
||||||
|
url: 'http://localhost:3333'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'prod'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
||||||
|
axios: {
|
||||||
|
baseURL: process.env.serverUrl || ''
|
||||||
|
},
|
||||||
|
|
||||||
|
// Build Configuration: https://go.nuxtjs.dev/config-build
|
||||||
|
build: {
|
||||||
|
},
|
||||||
|
watchers: {
|
||||||
|
webpack: {
|
||||||
|
aggregateTimeout: 300,
|
||||||
|
poll: 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
|
||||||
|
host: process.env.NODE_ENV === 'production' ? '0.0.0.0' : 'localhost'
|
||||||
|
}
|
||||||
|
}
|
14255
client/package-lock.json
generated
Normal file
14255
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
client/package.json
Normal file
28
client/package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "audiobookshelf-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"start": "nuxt start",
|
||||||
|
"generate": "nuxt generate"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
"@nuxtjs/proxy": "^2.1.0",
|
||||||
|
"core-js": "^3.16.0",
|
||||||
|
"hls.js": "^1.0.7",
|
||||||
|
"nuxt": "^2.15.7",
|
||||||
|
"nuxt-socket-io": "^1.1.18",
|
||||||
|
"vue-toastification": "^1.7.11",
|
||||||
|
"vuedraggable": "^2.24.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxtjs/tailwindcss": "^4.2.1",
|
||||||
|
"postcss": "^8.3.6"
|
||||||
|
}
|
||||||
|
}
|
222
client/pages/audiobook/_id/edit.vue
Normal file
222
client/pages/audiobook/_id/edit.vue
Normal 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>
|
220
client/pages/audiobook/_id/index.vue
Normal file
220
client/pages/audiobook/_id/index.vue
Normal 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>
|
32
client/pages/config/index.vue
Normal file
32
client/pages/config/index.vue
Normal 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
21
client/pages/index.vue
Normal 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
108
client/pages/login.vue
Normal 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>
|
19
client/plugins/axios.js
Normal file
19
client/plugins/axios.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export default function ({ $axios, store }) {
|
||||||
|
$axios.onRequest(config => {
|
||||||
|
console.log('Making request to ' + config.url)
|
||||||
|
var bearerToken = store.state.user ? store.state.user.token : null
|
||||||
|
// console.log('Bearer token', bearerToken)
|
||||||
|
if (bearerToken) {
|
||||||
|
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
config.url = `/dev${config.url}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$axios.onError(error => {
|
||||||
|
const code = parseInt(error.response && error.response.status)
|
||||||
|
console.error('Axios error code', code)
|
||||||
|
})
|
||||||
|
}
|
120
client/plugins/init.client.js
Normal file
120
client/plugins/init.client.js
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
|
||||||
|
|
||||||
|
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||||
|
if (bytes === 0) {
|
||||||
|
return '0 Bytes'
|
||||||
|
}
|
||||||
|
const k = 1024
|
||||||
|
const dm = decimals < 0 ? 0 : decimals
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.prototype.$elapsedPretty = (seconds) => {
|
||||||
|
var minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 70) {
|
||||||
|
return `${minutes} min`
|
||||||
|
}
|
||||||
|
var hours = Math.floor(minutes / 60)
|
||||||
|
minutes -= hours * 60
|
||||||
|
if (!minutes) {
|
||||||
|
return `${hours} hr`
|
||||||
|
}
|
||||||
|
return `${hours} hr ${minutes} min`
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||||
|
var _seconds = seconds
|
||||||
|
var _minutes = Math.floor(seconds / 60)
|
||||||
|
_seconds -= _minutes * 60
|
||||||
|
var _hours = Math.floor(_minutes / 60)
|
||||||
|
_minutes -= _hours * 60
|
||||||
|
_seconds = Math.round(_seconds)
|
||||||
|
if (!_hours) {
|
||||||
|
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImageBlob(uri) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = document.createElement('img')
|
||||||
|
const c = document.createElement('canvas')
|
||||||
|
const ctx = c.getContext('2d')
|
||||||
|
img.onload = ({ target }) => {
|
||||||
|
c.width = target.naturalWidth
|
||||||
|
c.height = target.naturalHeight
|
||||||
|
ctx.drawImage(target, 0, 0)
|
||||||
|
c.toBlob((b) => resolve(b), 'image/jpeg', 0.75)
|
||||||
|
}
|
||||||
|
img.crossOrigin = ''
|
||||||
|
img.src = uri
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.prototype.$downloadImage = async (uri, name) => {
|
||||||
|
var blob = await loadImageBlob(uri)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = URL.createObjectURL(blob)
|
||||||
|
a.target = '_blank'
|
||||||
|
a.download = name || 'fotosho-image'
|
||||||
|
a.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
|
||||||
|
const isDOMElement = (element) => {
|
||||||
|
return element instanceof Element || element instanceof HTMLDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickedEl = clickEvent.srcElement
|
||||||
|
const didClickOnIgnoredEl = ignoreElems.filter((el) => el).some((element) => element.contains(clickedEl) || element.isEqualNode(clickedEl))
|
||||||
|
const didClickOnIgnoredSelector = ignoreSelectors.length ? ignoreSelectors.map((selector) => clickedEl.closest(selector)).reduce((curr, accumulator) => curr && accumulator, true) : false
|
||||||
|
|
||||||
|
if (isDOMElement(elToCheckOutside) && !elToCheckOutside.contains(clickedEl) && !didClickOnIgnoredEl && !didClickOnIgnoredSelector) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.directive('click-outside', {
|
||||||
|
bind: function (el, binding, vnode) {
|
||||||
|
let vm = vnode.context;
|
||||||
|
let callback = binding.value;
|
||||||
|
if (typeof callback !== 'function') {
|
||||||
|
console.error('Invalid callback', binding)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
el['__click_outside__'] = (ev) => {
|
||||||
|
if (isClickedOutsideEl(ev, el)) {
|
||||||
|
callback.call(vm, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('click', el['__click_outside__'], false)
|
||||||
|
},
|
||||||
|
unbind: function (el, binding, vnode) {
|
||||||
|
document.removeEventListener('click', el['__click_outside__'], false)
|
||||||
|
delete el['__click_outside__']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
||||||
|
if (typeof input !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
||||||
|
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
||||||
|
var reservedRe = /^\.+$/;
|
||||||
|
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
||||||
|
var windowsTrailingRe = /[\. ]+$/;
|
||||||
|
|
||||||
|
var sanitized = input
|
||||||
|
.replace(illegalRe, replacement)
|
||||||
|
.replace(controlRe, replacement)
|
||||||
|
.replace(reservedRe, replacement)
|
||||||
|
.replace(windowsReservedRe, replacement)
|
||||||
|
.replace(windowsTrailingRe, replacement);
|
||||||
|
return sanitized
|
||||||
|
}
|
11
client/plugins/toast.js
Normal file
11
client/plugins/toast.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import Vue from "vue";
|
||||||
|
import Toast from "vue-toastification";
|
||||||
|
// Import the CSS or use your own!
|
||||||
|
import "vue-toastification/dist/index.css";
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hideProgressBar: true
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
Vue.use(Toast, options);
|
BIN
client/static/Logo.png
Normal file
BIN
client/static/Logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 69 KiB |
BIN
client/static/LogoTransparent.png
Normal file
BIN
client/static/LogoTransparent.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 74 KiB |
BIN
client/static/book_placeholder.jpg
Normal file
BIN
client/static/book_placeholder.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 105 KiB |
BIN
client/static/favicon.ico
Normal file
BIN
client/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
BIN
client/static/wood_panels.jpg
Normal file
BIN
client/static/wood_panels.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 114 KiB |
67
client/store/audiobooks.js
Normal file
67
client/store/audiobooks.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
|
||||||
|
export const state = () => ({
|
||||||
|
audiobooks: [],
|
||||||
|
listeners: []
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getters = {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
load({ commit }) {
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/audiobooks`)
|
||||||
|
.then((data) => {
|
||||||
|
commit('set', data)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
commit('set', [])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mutations = {
|
||||||
|
set(state, audiobooks) {
|
||||||
|
console.log('Set Audiobooks', audiobooks)
|
||||||
|
state.audiobooks = audiobooks
|
||||||
|
state.listeners.forEach((listener) => {
|
||||||
|
listener.meth()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addUpdate(state, audiobook) {
|
||||||
|
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
|
||||||
|
if (index >= 0) {
|
||||||
|
console.log('Audiobook Updated', audiobook)
|
||||||
|
state.audiobooks.splice(index, 1, audiobook)
|
||||||
|
} else {
|
||||||
|
console.log('Audiobook Added', audiobook)
|
||||||
|
state.audiobooks.push(audiobook)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.listeners.forEach((listener) => {
|
||||||
|
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
|
||||||
|
listener.meth()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
remove(state, audiobook) {
|
||||||
|
console.log('Audiobook removed', audiobook)
|
||||||
|
state.audiobooks = state.audiobooks.filter(a => a.id !== audiobook.id)
|
||||||
|
|
||||||
|
state.listeners.forEach((listener) => {
|
||||||
|
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
|
||||||
|
listener.meth()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addListener(state, listener) {
|
||||||
|
var index = state.listeners.findIndex(l => l.id === listener.id)
|
||||||
|
if (index >= 0) state.listeners.splice(index, 1, listener)
|
||||||
|
else state.listeners.push(listener)
|
||||||
|
},
|
||||||
|
removeListener(state, listenerId) {
|
||||||
|
state.listeners = state.listeners.filter(l => l.id !== listenerId)
|
||||||
|
}
|
||||||
|
}
|
63
client/store/index.js
Normal file
63
client/store/index.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
|
||||||
|
export const state = () => ({
|
||||||
|
user: null,
|
||||||
|
streamAudiobook: null,
|
||||||
|
showEditModal: false,
|
||||||
|
selectedAudiobook: null,
|
||||||
|
playOnLoad: false,
|
||||||
|
isScanning: false,
|
||||||
|
scanProgress: null
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getters = {
|
||||||
|
getToken: (state) => {
|
||||||
|
return state.user ? state.user.token : null
|
||||||
|
},
|
||||||
|
getUserAudiobook: (state) => (audiobookId) => {
|
||||||
|
return state.user && state.user.audiobooks ? state.user.audiobooks[audiobookId] || null : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mutations = {
|
||||||
|
setUser(state, user) {
|
||||||
|
state.user = user
|
||||||
|
console.log('SETUSER', user)
|
||||||
|
if (user.token) {
|
||||||
|
localStorage.setItem('token', user.token)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setStreamAudiobook(state, audiobook) {
|
||||||
|
state.playOnLoad = true
|
||||||
|
state.streamAudiobook = audiobook
|
||||||
|
},
|
||||||
|
setStream(state, stream) {
|
||||||
|
state.playOnLoad = false
|
||||||
|
state.streamAudiobook = stream ? stream.audiobook : null
|
||||||
|
},
|
||||||
|
clearStreamAudiobook(state, audiobookId) {
|
||||||
|
if (state.streamAudiobook && state.streamAudiobook.id === audiobookId) {
|
||||||
|
state.playOnLoad = false
|
||||||
|
state.streamAudiobook = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setPlayOnLoad(state, val) {
|
||||||
|
state.playOnLoad = val
|
||||||
|
},
|
||||||
|
showEditModal(state, audiobook) {
|
||||||
|
state.selectedAudiobook = audiobook
|
||||||
|
state.showEditModal = true
|
||||||
|
},
|
||||||
|
setShowEditModal(state, val) {
|
||||||
|
state.showEditModal = val
|
||||||
|
},
|
||||||
|
setIsScanning(state, isScanning) {
|
||||||
|
state.isScanning = isScanning
|
||||||
|
},
|
||||||
|
setScanProgress(state, progress) {
|
||||||
|
state.scanProgress = progress
|
||||||
|
}
|
||||||
|
}
|
41
client/tailwind.config.js
Normal file
41
client/tailwind.config.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const defaultTheme = require('tailwindcss/defaultTheme')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
purge: {},
|
||||||
|
darkMode: false,
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
bg: '#373838',
|
||||||
|
yellowgreen: 'yellowgreen',
|
||||||
|
primary: '#262626',
|
||||||
|
accent: '#1ad691',
|
||||||
|
error: '#FF5252',
|
||||||
|
info: '#2196F3',
|
||||||
|
success: '#4CAF50',
|
||||||
|
successDark: '#3b8a3e',
|
||||||
|
warning: '#FB8C00',
|
||||||
|
'black-50': '#bbbbbb',
|
||||||
|
'black-100': '#666666',
|
||||||
|
'black-200': '#555555',
|
||||||
|
'black-300': '#444444',
|
||||||
|
'black-400': '#333333',
|
||||||
|
'black-500': '#222222',
|
||||||
|
'black-600': '#111111',
|
||||||
|
'black-700': '#101010'
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
none: 'none'
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Open Sans', ...defaultTheme.fontFamily.sans],
|
||||||
|
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
|
||||||
|
book: ['Gentium Book Basic', 'serif']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
24
index.js
Normal file
24
index.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
||||||
|
|
||||||
|
const server = require('./server/Server')
|
||||||
|
global.appRoot = __dirname
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV !== 'production'
|
||||||
|
if (isDev) {
|
||||||
|
const devEnv = require('./dev').config
|
||||||
|
process.env.NODE_ENV = 'development'
|
||||||
|
process.env.PORT = devEnv.Port
|
||||||
|
process.env.CONFIG_PATH = devEnv.ConfigPath
|
||||||
|
process.env.METADATA_PATH = devEnv.MetadataPath
|
||||||
|
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 80
|
||||||
|
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
|
||||||
|
const AUDIOBOOK_PATH = process.env.AUDIOBOOK_PATH || '/audiobooks'
|
||||||
|
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
|
||||||
|
|
||||||
|
console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||||
|
|
||||||
|
const Server = new server(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||||
|
Server.start()
|
1106
package-lock.json
generated
Normal file
1106
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "audiobookshelf",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node index.js",
|
||||||
|
"start": "node index.js"
|
||||||
|
},
|
||||||
|
"author": "advplyr",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^0.21.1",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"chokidar": "^3.5.2",
|
||||||
|
"cookie-parser": "^1.4.5",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
|
"fs-extra": "^10.0.0",
|
||||||
|
"jsonwebtoken": "^8.5.1",
|
||||||
|
"libgen": "^2.1.0",
|
||||||
|
"njodb": "^0.4.20",
|
||||||
|
"node-dir": "^0.1.17",
|
||||||
|
"socket.io": "^4.1.3"
|
||||||
|
}
|
||||||
|
}
|
143
server/ApiController.js
Normal file
143
server/ApiController.js
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
|
class ApiController {
|
||||||
|
constructor(db, scanner, auth, streamManager, emitter) {
|
||||||
|
this.db = db
|
||||||
|
this.scanner = scanner
|
||||||
|
this.auth = auth
|
||||||
|
this.streamManager = streamManager
|
||||||
|
this.emitter = emitter
|
||||||
|
|
||||||
|
this.router = express()
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.router.get('/find/:method', this.find.bind(this))
|
||||||
|
|
||||||
|
this.router.get('/audiobooks', this.getAudiobooks.bind(this))
|
||||||
|
this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
|
||||||
|
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
|
||||||
|
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
|
||||||
|
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
|
||||||
|
|
||||||
|
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
|
||||||
|
this.router.patch('/match/:id', this.match.bind(this))
|
||||||
|
|
||||||
|
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
|
||||||
|
|
||||||
|
this.router.post('/authorize', this.authorize.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
find(req, res) {
|
||||||
|
this.scanner.find(req, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetadata(req, res) {
|
||||||
|
var metadata = await this.scanner.fetchMetadata(req.params.id, req.params.trackIndex)
|
||||||
|
res.json(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
authorize(req, res) {
|
||||||
|
if (!req.user) {
|
||||||
|
Logger.error('Invalid user in authorize')
|
||||||
|
return res.sendStatus(401)
|
||||||
|
}
|
||||||
|
res.json({ user: req.user })
|
||||||
|
}
|
||||||
|
|
||||||
|
getAudiobooks(req, res) {
|
||||||
|
Logger.info('Get Audiobooks')
|
||||||
|
var audiobooksMinified = this.db.audiobooks.map(ab => ab.toJSONMinified())
|
||||||
|
res.json(audiobooksMinified)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAudiobook(req, res) {
|
||||||
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||||
|
if (!audiobook) return res.sendStatus(404)
|
||||||
|
res.json(audiobook.toJSONExpanded())
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAudiobook(req, res) {
|
||||||
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||||
|
if (!audiobook) return res.sendStatus(404)
|
||||||
|
|
||||||
|
// Remove audiobook from users
|
||||||
|
for (let i = 0; i < this.db.users.length; i++) {
|
||||||
|
var user = this.db.users[i]
|
||||||
|
var madeUpdates = user.resetAudiobookProgress(audiobook.id)
|
||||||
|
if (madeUpdates) {
|
||||||
|
await this.db.updateEntity('user', user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove any streams open for this audiobook
|
||||||
|
var streams = this.streamManager.streams.filter(stream => stream.audiobookId === audiobook.id)
|
||||||
|
for (let i = 0; i < streams.length; i++) {
|
||||||
|
var stream = streams[i]
|
||||||
|
var client = stream.client
|
||||||
|
await stream.close()
|
||||||
|
if (client && client.user) {
|
||||||
|
client.user.stream = null
|
||||||
|
client.stream = null
|
||||||
|
this.db.updateUserStream(client.user.id, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.removeEntity('audiobook', audiobook.id)
|
||||||
|
|
||||||
|
this.emitter('audiobook_removed', audiobook.toJSONMinified())
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAudiobookTracks(req, res) {
|
||||||
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||||
|
if (!audiobook) return res.sendStatus(404)
|
||||||
|
var files = req.body.files
|
||||||
|
Logger.info(`Updating audiobook tracks called ${audiobook.id}`)
|
||||||
|
audiobook.updateAudioTracks(files)
|
||||||
|
await this.db.updateAudiobook(audiobook)
|
||||||
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||||
|
res.json(audiobook.toJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAudiobook(req, res) {
|
||||||
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||||
|
if (!audiobook) return res.sendStatus(404)
|
||||||
|
var hasUpdates = audiobook.update(req.body)
|
||||||
|
if (hasUpdates) {
|
||||||
|
await this.db.updateAudiobook(audiobook)
|
||||||
|
}
|
||||||
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||||
|
res.json(audiobook.toJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
async match(req, res) {
|
||||||
|
var body = req.body
|
||||||
|
var audiobookId = req.params.id
|
||||||
|
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||||
|
var bookData = {
|
||||||
|
olid: body.id,
|
||||||
|
publish_year: body.first_publish_year,
|
||||||
|
description: body.description,
|
||||||
|
title: body.title,
|
||||||
|
author: body.author,
|
||||||
|
cover: body.cover
|
||||||
|
}
|
||||||
|
audiobook.setBook(bookData)
|
||||||
|
await this.db.updateAudiobook(audiobook)
|
||||||
|
|
||||||
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||||
|
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetUserAudiobookProgress(req, res) {
|
||||||
|
req.user.resetAudiobookProgress(req.params.id)
|
||||||
|
await this.db.updateEntity('user', req.user)
|
||||||
|
this.emitter('user_updated', req.user.toJSONForBrowser())
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = ApiController
|
96
server/AudioTrack.js
Normal file
96
server/AudioTrack.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
var { bytesPretty } = require('./utils/fileUtils')
|
||||||
|
|
||||||
|
class AudioTrack {
|
||||||
|
constructor(audioTrack = null) {
|
||||||
|
this.index = null
|
||||||
|
this.path = null
|
||||||
|
this.fullPath = null
|
||||||
|
this.ext = null
|
||||||
|
this.filename = null
|
||||||
|
|
||||||
|
this.format = null
|
||||||
|
this.duration = null
|
||||||
|
this.size = null
|
||||||
|
this.bitRate = null
|
||||||
|
this.language = null
|
||||||
|
this.codec = null
|
||||||
|
this.timeBase = null
|
||||||
|
this.channels = null
|
||||||
|
this.channelLayout = null
|
||||||
|
|
||||||
|
this.tagAlbum = null
|
||||||
|
this.tagArtist = null
|
||||||
|
this.tagGenre = null
|
||||||
|
this.tagTitle = null
|
||||||
|
this.tagTrack = null
|
||||||
|
|
||||||
|
if (audioTrack) {
|
||||||
|
this.construct(audioTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
construct(audioTrack) {
|
||||||
|
this.index = audioTrack.index
|
||||||
|
this.path = audioTrack.path
|
||||||
|
this.fullPath = audioTrack.fullPath
|
||||||
|
this.ext = audioTrack.ext
|
||||||
|
this.filename = audioTrack.filename
|
||||||
|
|
||||||
|
this.format = audioTrack.format
|
||||||
|
this.duration = audioTrack.duration
|
||||||
|
this.size = audioTrack.size
|
||||||
|
this.bitRate = audioTrack.bitRate
|
||||||
|
this.language = audioTrack.language
|
||||||
|
this.codec = audioTrack.codec
|
||||||
|
this.timeBase = audioTrack.timeBase
|
||||||
|
this.channels = audioTrack.channels
|
||||||
|
this.channelLayout = audioTrack.channelLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return `${String(this.index).padStart(3, '0')}: ${this.filename} (${bytesPretty(this.size)}) [${this.duration}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
index: this.index,
|
||||||
|
path: this.path,
|
||||||
|
fullPath: this.fullPath,
|
||||||
|
ext: this.ext,
|
||||||
|
filename: this.filename,
|
||||||
|
format: this.format,
|
||||||
|
duration: this.duration,
|
||||||
|
size: this.size,
|
||||||
|
bitRate: this.bitRate,
|
||||||
|
language: this.language,
|
||||||
|
timeBase: this.timeBase,
|
||||||
|
channels: this.channels,
|
||||||
|
channelLayout: this.channelLayout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(probeData) {
|
||||||
|
this.index = probeData.index
|
||||||
|
this.path = probeData.path
|
||||||
|
this.fullPath = probeData.fullPath
|
||||||
|
this.ext = probeData.ext
|
||||||
|
this.filename = probeData.filename
|
||||||
|
|
||||||
|
this.format = probeData.format
|
||||||
|
this.duration = probeData.duration
|
||||||
|
this.size = probeData.size
|
||||||
|
this.bitRate = probeData.bit_rate
|
||||||
|
this.language = probeData.language
|
||||||
|
this.codec = probeData.codec
|
||||||
|
this.timeBase = probeData.time_base
|
||||||
|
this.channels = probeData.channels
|
||||||
|
this.channelLayout = probeData.channel_layout
|
||||||
|
|
||||||
|
this.tagAlbum = probeData.file_tag_album || null
|
||||||
|
this.tagArtist = probeData.file_tag_artist || null
|
||||||
|
this.tagGenre = probeData.file_tag_genre || null
|
||||||
|
this.tagTitle = probeData.file_tag_title || null
|
||||||
|
this.tagTrack = probeData.file_tag_track || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = AudioTrack
|
212
server/Audiobook.js
Normal file
212
server/Audiobook.js
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
const { bytesPretty, elapsedPretty } = require('./utils/fileUtils')
|
||||||
|
const Book = require('./Book')
|
||||||
|
const AudioTrack = require('./AudioTrack')
|
||||||
|
|
||||||
|
class Audiobook {
|
||||||
|
constructor(audiobook = null) {
|
||||||
|
this.id = null
|
||||||
|
this.path = null
|
||||||
|
this.fullPath = null
|
||||||
|
this.addedAt = null
|
||||||
|
|
||||||
|
this.tracks = []
|
||||||
|
this.missingParts = []
|
||||||
|
this.invalidParts = []
|
||||||
|
|
||||||
|
this.audioFiles = []
|
||||||
|
this.ebookFiles = []
|
||||||
|
this.otherFiles = []
|
||||||
|
|
||||||
|
this.tags = []
|
||||||
|
this.book = null
|
||||||
|
|
||||||
|
if (audiobook) {
|
||||||
|
this.construct(audiobook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
construct(audiobook) {
|
||||||
|
this.id = audiobook.id
|
||||||
|
this.path = audiobook.path
|
||||||
|
this.fullPath = audiobook.fullPath
|
||||||
|
this.addedAt = audiobook.addedAt
|
||||||
|
|
||||||
|
this.tracks = audiobook.tracks.map(track => {
|
||||||
|
return new AudioTrack(track)
|
||||||
|
})
|
||||||
|
this.missingParts = audiobook.missingParts
|
||||||
|
this.invalidParts = audiobook.invalidParts
|
||||||
|
|
||||||
|
this.audioFiles = audiobook.audioFiles
|
||||||
|
this.ebookFiles = audiobook.ebookFiles
|
||||||
|
this.otherFiles = audiobook.otherFiles
|
||||||
|
|
||||||
|
this.tags = audiobook.tags
|
||||||
|
if (audiobook.book) {
|
||||||
|
this.book = new Book(audiobook.book)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get title() {
|
||||||
|
return this.book ? this.book.title : 'No Title'
|
||||||
|
}
|
||||||
|
|
||||||
|
get cover() {
|
||||||
|
return this.book ? this.book.cover : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
get author() {
|
||||||
|
return this.book ? this.book.author : 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalDuration() {
|
||||||
|
var total = 0
|
||||||
|
this.tracks.forEach((track) => total += track.duration)
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalSize() {
|
||||||
|
var total = 0
|
||||||
|
this.tracks.forEach((track) => total += track.size)
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
get sizePretty() {
|
||||||
|
return bytesPretty(this.totalSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
get durationPretty() {
|
||||||
|
return elapsedPretty(this.totalDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
bookToJSON() {
|
||||||
|
return this.book ? this.book.toJSON() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
tracksToJSON() {
|
||||||
|
if (!this.tracks || !this.tracks.length) return []
|
||||||
|
return this.tracks.map(t => t.toJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
title: this.title,
|
||||||
|
author: this.author,
|
||||||
|
cover: this.cover,
|
||||||
|
path: this.path,
|
||||||
|
fullPath: this.fullPath,
|
||||||
|
addedAt: this.addedAt,
|
||||||
|
missingParts: this.missingParts,
|
||||||
|
invalidParts: this.invalidParts,
|
||||||
|
tags: this.tags,
|
||||||
|
book: this.bookToJSON(),
|
||||||
|
tracks: this.tracksToJSON(),
|
||||||
|
audioFiles: this.audioFiles,
|
||||||
|
ebookFiles: this.ebookFiles,
|
||||||
|
otherFiles: this.otherFiles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSONMinified() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
book: this.bookToJSON(),
|
||||||
|
tags: this.tags,
|
||||||
|
path: this.path,
|
||||||
|
fullPath: this.fullPath,
|
||||||
|
addedAt: this.addedAt,
|
||||||
|
duration: this.totalDuration,
|
||||||
|
size: this.totalSize,
|
||||||
|
hasBookMatch: !!this.book,
|
||||||
|
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||||
|
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
||||||
|
numTracks: this.tracks.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSONExpanded() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
title: this.title,
|
||||||
|
author: this.author,
|
||||||
|
cover: this.cover,
|
||||||
|
path: this.path,
|
||||||
|
fullPath: this.fullPath,
|
||||||
|
addedAt: this.addedAt,
|
||||||
|
duration: this.totalDuration,
|
||||||
|
durationPretty: this.durationPretty,
|
||||||
|
size: this.totalSize,
|
||||||
|
sizePretty: this.sizePretty,
|
||||||
|
missingParts: this.missingParts,
|
||||||
|
invalidParts: this.invalidParts,
|
||||||
|
audioFiles: this.audioFiles,
|
||||||
|
ebookFiles: this.ebookFiles,
|
||||||
|
otherFiles: this.otherFiles,
|
||||||
|
tags: this.tags,
|
||||||
|
book: this.bookToJSON(),
|
||||||
|
tracks: this.tracksToJSON()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(data) {
|
||||||
|
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||||
|
this.path = data.path
|
||||||
|
this.fullPath = data.fullPath
|
||||||
|
this.addedAt = Date.now()
|
||||||
|
|
||||||
|
this.otherFiles = data.otherFiles || []
|
||||||
|
this.ebookFiles = data.ebooks || []
|
||||||
|
this.setBook(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
setBook(data) {
|
||||||
|
this.book = new Book()
|
||||||
|
this.book.setData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
addTrack(trackData) {
|
||||||
|
var track = new AudioTrack()
|
||||||
|
track.setData(trackData)
|
||||||
|
this.tracks.push(track)
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
|
||||||
|
update(payload) {
|
||||||
|
var hasUpdates = false
|
||||||
|
|
||||||
|
if (payload.tags && payload.tags.join(',') !== this.tags.join(',')) {
|
||||||
|
this.tags = payload.tags
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.book) {
|
||||||
|
if (!this.book) {
|
||||||
|
this.setBook(payload.book)
|
||||||
|
hasUpdates = true
|
||||||
|
} else if (this.book.update(payload.book)) {
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasUpdates
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAudioTracks(files) {
|
||||||
|
var index = 1
|
||||||
|
this.audioFiles = files.map((file) => {
|
||||||
|
file.manuallyVerified = true
|
||||||
|
file.invalid = false
|
||||||
|
file.error = null
|
||||||
|
file.index = index++
|
||||||
|
return file
|
||||||
|
})
|
||||||
|
this.tracks = []
|
||||||
|
this.invalidParts = []
|
||||||
|
this.missingParts = []
|
||||||
|
this.audioFiles.forEach((file) => {
|
||||||
|
this.addTrack(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = Audiobook
|
192
server/Auth.js
Normal file
192
server/Auth.js
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
const bcrypt = require('bcryptjs')
|
||||||
|
const jwt = require('jsonwebtoken')
|
||||||
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
|
|
||||||
|
class Auth {
|
||||||
|
constructor(db) {
|
||||||
|
this.db = db
|
||||||
|
|
||||||
|
this.user = null
|
||||||
|
}
|
||||||
|
|
||||||
|
get username() {
|
||||||
|
return this.user ? this.user.username : 'nobody'
|
||||||
|
}
|
||||||
|
|
||||||
|
get users() {
|
||||||
|
return this.db.users
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
var root = this.users.find(u => u.type === 'root')
|
||||||
|
if (!root) {
|
||||||
|
Logger.fatal('No Root User', this.users)
|
||||||
|
throw new Error('No Root User')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cors(req, res, next) {
|
||||||
|
res.header('Access-Control-Allow-Origin', '*')
|
||||||
|
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
||||||
|
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
|
||||||
|
res.header('Access-Control-Allow-Credentials', true)
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.sendStatus(200)
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async authMiddleware(req, res, next) {
|
||||||
|
const authHeader = req.headers['authorization']
|
||||||
|
const token = authHeader && authHeader.split(' ')[1]
|
||||||
|
if (token == null) {
|
||||||
|
Logger.error('Api called without a token')
|
||||||
|
return res.sendStatus(401)
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await this.verifyToken(token)
|
||||||
|
if (!user) {
|
||||||
|
Logger.error('Verify Token User Not Found', token)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
req.user = user
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
hashPass(password) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
bcrypt.hash(password, 8, (err, hash) => {
|
||||||
|
if (err) {
|
||||||
|
Logger.error('Hash failed', err)
|
||||||
|
resolve(null)
|
||||||
|
} else {
|
||||||
|
resolve(hash)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuth(req) {
|
||||||
|
if (req.signedCookies.user) {
|
||||||
|
var user = this.users.find(u => u.username = req.signedCookies.user)
|
||||||
|
if (user) {
|
||||||
|
delete user.pash
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateAccessToken(payload) {
|
||||||
|
return jwt.sign(payload, process.env.TOKEN_SECRET, { expiresIn: '1800s' });
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyToken(token) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => {
|
||||||
|
var user = this.users.find(u => u.id === payload.userId)
|
||||||
|
resolve(user || null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(req, res) {
|
||||||
|
var username = req.body.username
|
||||||
|
var password = req.body.password || ''
|
||||||
|
Logger.debug('Check Auth', username, !!password)
|
||||||
|
|
||||||
|
var user = this.users.find(u => u.id === username)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.json({ error: 'User not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check passwordless root user
|
||||||
|
if (user.id === 'root' && (!user.pash || user.pash === '')) {
|
||||||
|
if (password) {
|
||||||
|
return res.json({ error: 'Invalid root password (hint: there is none)' })
|
||||||
|
} else {
|
||||||
|
return res.json({ user: user.toJSONForBrowser() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password match
|
||||||
|
var compare = await bcrypt.compare(password, user.pash)
|
||||||
|
if (compare) {
|
||||||
|
res.json({
|
||||||
|
user: user.toJSONForBrowser()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
error: 'Invalid Password'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAuth(req, res) {
|
||||||
|
var username = req.body.username
|
||||||
|
Logger.debug('Check Auth', username, !!req.body.password)
|
||||||
|
|
||||||
|
var matchingUser = this.users.find(u => u.username === username)
|
||||||
|
if (!matchingUser) {
|
||||||
|
return res.json({
|
||||||
|
error: 'User not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleanedUser = { ...matchingUser }
|
||||||
|
delete cleanedUser.pash
|
||||||
|
|
||||||
|
// check for empty password (default)
|
||||||
|
if (!req.body.password) {
|
||||||
|
if (!matchingUser.pash) {
|
||||||
|
res.cookie('user', username, { signed: true })
|
||||||
|
return res.json({
|
||||||
|
user: cleanedUser
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return res.json({
|
||||||
|
error: 'Invalid Password'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set root password first time
|
||||||
|
if (matchingUser.type === 'root' && !matchingUser.pash && req.body.password && req.body.password.length > 1) {
|
||||||
|
console.log('Set root pash')
|
||||||
|
var pw = await this.hashPass(req.body.password)
|
||||||
|
if (!pw) {
|
||||||
|
return res.json({
|
||||||
|
error: 'Hash failed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.users = this.users.map(u => {
|
||||||
|
if (u.username === matchingUser.username) {
|
||||||
|
u.pash = pw
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
})
|
||||||
|
await this.saveAuthDb()
|
||||||
|
return res.json({
|
||||||
|
setroot: true,
|
||||||
|
user: cleanedUser
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var compare = await bcrypt.compare(req.body.password, matchingUser.pash)
|
||||||
|
if (compare) {
|
||||||
|
res.cookie('user', username, { signed: true })
|
||||||
|
res.json({
|
||||||
|
user: cleanedUser
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
error: 'Invalid Password'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = Auth
|
72
server/Book.js
Normal file
72
server/Book.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
class Book {
|
||||||
|
constructor(book = null) {
|
||||||
|
this.olid = null
|
||||||
|
this.title = null
|
||||||
|
this.author = null
|
||||||
|
this.publishYear = null
|
||||||
|
this.publisher = null
|
||||||
|
this.description = null
|
||||||
|
this.cover = null
|
||||||
|
this.genres = []
|
||||||
|
|
||||||
|
if (book) {
|
||||||
|
this.construct(book)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
construct(book) {
|
||||||
|
this.olid = book.olid
|
||||||
|
this.title = book.title
|
||||||
|
this.author = book.author
|
||||||
|
this.publishYear = book.publish_year
|
||||||
|
this.publisher = book.publisher
|
||||||
|
this.description = book.description
|
||||||
|
this.cover = book.cover
|
||||||
|
this.genres = book.genres
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
olid: this.olid,
|
||||||
|
title: this.title,
|
||||||
|
author: this.author,
|
||||||
|
publishYear: this.publish_year,
|
||||||
|
publisher: this.publisher,
|
||||||
|
description: this.description,
|
||||||
|
cover: this.cover,
|
||||||
|
genres: this.genres
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(data) {
|
||||||
|
this.olid = data.olid || null
|
||||||
|
this.title = data.title || null
|
||||||
|
this.author = data.author || null
|
||||||
|
this.publishYear = data.publish_year || null
|
||||||
|
this.description = data.description || null
|
||||||
|
this.cover = data.cover || null
|
||||||
|
this.genres = data.genres || []
|
||||||
|
}
|
||||||
|
|
||||||
|
update(payload) {
|
||||||
|
var hasUpdates = false
|
||||||
|
for (const key in payload) {
|
||||||
|
if (payload[key] === undefined) continue;
|
||||||
|
|
||||||
|
if (key === 'genres') {
|
||||||
|
if (payload['genres'] === null && this.genres !== null) {
|
||||||
|
this.genres = []
|
||||||
|
hasUpdates = true
|
||||||
|
} else if (payload['genres'].join(',') !== this.genres.join(',')) {
|
||||||
|
this.genres = payload['genres']
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
} else if (this[key] !== undefined && payload[key] !== this[key]) {
|
||||||
|
this[key] = payload[key]
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = Book
|
33
server/BookFinder.js
Normal file
33
server/BookFinder.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
const OpenLibrary = require('./providers/OpenLibrary')
|
||||||
|
const LibGen = require('./providers/LibGen')
|
||||||
|
|
||||||
|
class BookFinder {
|
||||||
|
constructor() {
|
||||||
|
this.openLibrary = new OpenLibrary()
|
||||||
|
this.libGen = new LibGen()
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByISBN(isbn) {
|
||||||
|
var book = await this.openLibrary.isbnLookup(isbn)
|
||||||
|
if (book.errorCode) {
|
||||||
|
console.error('Book not found')
|
||||||
|
}
|
||||||
|
return book
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query, provider = 'openlibrary') {
|
||||||
|
var books = null
|
||||||
|
|
||||||
|
if (provider === 'libgen') {
|
||||||
|
books = await this.libGen.search(query)
|
||||||
|
return books
|
||||||
|
}
|
||||||
|
|
||||||
|
books = await this.openLibrary.search(query)
|
||||||
|
if (books.errorCode) {
|
||||||
|
console.error('Books not found')
|
||||||
|
}
|
||||||
|
return books
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = BookFinder
|
158
server/Db.js
Normal file
158
server/Db.js
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
const fs = require('fs-extra')
|
||||||
|
const Path = require('path')
|
||||||
|
const njodb = require("njodb")
|
||||||
|
const jwt = require('jsonwebtoken')
|
||||||
|
const Logger = require('./Logger')
|
||||||
|
const Audiobook = require('./Audiobook')
|
||||||
|
const User = require('./User')
|
||||||
|
|
||||||
|
class Db {
|
||||||
|
constructor(CONFIG_PATH) {
|
||||||
|
this.ConfigPath = CONFIG_PATH
|
||||||
|
this.AudiobooksPath = Path.join(CONFIG_PATH, 'audiobooks')
|
||||||
|
this.UsersPath = Path.join(CONFIG_PATH, 'users')
|
||||||
|
this.SettingsPath = Path.join(CONFIG_PATH, 'settings')
|
||||||
|
|
||||||
|
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
||||||
|
this.usersDb = new njodb.Database(this.UsersPath)
|
||||||
|
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
|
||||||
|
|
||||||
|
this.users = []
|
||||||
|
this.audiobooks = []
|
||||||
|
this.settings = []
|
||||||
|
}
|
||||||
|
|
||||||
|
getEntityDb(entityName) {
|
||||||
|
if (entityName === 'user') return this.usersDb
|
||||||
|
else if (entityName === 'audiobook') return this.audiobooksDb
|
||||||
|
return this.settingsDb
|
||||||
|
}
|
||||||
|
|
||||||
|
getEntityArrayKey(entityName) {
|
||||||
|
if (entityName === 'user') return 'users'
|
||||||
|
else if (entityName === 'audiobook') return 'audiobooks'
|
||||||
|
return 'settings'
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultSettings() {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
version: 1,
|
||||||
|
cardSize: 'md'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultUser(token) {
|
||||||
|
return new User({
|
||||||
|
id: 'root',
|
||||||
|
type: 'root',
|
||||||
|
|
||||||
|
username: 'root',
|
||||||
|
pash: '',
|
||||||
|
stream: null,
|
||||||
|
token,
|
||||||
|
createdAt: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.load()
|
||||||
|
|
||||||
|
// Insert Defaults
|
||||||
|
if (!this.users.find(u => u.type === 'root')) {
|
||||||
|
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
||||||
|
Logger.debug('Generated default token', token)
|
||||||
|
await this.insertUser(this.getDefaultUser(token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
var p1 = this.audiobooksDb.select(() => true).then((results) => {
|
||||||
|
this.audiobooks = results.data.map(a => new Audiobook(a))
|
||||||
|
Logger.info(`Audiobooks Loaded ${this.audiobooks.length}`)
|
||||||
|
})
|
||||||
|
var p2 = this.usersDb.select(() => true).then((results) => {
|
||||||
|
this.users = results.data.map(u => new User(u))
|
||||||
|
Logger.info(`Users Loaded ${this.users.length}`)
|
||||||
|
})
|
||||||
|
var p3 = this.settingsDb.select(() => true).then((results) => {
|
||||||
|
this.settings = results
|
||||||
|
})
|
||||||
|
await Promise.all([p1, p2, p3])
|
||||||
|
}
|
||||||
|
|
||||||
|
insertAudiobook(audiobook) {
|
||||||
|
return this.insertAudiobooks([audiobook])
|
||||||
|
}
|
||||||
|
|
||||||
|
insertAudiobooks(audiobooks) {
|
||||||
|
return this.audiobooksDb.insert(audiobooks).then((results) => {
|
||||||
|
Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
|
||||||
|
this.audiobooks = this.audiobooks.concat(audiobooks)
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[DB] Insert audiobooks Failed ${error}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAudiobook(audiobook) {
|
||||||
|
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
|
||||||
|
Logger.debug(`[DB] Audiobook updated ${results.updated}`)
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[DB] Audiobook update failed ${error}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
insertUser(user) {
|
||||||
|
return this.usersDb.insert([user]).then((results) => {
|
||||||
|
Logger.debug(`[DB] Inserted user ${results.inserted}`)
|
||||||
|
this.users.push(user)
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[DB] Insert user Failed ${error}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUserStream(userId, streamId) {
|
||||||
|
return this.usersDb.update((record) => record.id === userId, (user) => {
|
||||||
|
user.stream = streamId
|
||||||
|
return user
|
||||||
|
}).then((results) => {
|
||||||
|
Logger.debug(`[DB] Updated user ${results.updated}`)
|
||||||
|
this.users = this.users.map(u => {
|
||||||
|
if (u.id === userId) {
|
||||||
|
u.stream = streamId
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
})
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[DB] Update user Failed ${error}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEntity(entityName, entity) {
|
||||||
|
var entityDb = this.getEntityDb(entityName)
|
||||||
|
return entityDb.update((record) => record.id === entity.id, () => entity).then((results) => {
|
||||||
|
Logger.debug(`[DB] Updated entity ${entityName}: ${results.updated}`)
|
||||||
|
var arrayKey = this.getEntityArrayKey(entityName)
|
||||||
|
this[arrayKey] = this[arrayKey].map(e => {
|
||||||
|
return e.id === entity.id ? entity : e
|
||||||
|
})
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[DB] Update entity ${entityName} Failed: ${error}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEntity(entityName, entityId) {
|
||||||
|
var entityDb = this.getEntityDb(entityName)
|
||||||
|
return entityDb.delete((record) => record.id === entityId).then((results) => {
|
||||||
|
Logger.debug(`[DB] Deleted entity ${entityName}: ${results.deleted}`)
|
||||||
|
var arrayKey = this.getEntityArrayKey(entityName)
|
||||||
|
this[arrayKey] = this[arrayKey].filter(e => {
|
||||||
|
return e.id !== entityId
|
||||||
|
})
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[DB] Remove entity ${entityName} Failed: ${error}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = Db
|
90
server/HlsController.js
Normal file
90
server/HlsController.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const Path = require('path')
|
||||||
|
const fs = require('fs-extra')
|
||||||
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
|
class HlsController {
|
||||||
|
constructor(db, scanner, auth, streamManager, emitter, MetadataPath) {
|
||||||
|
this.db = db
|
||||||
|
this.scanner = scanner
|
||||||
|
this.auth = auth
|
||||||
|
this.streamManager = streamManager
|
||||||
|
this.emitter = emitter
|
||||||
|
this.MetadataPath = MetadataPath
|
||||||
|
|
||||||
|
this.router = express()
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.router.get('/:stream/:file', this.streamFileRequest.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
parseSegmentFilename(filename) {
|
||||||
|
var basename = Path.basename(filename, '.ts')
|
||||||
|
var num_part = basename.split('-')[1]
|
||||||
|
return Number(num_part)
|
||||||
|
}
|
||||||
|
|
||||||
|
async streamFileRequest(req, res) {
|
||||||
|
var streamId = req.params.stream
|
||||||
|
|
||||||
|
// Logger.info('Got hls request', streamId, req.params.file)
|
||||||
|
|
||||||
|
var fullFilePath = Path.join(this.MetadataPath, streamId, req.params.file)
|
||||||
|
|
||||||
|
var exists = await fs.pathExists(fullFilePath)
|
||||||
|
if (!exists) {
|
||||||
|
Logger.error('File path does not exist', fullFilePath)
|
||||||
|
|
||||||
|
var fileExt = Path.extname(req.params.file)
|
||||||
|
if (fileExt === '.ts') {
|
||||||
|
var segNum = this.parseSegmentFilename(req.params.file)
|
||||||
|
var stream = this.streamManager.getStream(streamId)
|
||||||
|
if (!stream) {
|
||||||
|
Logger.error(`[HLS-CONTROLLER] Stream ${streamId} does not exist`)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream.isResetting) {
|
||||||
|
Logger.info(`[HLS-CONTROLLER] Stream ${streamId} is currently resetting`)
|
||||||
|
return res.sendStatus(404)
|
||||||
|
} else {
|
||||||
|
var startTimeForReset = await stream.checkSegmentNumberRequest(segNum)
|
||||||
|
if (startTimeForReset) {
|
||||||
|
// HLS.js should request the file again]
|
||||||
|
Logger.info(`[HLS-CONTROLLER] Resetting Stream - notify client @${startTimeForReset}s`)
|
||||||
|
this.emitter('stream_reset', {
|
||||||
|
startTime: startTimeForReset,
|
||||||
|
streamId: stream.id
|
||||||
|
})
|
||||||
|
return res.sendStatus(500)
|
||||||
|
// await new Promise((resolve) => {
|
||||||
|
// setTimeout(() => {
|
||||||
|
// console.log('Waited 4 seconds')
|
||||||
|
// resolve()
|
||||||
|
// }, 4000)
|
||||||
|
// })
|
||||||
|
|
||||||
|
// exists = await fs.pathExists(fullFilePath)
|
||||||
|
// if (!exists) {
|
||||||
|
// console.error('Still does not exist')
|
||||||
|
// return res.sendStatus(404)
|
||||||
|
// }
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
// exists = await fs.pathExists(fullFilePath)
|
||||||
|
// Logger.info('Waited', exists)
|
||||||
|
// if (!exists) {
|
||||||
|
// Logger.error('still does not exist', fullFilePath)
|
||||||
|
// return res.sendStatus(404)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
// Logger.info('Sending file', fullFilePath)
|
||||||
|
res.sendFile(fullFilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = HlsController
|
50
server/Logger.js
Normal file
50
server/Logger.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
const LOG_LEVEL = {
|
||||||
|
TRACE: 0,
|
||||||
|
DEBUG: 1,
|
||||||
|
INFO: 2,
|
||||||
|
WARN: 3,
|
||||||
|
ERROR: 4,
|
||||||
|
FATAL: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
constructor() {
|
||||||
|
let env_log_level = process.env.LOG_LEVEL || 'TRACE'
|
||||||
|
this.LogLevel = LOG_LEVEL[env_log_level] || LOG_LEVEL.TRACE
|
||||||
|
this.info(`Log Level: ${this.LogLevel}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
get timestamp() {
|
||||||
|
return (new Date()).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
trace(...args) {
|
||||||
|
if (this.LogLevel > LOG_LEVEL.TRACE) return
|
||||||
|
console.trace(`[${this.timestamp}] TRACE:`, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(...args) {
|
||||||
|
if (this.LogLevel > LOG_LEVEL.DEBUG) return
|
||||||
|
console.debug(`[${this.timestamp}] DEBUG:`, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
info(...args) {
|
||||||
|
if (this.LogLevel > LOG_LEVEL.INFO) return
|
||||||
|
console.info(`[${this.timestamp}] INFO:`, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(...args) {
|
||||||
|
if (this.LogLevel > LOG_LEVEL.WARN) return
|
||||||
|
console.warn(`[${this.timestamp}] WARN:`, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
error(...args) {
|
||||||
|
if (this.LogLevel > LOG_LEVEL.ERROR) return
|
||||||
|
console.error(`[${this.timestamp}] ERROR:`, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fatal(...args) {
|
||||||
|
console.error(`[${this.timestamp}] FATAL:`, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = new Logger()
|
90
server/Scanner.js
Normal file
90
server/Scanner.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
const Logger = require('./Logger')
|
||||||
|
const BookFinder = require('./BookFinder')
|
||||||
|
const Audiobook = require('./Audiobook')
|
||||||
|
const audioFileScanner = require('./utils/audioFileScanner')
|
||||||
|
const { getAllAudiobookFiles } = require('./utils/scandir')
|
||||||
|
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||||
|
|
||||||
|
class Scanner {
|
||||||
|
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
|
||||||
|
this.AudiobookPath = AUDIOBOOK_PATH
|
||||||
|
this.MetadataPath = METADATA_PATH
|
||||||
|
this.db = db
|
||||||
|
this.emitter = emitter
|
||||||
|
|
||||||
|
this.bookFinder = new BookFinder()
|
||||||
|
}
|
||||||
|
|
||||||
|
get audiobooks() {
|
||||||
|
return this.db.audiobooks
|
||||||
|
}
|
||||||
|
|
||||||
|
async scan() {
|
||||||
|
// console.log('Start scan audiobooks', this.audiobooks.map(a => a.fullPath).join(', '))
|
||||||
|
const scanStart = Date.now()
|
||||||
|
var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath)
|
||||||
|
for (let i = 0; i < audiobookDataFound.length; i++) {
|
||||||
|
var audiobookData = audiobookDataFound[i]
|
||||||
|
if (!audiobookData.parts.length) {
|
||||||
|
Logger.error('No Valid Parts for Audiobook', audiobookData)
|
||||||
|
} else {
|
||||||
|
var existingAudiobook = this.audiobooks.find(a => a.fullPath === audiobookData.fullPath)
|
||||||
|
if (existingAudiobook) {
|
||||||
|
Logger.info('Audiobook already added', audiobookData.title)
|
||||||
|
// Todo: Update Audiobook here
|
||||||
|
} else {
|
||||||
|
// console.log('Audiobook not already there... add new audiobook', audiobookData.fullPath)
|
||||||
|
var audiobook = new Audiobook()
|
||||||
|
audiobook.setData(audiobookData)
|
||||||
|
await audioFileScanner.scanParts(audiobook, audiobookData.parts)
|
||||||
|
if (!audiobook.tracks.length) {
|
||||||
|
Logger.warn('Invalid audiobook, no valid tracks', audiobook.title)
|
||||||
|
} else {
|
||||||
|
Logger.info('Audiobook Scanned', audiobook.title, `(${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
||||||
|
await this.db.insertAudiobook(audiobook)
|
||||||
|
this.emitter('audiobook_added', audiobook.toJSONMinified())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
|
||||||
|
this.emitter('scan_progress', {
|
||||||
|
total: audiobookDataFound.length,
|
||||||
|
done: i + 1,
|
||||||
|
progress
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
|
||||||
|
Logger.info(`[SCANNER] Finished ${secondsToTimestamp(scanElapsed)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMetadata(id, trackIndex = 0) {
|
||||||
|
var audiobook = this.audiobooks.find(a => a.id === id)
|
||||||
|
if (!audiobook) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var tracks = audiobook.tracks
|
||||||
|
var index = isNaN(trackIndex) ? 0 : Number(trackIndex)
|
||||||
|
var firstTrack = tracks[index]
|
||||||
|
var firstTrackFullPath = firstTrack.fullPath
|
||||||
|
var scanResult = await audioFileScanner.scan(firstTrackFullPath)
|
||||||
|
return scanResult
|
||||||
|
}
|
||||||
|
|
||||||
|
async find(req, res) {
|
||||||
|
var method = req.params.method
|
||||||
|
var query = req.query
|
||||||
|
|
||||||
|
var result = null
|
||||||
|
|
||||||
|
if (method === 'isbn') {
|
||||||
|
console.log('Search', query, 'via ISBN')
|
||||||
|
result = await this.bookFinder.findByISBN(query)
|
||||||
|
} else if (method === 'search') {
|
||||||
|
console.log('Search', query, 'via query')
|
||||||
|
result = await this.bookFinder.search(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = Scanner
|
241
server/Server.js
Normal file
241
server/Server.js
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
const Path = require('path')
|
||||||
|
const express = require('express')
|
||||||
|
const http = require('http')
|
||||||
|
const SocketIO = require('socket.io')
|
||||||
|
const fs = require('fs-extra')
|
||||||
|
const cookieparser = require('cookie-parser')
|
||||||
|
|
||||||
|
const Auth = require('./Auth')
|
||||||
|
const Watcher = require('./Watcher')
|
||||||
|
const Scanner = require('./Scanner')
|
||||||
|
const Db = require('./Db')
|
||||||
|
const ApiController = require('./ApiController')
|
||||||
|
const HlsController = require('./HlsController')
|
||||||
|
const StreamManager = require('./StreamManager')
|
||||||
|
const Logger = require('./Logger')
|
||||||
|
const streamTest = require('./streamTest')
|
||||||
|
|
||||||
|
class Server {
|
||||||
|
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||||
|
this.Port = PORT
|
||||||
|
this.Host = '0.0.0.0'
|
||||||
|
this.ConfigPath = CONFIG_PATH
|
||||||
|
this.AudiobookPath = AUDIOBOOK_PATH
|
||||||
|
this.MetadataPath = METADATA_PATH
|
||||||
|
|
||||||
|
fs.ensureDirSync(CONFIG_PATH)
|
||||||
|
fs.ensureDirSync(METADATA_PATH)
|
||||||
|
fs.ensureDirSync(AUDIOBOOK_PATH)
|
||||||
|
|
||||||
|
this.db = new Db(this.ConfigPath)
|
||||||
|
this.auth = new Auth(this.db)
|
||||||
|
this.watcher = new Watcher(this.AudiobookPath)
|
||||||
|
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
|
||||||
|
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
||||||
|
this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this))
|
||||||
|
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.MetadataPath)
|
||||||
|
|
||||||
|
this.server = null
|
||||||
|
this.io = null
|
||||||
|
|
||||||
|
this.clients = {}
|
||||||
|
|
||||||
|
this.isScanning = false
|
||||||
|
this.isInitialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
get audiobooks() {
|
||||||
|
return this.db.audiobooks
|
||||||
|
}
|
||||||
|
get settings() {
|
||||||
|
return this.db.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter(ev, data) {
|
||||||
|
Logger.debug('EMITTER', ev)
|
||||||
|
if (!this.io) {
|
||||||
|
Logger.error('Invalid IO')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.io.emit(ev, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fileAddedUpdated({ path, fullPath }) {
|
||||||
|
Logger.info('[SERVER] FileAddedUpdated', path, fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fileRemoved({ path, fullPath }) { }
|
||||||
|
|
||||||
|
async scan() {
|
||||||
|
Logger.info('[SERVER] Starting Scan')
|
||||||
|
this.isScanning = true
|
||||||
|
this.isInitialized = true
|
||||||
|
this.emitter('scan_start')
|
||||||
|
await this.scanner.scan()
|
||||||
|
this.isScanning = false
|
||||||
|
this.emitter('scan_complete')
|
||||||
|
Logger.info('[SERVER] Scan complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
Logger.info('[SERVER] Init')
|
||||||
|
await this.streamManager.removeOrphanStreams()
|
||||||
|
await this.db.init()
|
||||||
|
this.auth.init()
|
||||||
|
|
||||||
|
this.watcher.initWatcher()
|
||||||
|
this.watcher.on('file_added', this.fileAddedUpdated.bind(this))
|
||||||
|
this.watcher.on('file_removed', this.fileRemoved.bind(this))
|
||||||
|
this.watcher.on('file_updated', this.fileAddedUpdated.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
authMiddleware(req, res, next) {
|
||||||
|
this.auth.authMiddleware(req, res, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
Logger.info('=== Starting Server ===')
|
||||||
|
|
||||||
|
await this.init()
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
this.server = http.createServer(app)
|
||||||
|
|
||||||
|
app.use(cookieparser('secret_family_recipe'))
|
||||||
|
app.use(this.auth.cors)
|
||||||
|
|
||||||
|
// Static path to generated nuxt
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||||
|
app.use(express.static(distPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(express.static(this.AudiobookPath))
|
||||||
|
app.use(express.static(this.MetadataPath))
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
|
||||||
|
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.sendFile('/index.html')
|
||||||
|
})
|
||||||
|
app.get('/test/:id', (req, res) => {
|
||||||
|
var audiobook = this.audiobooks.find(a => a.id === req.params.id)
|
||||||
|
var startTime = !isNaN(req.query.start) ? Number(req.query.start) : 0
|
||||||
|
Logger.info('/test with audiobook', audiobook.title)
|
||||||
|
streamTest.start(audiobook, startTime)
|
||||||
|
res.sendStatus(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/stream', (req, res) => this.streamManager.openStreamRequest(req, res))
|
||||||
|
app.post('/login', (req, res) => this.auth.login(req, res))
|
||||||
|
app.post('/logout', this.logout.bind(this))
|
||||||
|
app.get('/ping', (req, res) => {
|
||||||
|
Logger.info('Recieved ping')
|
||||||
|
res.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
this.server.listen(this.Port, this.Host, () => {
|
||||||
|
Logger.info(`Running on http://${this.Host}:${this.Port}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.io = new SocketIO.Server(this.server, {
|
||||||
|
cors: {
|
||||||
|
origin: '*',
|
||||||
|
methods: ["GET", "POST"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.io.on('connection', (socket) => {
|
||||||
|
this.clients[socket.id] = {
|
||||||
|
id: socket.id,
|
||||||
|
socket,
|
||||||
|
connected_at: Date.now()
|
||||||
|
}
|
||||||
|
socket.sheepClient = this.clients[socket.id]
|
||||||
|
|
||||||
|
Logger.info('[SOCKET] Socket Connected', socket.id)
|
||||||
|
|
||||||
|
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||||
|
socket.on('scan', this.scan.bind(this))
|
||||||
|
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
|
||||||
|
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
|
||||||
|
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
|
||||||
|
socket.on('test', () => {
|
||||||
|
console.log('Test Request from', socket.id)
|
||||||
|
socket.emit('test_received', socket.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
var _client = this.clients[socket.id]
|
||||||
|
if (!_client) {
|
||||||
|
Logger.warn('[SOCKET] Socket disconnect, no client ' + socket.id)
|
||||||
|
} else if (!_client.user) {
|
||||||
|
Logger.info('[SOCKET] Unauth socket disconnected ' + socket.id)
|
||||||
|
delete this.clients[socket.id]
|
||||||
|
} else {
|
||||||
|
const disconnectTime = Date.now() - _client.connected_at
|
||||||
|
Logger.info(`[SOCKET] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`)
|
||||||
|
delete this.clients[socket.id]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(req, res) {
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticateSocket(socket, token) {
|
||||||
|
var user = await this.auth.verifyToken(token)
|
||||||
|
if (!user) {
|
||||||
|
Logger.error('Cannot validate socket - invalid token')
|
||||||
|
return socket.emit('invalid_token')
|
||||||
|
}
|
||||||
|
var client = this.clients[socket.id]
|
||||||
|
client.user = user
|
||||||
|
|
||||||
|
// Check if user has stream open
|
||||||
|
if (client.user.stream) {
|
||||||
|
Logger.info('User has stream open already', client.user.stream)
|
||||||
|
client.stream = this.streamManager.getStream(client.user.stream)
|
||||||
|
if (!client.stream) {
|
||||||
|
Logger.error('Invalid user stream id', client.user.stream)
|
||||||
|
this.streamManager.removeOrphanStreamFiles(client.user.stream)
|
||||||
|
await this.db.updateUserStream(client.user.id, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialPayload = {
|
||||||
|
settings: this.settings,
|
||||||
|
isScanning: this.isScanning,
|
||||||
|
isInitialized: this.isInitialized,
|
||||||
|
audiobookPath: this.AudiobookPath,
|
||||||
|
metadataPath: this.MetadataPath,
|
||||||
|
configPath: this.ConfigPath,
|
||||||
|
user: client.user.toJSONForBrowser(),
|
||||||
|
stream: client.stream || null
|
||||||
|
}
|
||||||
|
client.socket.emit('init', initialPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await this.watcher.close()
|
||||||
|
Logger.info('Watcher Closed')
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
Logger.error('Failed to close server', err)
|
||||||
|
} else {
|
||||||
|
Logger.info('Server successfully closed')
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = Server
|
497
server/Stream.js
Normal file
497
server/Stream.js
Normal file
@ -0,0 +1,497 @@
|
|||||||
|
const Ffmpeg = require('fluent-ffmpeg')
|
||||||
|
const EventEmitter = require('events')
|
||||||
|
const Path = require('path')
|
||||||
|
const fs = require('fs-extra')
|
||||||
|
const Logger = require('./Logger')
|
||||||
|
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||||
|
const hlsPlaylistGenerator = require('./utils/hlsPlaylistGenerator')
|
||||||
|
|
||||||
|
class Stream extends EventEmitter {
|
||||||
|
constructor(streamPath, client, audiobook) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.id = (Date.now() + Math.trunc(Math.random() * 1000)).toString(36)
|
||||||
|
this.client = client
|
||||||
|
this.audiobook = audiobook
|
||||||
|
|
||||||
|
this.segmentLength = 6
|
||||||
|
this.segmentBasename = 'output-%d.ts'
|
||||||
|
this.streamPath = Path.join(streamPath, this.id)
|
||||||
|
this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
|
||||||
|
this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
|
||||||
|
this.fakePlaylistPath = Path.join(this.streamPath, 'fake-output.m3u8')
|
||||||
|
this.startTime = 0
|
||||||
|
|
||||||
|
this.ffmpeg = null
|
||||||
|
this.loop = null
|
||||||
|
this.isResetting = false
|
||||||
|
this.isClientInitialized = false
|
||||||
|
this.isTranscodeComplete = false
|
||||||
|
this.segmentsCreated = new Set()
|
||||||
|
this.furthestSegmentCreated = 0
|
||||||
|
this.clientCurrentTime = 0
|
||||||
|
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
get socket() {
|
||||||
|
return this.client.socket
|
||||||
|
}
|
||||||
|
|
||||||
|
get audiobookId() {
|
||||||
|
return this.audiobook.id
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalDuration() {
|
||||||
|
return this.audiobook.totalDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
get segmentStartNumber() {
|
||||||
|
if (!this.startTime) return 0
|
||||||
|
return Math.floor(this.startTime / this.segmentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
get numSegments() {
|
||||||
|
var numSegs = Math.floor(this.totalDuration / this.segmentLength)
|
||||||
|
if (this.totalDuration - (numSegs * this.segmentLength) > 0) {
|
||||||
|
numSegs++
|
||||||
|
}
|
||||||
|
return numSegs
|
||||||
|
}
|
||||||
|
|
||||||
|
get tracks() {
|
||||||
|
return this.audiobook.tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
get clientPlaylistUri() {
|
||||||
|
return `/hls/${this.id}/output.m3u8`
|
||||||
|
}
|
||||||
|
|
||||||
|
get clientProgress() {
|
||||||
|
if (!this.clientCurrentTime) return 0
|
||||||
|
return Number((this.clientCurrentTime / this.totalDuration).toFixed(3))
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
clientId: this.client.id,
|
||||||
|
userId: this.client.user.id,
|
||||||
|
audiobook: this.audiobook.toJSONMinified(),
|
||||||
|
segmentLength: this.segmentLength,
|
||||||
|
playlistPath: this.playlistPath,
|
||||||
|
clientPlaylistUri: this.clientPlaylistUri,
|
||||||
|
clientCurrentTime: this.clientCurrentTime,
|
||||||
|
startTime: this.startTime,
|
||||||
|
segmentStartNumber: this.segmentStartNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, `Time Remaining: ${timeRemaining}s`)
|
||||||
|
if (timeRemaining > 15) {
|
||||||
|
this.startTime = userAudiobook.currentTime
|
||||||
|
this.clientCurrentTime = this.startTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkSegmentNumberRequest(segNum) {
|
||||||
|
var segStartTime = segNum * this.segmentLength
|
||||||
|
if (this.startTime > segStartTime) {
|
||||||
|
Logger.warn(`[STREAM] Segment #${segNum} Request @${secondsToTimestamp(segStartTime)} is before start time (${secondsToTimestamp(this.startTime)}) - Reset Transcode`)
|
||||||
|
await this.reset(segStartTime - (this.segmentLength * 2))
|
||||||
|
return segStartTime
|
||||||
|
} else if (this.isTranscodeComplete) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var distanceFromFurthestSegment = segNum - this.furthestSegmentCreated
|
||||||
|
if (distanceFromFurthestSegment > 10) {
|
||||||
|
Logger.info(`Segment #${segNum} requested is ${distanceFromFurthestSegment} segments from latest (${secondsToTimestamp(segStartTime)}) - Reset Transcode`)
|
||||||
|
await this.reset(segStartTime - (this.segmentLength * 2))
|
||||||
|
return segStartTime
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
updateClientCurrentTime(currentTime) {
|
||||||
|
Logger.debug('[Stream] Updated client current time', secondsToTimestamp(currentTime))
|
||||||
|
this.clientCurrentTime = currentTime
|
||||||
|
}
|
||||||
|
|
||||||
|
async generatePlaylist() {
|
||||||
|
fs.ensureDirSync(this.streamPath)
|
||||||
|
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength)
|
||||||
|
console.log('Playlist generated')
|
||||||
|
return this.clientPlaylistUri
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkFiles() {
|
||||||
|
try {
|
||||||
|
var files = await fs.readdir(this.streamPath)
|
||||||
|
files.forEach((file) => {
|
||||||
|
var extname = Path.extname(file)
|
||||||
|
if (extname === '.ts') {
|
||||||
|
var basename = Path.basename(file, extname)
|
||||||
|
var num_part = basename.split('-')[1]
|
||||||
|
var part_num = Number(num_part)
|
||||||
|
this.segmentsCreated.add(part_num)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!this.segmentsCreated.size) {
|
||||||
|
Logger.warn('No Segments')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.segmentsCreated.size > 6 && !this.isClientInitialized) {
|
||||||
|
this.isClientInitialized = true
|
||||||
|
Logger.info(`[STREAM] ${this.id} notifying client that stream is ready`)
|
||||||
|
this.socket.emit('stream_open', this.toJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks = []
|
||||||
|
var current_chunk = []
|
||||||
|
var last_seg_in_chunk = -1
|
||||||
|
|
||||||
|
var segments = Array.from(this.segmentsCreated).sort((a, b) => a - b);
|
||||||
|
var lastSegment = segments[segments.length - 1]
|
||||||
|
if (lastSegment > this.furthestSegmentCreated) {
|
||||||
|
this.furthestSegmentCreated = lastSegment
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log('SORT', [...this.segmentsCreated].slice(0, 200).join(', '), segments.slice(0, 200).join(', '))
|
||||||
|
segments.forEach((seg) => {
|
||||||
|
if (!current_chunk.length || last_seg_in_chunk + 1 === seg) {
|
||||||
|
last_seg_in_chunk = seg
|
||||||
|
current_chunk.push(seg)
|
||||||
|
} else {
|
||||||
|
// console.log('Last Seg is not equal to - 1', last_seg_in_chunk, seg)
|
||||||
|
if (current_chunk.length === 1) chunks.push(current_chunk[0])
|
||||||
|
else chunks.push(`${current_chunk[0]}-${current_chunk[current_chunk.length - 1]}`)
|
||||||
|
last_seg_in_chunk = seg
|
||||||
|
current_chunk = [seg]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (current_chunk.length) {
|
||||||
|
if (current_chunk.length === 1) chunks.push(current_chunk[0])
|
||||||
|
else chunks.push(`${current_chunk[0]}-${current_chunk[current_chunk.length - 1]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
var perc = (this.segmentsCreated.size * 100 / this.numSegments).toFixed(2) + '%'
|
||||||
|
Logger.info('[STREAM-CHECK] Check Files', this.segmentsCreated.size, 'of', this.numSegments, perc, `Furthest Segment: ${this.furthestSegmentCreated}`)
|
||||||
|
Logger.info('[STREAM-CHECK] Chunks', chunks.join(', '))
|
||||||
|
|
||||||
|
this.socket.emit('stream_progress', {
|
||||||
|
stream: this.id,
|
||||||
|
percentCreated: perc,
|
||||||
|
chunks,
|
||||||
|
numSegments: this.numSegments
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed checkign files', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startLoop() {
|
||||||
|
this.socket.emit('stream_progress', { chunks: [], numSegments: 0 })
|
||||||
|
this.loop = setInterval(() => {
|
||||||
|
if (!this.isTranscodeComplete) {
|
||||||
|
this.checkFiles()
|
||||||
|
} else {
|
||||||
|
this.socket.emit('stream_ready')
|
||||||
|
clearTimeout(this.loop)
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeSingleQuotes(path) {
|
||||||
|
// return path.replace(/'/g, '\'\\\'\'')
|
||||||
|
return path.replace(/\\/g, '/').replace(/ /g, '\\ ').replace(/'/g, '\\\'')
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)
|
||||||
|
|
||||||
|
this.ffmpeg = Ffmpeg()
|
||||||
|
var currTrackEnd = 0
|
||||||
|
var startingTrack = this.tracks.find(t => {
|
||||||
|
currTrackEnd += t.duration
|
||||||
|
return this.startTime < currTrackEnd
|
||||||
|
})
|
||||||
|
var trackStartTime = currTrackEnd - startingTrack.duration
|
||||||
|
|
||||||
|
var tracksToInclude = this.tracks.filter(t => t.index >= startingTrack.index)
|
||||||
|
var trackPaths = tracksToInclude.map(t => {
|
||||||
|
var line = 'file ' + this.escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
|
||||||
|
return line
|
||||||
|
})
|
||||||
|
var inputstr = trackPaths.join('\n\n')
|
||||||
|
await fs.writeFile(this.concatFilesPath, inputstr)
|
||||||
|
|
||||||
|
this.ffmpeg.addInput(this.concatFilesPath)
|
||||||
|
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}`)
|
||||||
|
this.ffmpeg.inputOption('-noaccurate_seek')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ffmpeg.addOption([
|
||||||
|
'-loglevel warning',
|
||||||
|
'-map 0:a',
|
||||||
|
'-c:a copy'
|
||||||
|
])
|
||||||
|
this.ffmpeg.addOption([
|
||||||
|
'-f hls',
|
||||||
|
"-copyts",
|
||||||
|
"-avoid_negative_ts disabled",
|
||||||
|
"-max_delay 5000000",
|
||||||
|
"-max_muxing_queue_size 2048",
|
||||||
|
`-hls_time 6`,
|
||||||
|
"-hls_segment_type mpegts",
|
||||||
|
`-start_number ${this.segmentStartNumber}`,
|
||||||
|
"-hls_playlist_type vod",
|
||||||
|
"-hls_list_size 0",
|
||||||
|
"-hls_allow_cache 0"
|
||||||
|
])
|
||||||
|
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
|
||||||
|
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
|
||||||
|
this.ffmpeg.output(this.fakePlaylistPath)
|
||||||
|
|
||||||
|
this.ffmpeg.on('start', (command) => {
|
||||||
|
Logger.info('[INFO] FFMPEG transcoding started with command: ' + command)
|
||||||
|
if (this.isResetting) {
|
||||||
|
setTimeout(() => {
|
||||||
|
Logger.info('[STREAM] Clearing isResetting')
|
||||||
|
this.isResetting = false
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
this.startLoop()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.ffmpeg.on('stderr', (stdErrline) => {
|
||||||
|
Logger.info(stdErrline)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.ffmpeg.on('error', (err, stdout, stderr) => {
|
||||||
|
if (err.message && err.message.includes('SIGKILL')) {
|
||||||
|
// This is an intentional SIGKILL
|
||||||
|
Logger.info('[FFMPEG] Transcode Killed')
|
||||||
|
this.ffmpeg = null
|
||||||
|
} else {
|
||||||
|
Logger.error('Ffmpeg Err', err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.ffmpeg.on('end', (stdout, stderr) => {
|
||||||
|
Logger.info('[FFMPEG] Transcoding ended')
|
||||||
|
this.isTranscodeComplete = true
|
||||||
|
this.ffmpeg = null
|
||||||
|
})
|
||||||
|
|
||||||
|
this.ffmpeg.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
async startConcat() {
|
||||||
|
Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)
|
||||||
|
|
||||||
|
|
||||||
|
var concatOutput = null
|
||||||
|
if (this.tracks.length > 1) {
|
||||||
|
var start = Date.now()
|
||||||
|
await new Promise(async (resolve) => {
|
||||||
|
Logger.info('Concatenating here', this.tracks.length)
|
||||||
|
|
||||||
|
this.ffmpeg = Ffmpeg()
|
||||||
|
var trackExt = this.tracks[0].ext
|
||||||
|
concatOutput = Path.join(this.streamPath, `concat${trackExt}`)
|
||||||
|
Logger.info('Concat OUTPUT', concatOutput)
|
||||||
|
var trackPaths = this.tracks.map(t => {
|
||||||
|
var line = 'file ' + this.escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
|
||||||
|
return line
|
||||||
|
})
|
||||||
|
var inputstr = trackPaths.join('\n\n')
|
||||||
|
await fs.writeFile(this.concatFilesPath, inputstr)
|
||||||
|
this.ffmpeg.addInput(this.concatFilesPath)
|
||||||
|
this.ffmpeg.inputFormat('concat')
|
||||||
|
this.ffmpeg.inputOption('-safe 0')
|
||||||
|
this.ffmpeg.addOption([
|
||||||
|
'-loglevel warning',
|
||||||
|
'-map 0:a',
|
||||||
|
'-c:a copy'
|
||||||
|
])
|
||||||
|
this.ffmpeg.output(concatOutput)
|
||||||
|
|
||||||
|
this.ffmpeg.on('start', (command) => {
|
||||||
|
Logger.info('[CONCAT] FFMPEG transcoding started with command: ' + command)
|
||||||
|
})
|
||||||
|
this.ffmpeg.on('error', (err, stdout, stderr) => {
|
||||||
|
Logger.info('[CONCAT] ERROR', err, stderr)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.ffmpeg.on('end', (stdout, stderr) => {
|
||||||
|
Logger.info('[CONCAT] Concat is done')
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
this.ffmpeg.run()
|
||||||
|
})
|
||||||
|
var elapsed = ((Date.now() - start) / 1000).toFixed(1)
|
||||||
|
Logger.info(`[CONCAT] Final elapsed is ${elapsed}s`)
|
||||||
|
} else {
|
||||||
|
concatOutput = this.tracks[0].fullPath
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
this.ffmpeg = Ffmpeg()
|
||||||
|
|
||||||
|
// var currTrackEnd = 0
|
||||||
|
// var startingTrack = this.tracks.find(t => {
|
||||||
|
// currTrackEnd += t.duration
|
||||||
|
// return this.startTime < currTrackEnd
|
||||||
|
// })
|
||||||
|
// var trackStartTime = currTrackEnd - startingTrack.duration
|
||||||
|
// var currInpoint = this.startTime - trackStartTime
|
||||||
|
|
||||||
|
// var tracksToInclude = this.tracks.filter(t => t.index >= startingTrack.index)
|
||||||
|
|
||||||
|
// var trackPaths = tracksToInclude.map(t => {
|
||||||
|
// var line = 'file ' + this.escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
|
||||||
|
// if (t.index === startingTrack.index) {
|
||||||
|
// line += `\ninpoint ${currInpoint}`
|
||||||
|
// }
|
||||||
|
// return line
|
||||||
|
// })
|
||||||
|
// var inputstr = trackPaths.join('\n\n')
|
||||||
|
// await fs.writeFile(this.concatFilesPath, inputstr)
|
||||||
|
|
||||||
|
this.ffmpeg.addInput(concatOutput)
|
||||||
|
// this.ffmpeg.inputFormat('concat')
|
||||||
|
// this.ffmpeg.inputOption('-safe 0')
|
||||||
|
|
||||||
|
if (this.startTime > 0) {
|
||||||
|
Logger.info(`[STREAM] Starting Stream at startTime ${secondsToTimestamp(this.startTime)} and Segment #${this.segmentStartNumber}`)
|
||||||
|
this.ffmpeg.inputOption(`-ss ${this.startTime}`)
|
||||||
|
this.ffmpeg.inputOption('-noaccurate_seek')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ffmpeg.addOption([
|
||||||
|
'-loglevel warning',
|
||||||
|
'-map 0:a',
|
||||||
|
'-c:a copy'
|
||||||
|
])
|
||||||
|
this.ffmpeg.addOption([
|
||||||
|
'-f hls',
|
||||||
|
"-copyts",
|
||||||
|
"-avoid_negative_ts disabled",
|
||||||
|
"-max_delay 5000000",
|
||||||
|
"-max_muxing_queue_size 2048",
|
||||||
|
`-hls_time 6`,
|
||||||
|
"-hls_segment_type mpegts",
|
||||||
|
`-start_number ${this.segmentStartNumber}`,
|
||||||
|
"-hls_playlist_type vod",
|
||||||
|
"-hls_list_size 0",
|
||||||
|
"-hls_allow_cache 0"
|
||||||
|
])
|
||||||
|
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
|
||||||
|
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
|
||||||
|
this.ffmpeg.output(this.playlistPath)
|
||||||
|
|
||||||
|
this.ffmpeg.on('start', (command) => {
|
||||||
|
Logger.info('[INFO] FFMPEG transcoding started with command: ' + command)
|
||||||
|
if (this.isResetting) {
|
||||||
|
setTimeout(() => {
|
||||||
|
Logger.info('[STREAM] Clearing isResetting')
|
||||||
|
this.isResetting = false
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
this.startLoop()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.ffmpeg.on('stderr', (stdErrline) => {
|
||||||
|
Logger.info(stdErrline)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.ffmpeg.on('error', (err, stdout, stderr) => {
|
||||||
|
if (err.message && err.message.includes('SIGKILL')) {
|
||||||
|
// This is an intentional SIGKILL
|
||||||
|
Logger.info('[FFMPEG] Transcode Killed')
|
||||||
|
this.ffmpeg = null
|
||||||
|
} else {
|
||||||
|
Logger.error('Ffmpeg Err', err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.ffmpeg.on('end', (stdout, stderr) => {
|
||||||
|
Logger.info('[FFMPEG] Transcoding ended')
|
||||||
|
this.isTranscodeComplete = true
|
||||||
|
this.ffmpeg = null
|
||||||
|
})
|
||||||
|
|
||||||
|
this.ffmpeg.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
clearInterval(this.loop)
|
||||||
|
|
||||||
|
Logger.info('Closing Stream', this.id)
|
||||||
|
if (this.ffmpeg) {
|
||||||
|
this.ffmpeg.kill('SIGKILL')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.remove(this.streamPath).then(() => {
|
||||||
|
Logger.info('Deleted session data', this.streamPath)
|
||||||
|
}).catch((err) => {
|
||||||
|
Logger.error('Failed to delete session data', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.client.socket.emit('stream_closed', this.id)
|
||||||
|
|
||||||
|
this.emit('closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelTranscode() {
|
||||||
|
clearInterval(this.loop)
|
||||||
|
if (this.ffmpeg) {
|
||||||
|
this.ffmpeg.kill('SIGKILL')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitCancelTranscode() {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
if (!this.ffmpeg) return true
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
}
|
||||||
|
Logger.error('[STREAM] Transcode never closed...')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset(time) {
|
||||||
|
if (this.isResetting) {
|
||||||
|
return Logger.info(`[STREAM] Stream ${this.id} already resetting`)
|
||||||
|
}
|
||||||
|
time = Math.max(0, time)
|
||||||
|
this.isResetting = true
|
||||||
|
|
||||||
|
if (this.ffmpeg) {
|
||||||
|
this.cancelTranscode()
|
||||||
|
await this.waitCancelTranscode()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isTranscodeComplete = false
|
||||||
|
this.startTime = time
|
||||||
|
this.clientCurrentTime = this.startTime
|
||||||
|
Logger.info(`Stream Reset New Start Time ${secondsToTimestamp(this.startTime)}`)
|
||||||
|
this.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = Stream
|
118
server/StreamManager.js
Normal file
118
server/StreamManager.js
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
const Stream = require('./Stream')
|
||||||
|
const Logger = require('./Logger')
|
||||||
|
const fs = require('fs-extra')
|
||||||
|
const Path = require('path')
|
||||||
|
|
||||||
|
class StreamManager {
|
||||||
|
constructor(db, STREAM_PATH) {
|
||||||
|
this.db = db
|
||||||
|
|
||||||
|
this.streams = []
|
||||||
|
this.streamPath = STREAM_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
get audiobooks() {
|
||||||
|
return this.db.audiobooks
|
||||||
|
}
|
||||||
|
|
||||||
|
getStream(streamId) {
|
||||||
|
return this.streams.find(s => s.id === streamId)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeStream(stream) {
|
||||||
|
this.streams = this.streams.filter(s => s.id !== stream.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async openStream(client, audiobook) {
|
||||||
|
var stream = new Stream(this.streamPath, client, audiobook)
|
||||||
|
|
||||||
|
stream.on('closed', () => {
|
||||||
|
this.removeStream(stream)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.streams.push(stream)
|
||||||
|
|
||||||
|
await stream.generatePlaylist()
|
||||||
|
stream.start()
|
||||||
|
|
||||||
|
Logger.info('Stream Opened for client', client.user.username, 'for audiobook', audiobook.title, 'with streamId', stream.id)
|
||||||
|
|
||||||
|
client.stream = stream
|
||||||
|
client.user.stream = stream.id
|
||||||
|
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
removeOrphanStreamFiles(streamId) {
|
||||||
|
try {
|
||||||
|
var streamPath = Path.join(this.streamPath, streamId)
|
||||||
|
return fs.remove(streamPath)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.debug('No orphan stream', streamId)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeOrphanStreams() {
|
||||||
|
try {
|
||||||
|
var dirs = await fs.readdir(this.streamPath)
|
||||||
|
if (!dirs || !dirs.length) return true
|
||||||
|
|
||||||
|
await Promise.all(dirs.map(async (dirname) => {
|
||||||
|
var fullPath = Path.join(this.streamPath, dirname)
|
||||||
|
Logger.info(`Removing Orphan Stream ${dirname}`)
|
||||||
|
return fs.remove(fullPath)
|
||||||
|
}))
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
Logger.debug('No orphan stream', streamId)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async openStreamSocketRequest(socket, audiobookId) {
|
||||||
|
Logger.info('Open Stream Request', socket.id, audiobookId)
|
||||||
|
var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
|
||||||
|
var client = socket.sheepClient
|
||||||
|
|
||||||
|
if (client.stream) {
|
||||||
|
Logger.info('Closing client stream first', client.stream.id)
|
||||||
|
await client.stream.close()
|
||||||
|
client.user.stream = null
|
||||||
|
client.stream = null
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = await this.openStream(client, audiobook)
|
||||||
|
this.db.updateUserStream(client.user.id, stream.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeStreamRequest(socket) {
|
||||||
|
Logger.info('Close Stream Request', socket.id)
|
||||||
|
var client = socket.sheepClient
|
||||||
|
if (!client || !client.stream) {
|
||||||
|
Logger.error('No stream for client', client.user.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// var streamId = client.stream.id
|
||||||
|
await client.stream.close()
|
||||||
|
client.user.stream = null
|
||||||
|
client.stream = null
|
||||||
|
this.db.updateUserStream(client.user.id, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
streamUpdate(socket, { currentTime, streamId }) {
|
||||||
|
var client = socket.sheepClient
|
||||||
|
if (!client || !client.stream) {
|
||||||
|
Logger.error('No stream for client', client.user.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (client.stream.id !== streamId) {
|
||||||
|
Logger.error('Stream id mismatch on stream update', streamId, client.stream.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client.stream.updateClientCurrentTime(currentTime)
|
||||||
|
client.user.updateAudiobookProgress(client.stream)
|
||||||
|
this.db.updateEntity('user', client.user.toJSON())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = StreamManager
|
75
server/User.js
Normal file
75
server/User.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
class User {
|
||||||
|
constructor(user) {
|
||||||
|
this.id = null
|
||||||
|
this.username = null
|
||||||
|
this.pash = null
|
||||||
|
this.type = null
|
||||||
|
this.stream = null
|
||||||
|
this.token = null
|
||||||
|
this.createdAt = null
|
||||||
|
this.audiobooks = null
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
this.construct(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
username: this.username,
|
||||||
|
pash: this.pash,
|
||||||
|
type: this.type,
|
||||||
|
stream: this.stream,
|
||||||
|
token: this.token,
|
||||||
|
audiobooks: this.audiobooks,
|
||||||
|
createdAt: this.createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSONForBrowser() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
username: this.username,
|
||||||
|
type: this.type,
|
||||||
|
stream: this.stream,
|
||||||
|
token: this.token,
|
||||||
|
audiobooks: this.audiobooks,
|
||||||
|
createdAt: this.createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
construct(user) {
|
||||||
|
this.id = user.id
|
||||||
|
this.username = user.username
|
||||||
|
this.pash = user.pash
|
||||||
|
this.type = user.type
|
||||||
|
this.stream = user.stream
|
||||||
|
this.token = user.token
|
||||||
|
this.audiobooks = user.audiobooks || null
|
||||||
|
this.createdAt = user.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAudiobookProgress(stream) {
|
||||||
|
if (!this.audiobooks) this.audiobooks = {}
|
||||||
|
if (!this.audiobooks[stream.audiobookId]) {
|
||||||
|
this.audiobooks[stream.audiobookId] = {
|
||||||
|
audiobookId: stream.audiobookId,
|
||||||
|
totalDuration: stream.totalDuration,
|
||||||
|
startedAt: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.audiobooks[stream.audiobookId].lastUpdate = Date.now()
|
||||||
|
this.audiobooks[stream.audiobookId].progress = stream.clientProgress
|
||||||
|
this.audiobooks[stream.audiobookId].currentTime = stream.clientCurrentTime
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAudiobookProgress(audiobookId) {
|
||||||
|
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete this.audiobooks[audiobookId]
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = User
|
71
server/Watcher.js
Normal file
71
server/Watcher.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
var EventEmitter = require('events')
|
||||||
|
var Logger = require('./Logger')
|
||||||
|
var chokidar = require('chokidar')
|
||||||
|
|
||||||
|
class FolderWatcher extends EventEmitter {
|
||||||
|
constructor(audiobookPath) {
|
||||||
|
super()
|
||||||
|
this.AudiobookPath = audiobookPath
|
||||||
|
this.folderMap = {}
|
||||||
|
this.watcher = null
|
||||||
|
}
|
||||||
|
|
||||||
|
initWatcher() {
|
||||||
|
try {
|
||||||
|
Logger.info('[WATCHER] Initializing..')
|
||||||
|
this.watcher = chokidar.watch(this.AudiobookPath, {
|
||||||
|
ignoreInitial: true,
|
||||||
|
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||||
|
persistent: true,
|
||||||
|
awaitWriteFinish: {
|
||||||
|
stabilityThreshold: 2500,
|
||||||
|
pollInterval: 500
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.watcher
|
||||||
|
.on('add', (path) => {
|
||||||
|
this.onNewFile(path)
|
||||||
|
}).on('change', (path) => {
|
||||||
|
this.onFileUpdated(path)
|
||||||
|
}).on('unlink', path => {
|
||||||
|
this.onFileRemoved(path)
|
||||||
|
}).on('error', (error) => {
|
||||||
|
Logger.error(`Watcher error: ${error}`)
|
||||||
|
}).on('ready', () => {
|
||||||
|
Logger.info('[WATCHER] Ready')
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Chokidar watcher failed', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
return this.watcher.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
onNewFile(path) {
|
||||||
|
Logger.info('FolderWatcher: New File', path)
|
||||||
|
this.emit('file_added', {
|
||||||
|
path: path.replace(this.AudiobookPath, ''),
|
||||||
|
fullPath: path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileRemoved(path) {
|
||||||
|
Logger.info('FolderWatcher: File Removed', path)
|
||||||
|
this.emit('file_removed', {
|
||||||
|
path: path.replace(this.AudiobookPath, ''),
|
||||||
|
fullPath: path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileUpdated(path) {
|
||||||
|
Logger.info('FolderWatcher: Updated File', path)
|
||||||
|
this.emit('file_updated', {
|
||||||
|
path: path.replace(this.AudiobookPath, ''),
|
||||||
|
fullPath: path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = FolderWatcher
|
44
server/providers/LibGen.js
Normal file
44
server/providers/LibGen.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
var libgen = require('libgen')
|
||||||
|
|
||||||
|
class LibGen {
|
||||||
|
constructor() {
|
||||||
|
this.mirror = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.mirror = await libgen.mirror()
|
||||||
|
console.log(`${this.mirror} is currently fastest`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query) {
|
||||||
|
if (!this.mirror) {
|
||||||
|
await this.init()
|
||||||
|
}
|
||||||
|
var options = {
|
||||||
|
mirror: this.mirror,
|
||||||
|
query: query,
|
||||||
|
search_in: 'title'
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await libgen.search(options)
|
||||||
|
let n = data.length
|
||||||
|
console.log(`${n} results for "${options.query}"`)
|
||||||
|
while (n--) {
|
||||||
|
console.log('');
|
||||||
|
console.log('Title: ' + data[n].title)
|
||||||
|
console.log('Author: ' + data[n].author)
|
||||||
|
console.log('Download: ' +
|
||||||
|
'http://gen.lib.rus.ec/book/index.php?md5=' +
|
||||||
|
data[n].md5.toLowerCase())
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
return {
|
||||||
|
errorCode: 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LibGen
|
72
server/providers/OpenLibrary.js
Normal file
72
server/providers/OpenLibrary.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
var axios = require('axios')
|
||||||
|
|
||||||
|
class OpenLibrary {
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = 'https://openlibrary.org'
|
||||||
|
}
|
||||||
|
|
||||||
|
get(uri) {
|
||||||
|
return axios.get(`${this.baseUrl}/${uri}`).then((res) => {
|
||||||
|
return res.data
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async isbnLookup(isbn) {
|
||||||
|
var lookupData = await this.get(`/isbn/${isbn}`)
|
||||||
|
if (!lookupData) {
|
||||||
|
return {
|
||||||
|
errorCode: 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lookupData
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWorksData(worksKey) {
|
||||||
|
var worksData = await this.get(`${worksKey}.json`)
|
||||||
|
if (!worksData.covers) worksData.covers = []
|
||||||
|
var coverImages = worksData.covers.filter(c => c > 0).map(c => `https://covers.openlibrary.org/b/id/${c}-L.jpg`)
|
||||||
|
var description = null
|
||||||
|
if (worksData.description) {
|
||||||
|
if (typeof worksData.description === 'string') {
|
||||||
|
description = worksData.description
|
||||||
|
} else {
|
||||||
|
description = worksData.description.value || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: worksKey.split('/').pop(),
|
||||||
|
key: worksKey,
|
||||||
|
covers: coverImages,
|
||||||
|
first_publish_date: worksData.first_publish_date,
|
||||||
|
description: description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanSearchDoc(doc) {
|
||||||
|
var worksData = await this.getWorksData(doc.key)
|
||||||
|
return {
|
||||||
|
title: doc.title,
|
||||||
|
author: doc.author_name ? doc.author_name.join(', ') : null,
|
||||||
|
first_publish_year: doc.first_publish_year,
|
||||||
|
edition: doc.cover_edition_key,
|
||||||
|
cover: doc.cover_edition_key ? `https://covers.openlibrary.org/b/OLID/${doc.cover_edition_key}-L.jpg` : null,
|
||||||
|
...worksData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query) {
|
||||||
|
var queryString = Object.keys(query).map(key => key + '=' + query[key]).join('&')
|
||||||
|
var lookupData = await this.get(`/search.json?${queryString}`)
|
||||||
|
if (!lookupData) {
|
||||||
|
return {
|
||||||
|
errorCode: 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var searchDocs = await Promise.all(lookupData.docs.map(d => this.cleanSearchDoc(d)))
|
||||||
|
return searchDocs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = OpenLibrary
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user