mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +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() {
 | 
			
		||||
    return {}
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
    value(newVal) {
 | 
			
		||||
      this.$nextTick(this.scrollToChapter)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    show: {
 | 
			
		||||
      get() {
 | 
			
		||||
@ -53,7 +48,7 @@ export default {
 | 
			
		||||
      return this.playbackRate
 | 
			
		||||
    },
 | 
			
		||||
    currentChapterId() {
 | 
			
		||||
      return this.currentChapter ? this.currentChapter.id : null
 | 
			
		||||
      return this.currentChapter?.id || null
 | 
			
		||||
    },
 | 
			
		||||
    currentChapterStart() {
 | 
			
		||||
      return (this.currentChapter?.start || 0) / this._playbackRate
 | 
			
		||||
@ -74,6 +69,11 @@ export default {
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  updated() {
 | 
			
		||||
    if (this.value) {
 | 
			
		||||
      this.$nextTick(this.scrollToChapter)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="text-center mt-4">
 | 
			
		||||
  <div class="text-center mt-4 relative">
 | 
			
		||||
    <div class="flex py-4">
 | 
			
		||||
      <ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">{{ $strings.ButtonUploadBackup }}</ui-file-input>
 | 
			
		||||
      <div class="flex-grow" />
 | 
			
		||||
@ -54,6 +54,10 @@
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </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>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
@ -64,6 +68,7 @@ export default {
 | 
			
		||||
      showConfirmApply: false,
 | 
			
		||||
      selectedBackup: null,
 | 
			
		||||
      isBackingUp: false,
 | 
			
		||||
      isApplyingBackup: false,
 | 
			
		||||
      processing: false,
 | 
			
		||||
      backups: []
 | 
			
		||||
    }
 | 
			
		||||
@ -85,19 +90,21 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    confirm() {
 | 
			
		||||
      this.showConfirmApply = false
 | 
			
		||||
      this.isApplyingBackup = true
 | 
			
		||||
 | 
			
		||||
      this.$axios
 | 
			
		||||
        .$get(`/api/backups/${this.selectedBackup.id}/apply`)
 | 
			
		||||
        .then(() => {
 | 
			
		||||
          this.isBackingUp = false
 | 
			
		||||
          location.replace('/config/backups?backup=1')
 | 
			
		||||
        })
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          this.isBackingUp = false
 | 
			
		||||
          console.error('Failed to apply backup', error)
 | 
			
		||||
          const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
 | 
			
		||||
          this.$toast.error(errorMsg)
 | 
			
		||||
        })
 | 
			
		||||
        .finally(() => {
 | 
			
		||||
          this.isApplyingBackup = false
 | 
			
		||||
        })
 | 
			
		||||
    },
 | 
			
		||||
    deleteBackupClick(backup) {
 | 
			
		||||
      if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
 | 
			
		||||
@ -180,7 +187,6 @@ export default {
 | 
			
		||||
    this.loadBackups()
 | 
			
		||||
    if (this.$route.query.backup) {
 | 
			
		||||
      this.$toast.success('Backup applied successfully')
 | 
			
		||||
      this.$router.replace('/config')
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										8
									
								
								client/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								client/package-lock.json
									
									
									
										generated
									
									
									
								
							@ -16,7 +16,7 @@
 | 
			
		||||
        "cron-parser": "^4.7.1",
 | 
			
		||||
        "date-fns": "^2.25.0",
 | 
			
		||||
        "epubjs": "^0.3.88",
 | 
			
		||||
        "hls.js": "^1.0.7",
 | 
			
		||||
        "hls.js": "^1.5.7",
 | 
			
		||||
        "libarchive.js": "^1.3.0",
 | 
			
		||||
        "nuxt": "^2.17.3",
 | 
			
		||||
        "nuxt-socket-io": "^1.1.18",
 | 
			
		||||
@ -8627,9 +8627,9 @@
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/hls.js": {
 | 
			
		||||
      "version": "1.5.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.1.tgz",
 | 
			
		||||
      "integrity": "sha512-SsUSlpyjOGnwBhVrVEG6vRFPU2SAJ0gUqrFdGeo7YPbOC0vuWK0TDMyp7n3QiaBC/Wkic771uqPnnVdT8/x+3Q=="
 | 
			
		||||
      "version": "1.5.7",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.7.tgz",
 | 
			
		||||
      "integrity": "sha512-Hnyf7ojTBtXHeOW1/t6wCBJSiK1WpoKF9yg7juxldDx8u3iswrkPt2wbOA/1NiwU4j27DSIVoIEJRAhcdMef/A=="
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/hmac-drbg": {
 | 
			
		||||
      "version": "1.0.1",
 | 
			
		||||
 | 
			
		||||
@ -21,7 +21,7 @@
 | 
			
		||||
    "cron-parser": "^4.7.1",
 | 
			
		||||
    "date-fns": "^2.25.0",
 | 
			
		||||
    "epubjs": "^0.3.88",
 | 
			
		||||
    "hls.js": "^1.0.7",
 | 
			
		||||
    "hls.js": "^1.5.7",
 | 
			
		||||
    "libarchive.js": "^1.3.0",
 | 
			
		||||
    "nuxt": "^2.17.3",
 | 
			
		||||
    "nuxt-socket-io": "^1.1.18",
 | 
			
		||||
 | 
			
		||||
@ -139,11 +139,30 @@ export default class LocalAudioPlayer extends EventEmitter {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var hlsOptions = {
 | 
			
		||||
      startPosition: this.startTime || -1
 | 
			
		||||
      // No longer needed because token is put in a query string
 | 
			
		||||
      // xhrSetup: (xhr) => {
 | 
			
		||||
      //   xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
 | 
			
		||||
      // }
 | 
			
		||||
      startPosition: this.startTime || -1,
 | 
			
		||||
      fragLoadPolicy: {
 | 
			
		||||
        default: {
 | 
			
		||||
          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)
 | 
			
		||||
 | 
			
		||||
@ -156,9 +175,15 @@ export default class LocalAudioPlayer extends EventEmitter {
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      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) {
 | 
			
		||||
          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, () => {
 | 
			
		||||
 | 
			
		||||
@ -217,7 +217,6 @@ class Database {
 | 
			
		||||
  async disconnect() {
 | 
			
		||||
    Logger.info(`[Database] Disconnecting sqlite db`)
 | 
			
		||||
    await this.sequelize.close()
 | 
			
		||||
    this.sequelize = null
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
 | 
			
		||||
@ -49,8 +49,13 @@ class BackupController {
 | 
			
		||||
    res.sendFile(req.backup.fullPath)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * 
 | 
			
		||||
   * @param {import('express').Request} req 
 | 
			
		||||
   * @param {import('express').Response} res 
 | 
			
		||||
   */
 | 
			
		||||
  apply(req, res) {
 | 
			
		||||
    this.backupManager.requestApplyBackup(req.backup, res)
 | 
			
		||||
    this.backupManager.requestApplyBackup(this.apiCacheManager, req.backup, res)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  middleware(req, res, next) {
 | 
			
		||||
 | 
			
		||||
@ -22,6 +22,16 @@ class ApiCacheManager {
 | 
			
		||||
    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() {
 | 
			
		||||
    return (req, res, next) => {
 | 
			
		||||
      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 entries = await zip.entries()
 | 
			
		||||
 | 
			
		||||
    // Ensure backup has an absdatabase.sqlite file
 | 
			
		||||
    if (!Object.keys(entries).includes('absdatabase.sqlite')) {
 | 
			
		||||
      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.')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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-authors/', this.AuthorsMetadataPath)
 | 
			
		||||
    await zip.close()
 | 
			
		||||
 | 
			
		||||
    // Reconnect db
 | 
			
		||||
    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')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user