mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-22 00:07:52 +01:00
Merge branch 'master' into mf/loginPage
This commit is contained in:
commit
d71bc89c9d
31
.github/workflows/unit-tests.yml
vendored
Normal file
31
.github/workflows/unit-tests.yml
vendored
Normal 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
|
@ -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>
|
||||||
|
@ -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')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
10
client/package-lock.json
generated
10
client/package-lock.json
generated
@ -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 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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, () => {
|
||||||
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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) {
|
||||||
|
@ -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 }
|
||||||
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user