Merge branch 'master' into mf/loginPage

This commit is contained in:
advplyr 2024-03-16 15:24:22 -05:00
commit d71bc89c9d
10 changed files with 153 additions and 27 deletions

31
.github/workflows/unit-tests.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Run Unit Tests
on:
workflow_dispatch:
inputs:
ref:
description: 'Branch/Tag/SHA to test'
required: true
pull_request:
push:
jobs:
run-unit-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event_name != 'workflow_dispatch' && github.ref_name || inputs.ref}}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test

View File

@ -34,11 +34,6 @@ export default {
data() { data() {
return {} return {}
}, },
watch: {
value(newVal) {
this.$nextTick(this.scrollToChapter)
}
},
computed: { computed: {
show: { show: {
get() { get() {
@ -53,7 +48,7 @@ export default {
return this.playbackRate return this.playbackRate
}, },
currentChapterId() { currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null return this.currentChapter?.id || null
}, },
currentChapterStart() { currentChapterStart() {
return (this.currentChapter?.start || 0) / this._playbackRate return (this.currentChapter?.start || 0) / this._playbackRate
@ -74,6 +69,11 @@ export default {
} }
} }
} }
},
updated() {
if (this.value) {
this.$nextTick(this.scrollToChapter)
}
} }
} }
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="text-center mt-4"> <div class="text-center mt-4 relative">
<div class="flex py-4"> <div class="flex py-4">
<ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">{{ $strings.ButtonUploadBackup }}</ui-file-input> <ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">{{ $strings.ButtonUploadBackup }}</ui-file-input>
<div class="flex-grow" /> <div class="flex-grow" />
@ -54,6 +54,10 @@
</div> </div>
</div> </div>
</prompt-dialog> </prompt-dialog>
<div v-if="isApplyingBackup" class="absolute inset-0 w-full h-full flex items-center justify-center bg-black/20 rounded-md">
<ui-loading-indicator />
</div>
</div> </div>
</template> </template>
@ -64,6 +68,7 @@ export default {
showConfirmApply: false, showConfirmApply: false,
selectedBackup: null, selectedBackup: null,
isBackingUp: false, isBackingUp: false,
isApplyingBackup: false,
processing: false, processing: false,
backups: [] backups: []
} }
@ -85,19 +90,21 @@ export default {
}, },
confirm() { confirm() {
this.showConfirmApply = false this.showConfirmApply = false
this.isApplyingBackup = true
this.$axios this.$axios
.$get(`/api/backups/${this.selectedBackup.id}/apply`) .$get(`/api/backups/${this.selectedBackup.id}/apply`)
.then(() => { .then(() => {
this.isBackingUp = false
location.replace('/config/backups?backup=1') location.replace('/config/backups?backup=1')
}) })
.catch((error) => { .catch((error) => {
this.isBackingUp = false
console.error('Failed to apply backup', error) console.error('Failed to apply backup', error)
const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
}) })
.finally(() => {
this.isApplyingBackup = false
})
}, },
deleteBackupClick(backup) { deleteBackupClick(backup) {
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) { if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
@ -180,7 +187,6 @@ export default {
this.loadBackups() this.loadBackups()
if (this.$route.query.backup) { if (this.$route.query.backup) {
this.$toast.success('Backup applied successfully') this.$toast.success('Backup applied successfully')
this.$router.replace('/config')
} }
} }
} }

View File

@ -16,7 +16,7 @@
"cron-parser": "^4.7.1", "cron-parser": "^4.7.1",
"date-fns": "^2.25.0", "date-fns": "^2.25.0",
"epubjs": "^0.3.88", "epubjs": "^0.3.88",
"hls.js": "^1.0.7", "hls.js": "^1.5.7",
"libarchive.js": "^1.3.0", "libarchive.js": "^1.3.0",
"nuxt": "^2.17.3", "nuxt": "^2.17.3",
"nuxt-socket-io": "^1.1.18", "nuxt-socket-io": "^1.1.18",
@ -8627,9 +8627,9 @@
} }
}, },
"node_modules/hls.js": { "node_modules/hls.js": {
"version": "1.5.1", "version": "1.5.7",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.7.tgz",
"integrity": "sha512-SsUSlpyjOGnwBhVrVEG6vRFPU2SAJ0gUqrFdGeo7YPbOC0vuWK0TDMyp7n3QiaBC/Wkic771uqPnnVdT8/x+3Q==" "integrity": "sha512-Hnyf7ojTBtXHeOW1/t6wCBJSiK1WpoKF9yg7juxldDx8u3iswrkPt2wbOA/1NiwU4j27DSIVoIEJRAhcdMef/A=="
}, },
"node_modules/hmac-drbg": { "node_modules/hmac-drbg": {
"version": "1.0.1", "version": "1.0.1",
@ -16976,4 +16976,4 @@
} }
} }
} }
} }

View File

@ -21,7 +21,7 @@
"cron-parser": "^4.7.1", "cron-parser": "^4.7.1",
"date-fns": "^2.25.0", "date-fns": "^2.25.0",
"epubjs": "^0.3.88", "epubjs": "^0.3.88",
"hls.js": "^1.0.7", "hls.js": "^1.5.7",
"libarchive.js": "^1.3.0", "libarchive.js": "^1.3.0",
"nuxt": "^2.17.3", "nuxt": "^2.17.3",
"nuxt-socket-io": "^1.1.18", "nuxt-socket-io": "^1.1.18",
@ -36,4 +36,4 @@
"postcss": "^8.3.6", "postcss": "^8.3.6",
"tailwindcss": "^3.4.1" "tailwindcss": "^3.4.1"
} }
} }

View File

@ -139,11 +139,30 @@ export default class LocalAudioPlayer extends EventEmitter {
} }
var hlsOptions = { var hlsOptions = {
startPosition: this.startTime || -1 startPosition: this.startTime || -1,
// No longer needed because token is put in a query string fragLoadPolicy: {
// xhrSetup: (xhr) => { default: {
// xhr.setRequestHeader('Authorization', `Bearer ${this.token}`) maxTimeToFirstByteMs: 10000,
// } maxLoadTimeMs: 120000,
timeoutRetry: {
maxNumRetry: 4,
retryDelayMs: 0,
maxRetryDelayMs: 0,
},
errorRetry: {
maxNumRetry: 8,
retryDelayMs: 1000,
maxRetryDelayMs: 8000,
shouldRetry: (retryConfig, retryCount, isTimeout, httpStatus, retry) => {
if (httpStatus?.code === 404 && retryConfig?.maxNumRetry > retryCount) {
console.log(`[HLS] Server 404 for fragment retry ${retryCount} of ${retryConfig.maxNumRetry}`)
return true
}
return retry
}
},
}
}
} }
this.hlsInstance = new Hls(hlsOptions) this.hlsInstance = new Hls(hlsOptions)
@ -156,9 +175,15 @@ export default class LocalAudioPlayer extends EventEmitter {
}) })
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => { this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
console.error('[HLS] Error', data.type, data.details, data)
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) { if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.error('[HLS] BUFFER STALLED ERROR') console.error('[HLS] BUFFER STALLED ERROR')
} else if (data.details === Hls.ErrorDetails.FRAG_LOAD_ERROR) {
// Only show error if the fragment is not being retried
if (data.errorAction?.action !== 5) {
console.error('[HLS] FRAG LOAD ERROR', data)
}
} else {
console.error('[HLS] Error', data.type, data.details, data)
} }
}) })
this.hlsInstance.on(Hls.Events.DESTROYING, () => { this.hlsInstance.on(Hls.Events.DESTROYING, () => {

View File

@ -217,7 +217,6 @@ class Database {
async disconnect() { async disconnect() {
Logger.info(`[Database] Disconnecting sqlite db`) Logger.info(`[Database] Disconnecting sqlite db`)
await this.sequelize.close() await this.sequelize.close()
this.sequelize = null
} }
/** /**

View File

@ -49,8 +49,13 @@ class BackupController {
res.sendFile(req.backup.fullPath) res.sendFile(req.backup.fullPath)
} }
/**
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
apply(req, res) { apply(req, res) {
this.backupManager.requestApplyBackup(req.backup, res) this.backupManager.requestApplyBackup(this.apiCacheManager, req.backup, res)
} }
middleware(req, res, next) { middleware(req, res, next) {

View File

@ -22,6 +22,16 @@ class ApiCacheManager {
this.cache.clear() this.cache.clear()
} }
/**
* Reset hooks and clear cache. Used when applying backups
*/
reset() {
Logger.info(`[ApiCacheManager] Resetting cache`)
this.init()
this.cache.clear()
}
get middleware() { get middleware() {
return (req, res, next) => { return (req, res, next) => {
const key = { user: req.user.username, url: req.url } const key = { user: req.user.username, url: req.url }

View File

@ -146,23 +146,73 @@ class BackupManager {
} }
} }
async requestApplyBackup(backup, res) { /**
*
* @param {import('./ApiCacheManager')} apiCacheManager
* @param {Backup} backup
* @param {import('express').Response} res
*/
async requestApplyBackup(apiCacheManager, backup, res) {
Logger.info(`[BackupManager] Applying backup at "${backup.fullPath}"`)
const zip = new StreamZip.async({ file: backup.fullPath }) const zip = new StreamZip.async({ file: backup.fullPath })
const entries = await zip.entries() const entries = await zip.entries()
// Ensure backup has an absdatabase.sqlite file
if (!Object.keys(entries).includes('absdatabase.sqlite')) { if (!Object.keys(entries).includes('absdatabase.sqlite')) {
Logger.error(`[BackupManager] Cannot apply old backup ${backup.fullPath}`) Logger.error(`[BackupManager] Cannot apply old backup ${backup.fullPath}`)
await zip.close()
return res.status(500).send('Invalid backup file. Does not include absdatabase.sqlite. This might be from an older Audiobookshelf server.') return res.status(500).send('Invalid backup file. Does not include absdatabase.sqlite. This might be from an older Audiobookshelf server.')
} }
await Database.disconnect() await Database.disconnect()
await zip.extract('absdatabase.sqlite', global.ConfigPath) const dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')
const tempDbPath = Path.join(global.ConfigPath, 'absdatabase-temp.sqlite')
// Extract backup sqlite file to temporary path
await zip.extract('absdatabase.sqlite', tempDbPath)
Logger.info(`[BackupManager] Extracted backup sqlite db to temp path ${tempDbPath}`)
// Verify extract - Abandon backup if sqlite file did not extract
if (!await fs.pathExists(tempDbPath)) {
Logger.error(`[BackupManager] Sqlite file not found after extract - abandon backup apply and reconnect db`)
await zip.close()
await Database.reconnect()
return res.status(500).send('Failed to extract sqlite db from backup')
}
// Attempt to remove existing db file
try {
await fs.remove(dbPath)
} catch (error) {
// Abandon backup and remove extracted sqlite file if unable to remove existing db file
Logger.error(`[BackupManager] Unable to overwrite existing db file - abandon backup apply and reconnect db`, error)
await fs.remove(tempDbPath)
await zip.close()
await Database.reconnect()
return res.status(500).send(`Failed to overwrite sqlite db: ${error?.message || 'Unknown Error'}`)
}
// Rename temp db
await fs.move(tempDbPath, dbPath)
Logger.info(`[BackupManager] Saved backup sqlite file at "${dbPath}"`)
// Extract /metadata/items and /metadata/authors folders
await zip.extract('metadata-items/', this.ItemsMetadataPath) await zip.extract('metadata-items/', this.ItemsMetadataPath)
await zip.extract('metadata-authors/', this.AuthorsMetadataPath) await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
await zip.close()
// Reconnect db
await Database.reconnect() await Database.reconnect()
// Reset api cache, set hooks again
await apiCacheManager.reset()
res.sendStatus(200)
// Triggers browser refresh for all clients
SocketAuthority.emitter('backup_applied') SocketAuthority.emitter('backup_applied')
} }