mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Add:Ability to edit backup location path on backups page #2973
- Added api endpoint PATCH /api/backups/path - Cleanup backup page UI for mobile screens
This commit is contained in:
		
							parent
							
								
									8498cab842
								
							
						
					
					
						commit
						331d7a41ab
					
				| @ -1,10 +1,27 @@ | |||||||
| <template> | <template> | ||||||
|   <div> |   <div> | ||||||
|     <app-settings-content :header-text="$strings.HeaderBackups" :description="$strings.MessageBackupsDescription"> |     <app-settings-content :header-text="$strings.HeaderBackups" :description="$strings.MessageBackupsDescription"> | ||||||
|       <div v-if="backupLocation" class="flex items-center mb-4"> |       <div v-if="backupLocation" class="mb-4 max-w-full overflow-hidden"> | ||||||
|  |         <div class="flex items-center mb-0.5"> | ||||||
|           <span class="material-icons-outlined text-2xl text-black-50 mr-2">folder</span> |           <span class="material-icons-outlined text-2xl text-black-50 mr-2">folder</span> | ||||||
|         <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelBackupLocation }}:</span> |           <span class="text-white text-opacity-60 uppercase text-sm whitespace-nowrap">{{ $strings.LabelBackupLocation }}:</span> | ||||||
|         <div class="text-gray-100 pl-4">{{ backupLocation }}</div> |         </div> | ||||||
|  |         <div v-if="!showEditBackupPath" class="inline-flex items-center w-full overflow-hidden"> | ||||||
|  |           <p class="text-gray-100 max-w-[calc(100%-40px)] text-sm sm:text-base break-words">{{ backupLocation }}</p> | ||||||
|  |           <div class="w-10 min-w-10 flex items-center justify-center"> | ||||||
|  |             <button class="text-black-50 hover:text-yellow-500 inline-flex" type="button" @click="showEditBackupPath = !showEditBackupPath"> | ||||||
|  |               <span class="material-icons text-lg">edit</span> | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <div v-else> | ||||||
|  |           <form class="flex items-center w-full space-x-1" @submit.prevent="saveBackupPath"> | ||||||
|  |             <ui-text-input v-model="newBackupLocation" :disabled="savingBackupPath" class="w-full max-w-[calc(100%-50px)] text-sm h-8" /> | ||||||
|  |             <ui-btn small :loading="savingBackupPath" color="success" type="submit" class="h-8">{{ $strings.ButtonSave }}</ui-btn> | ||||||
|  |             <ui-btn small :disabled="savingBackupPath" type="button" class="h-8" @click="cancelEditBackupPath">{{ $strings.ButtonCancel }}</ui-btn> | ||||||
|  |           </form> | ||||||
|  |           <p class="text-sm text-warning/80 pt-1">{{ $strings.MessageBackupsLocationEditNote }}</p> | ||||||
|  |         </div> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <div class="flex items-center py-2"> |       <div class="flex items-center py-2"> | ||||||
| @ -15,21 +32,23 @@ | |||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <div v-if="enableBackups" class="mb-6"> |       <div v-if="enableBackups" class="mb-6"> | ||||||
|         <div class="flex items-center pl-6 mb-2"> |         <div class="flex items-center pl-0 sm:pl-6 mb-2"> | ||||||
|           <span class="material-icons-outlined text-2xl text-black-50 mr-2">schedule</span> |           <span class="material-icons-outlined text-xl sm:text-2xl text-black-50 mr-2">schedule</span> | ||||||
|           <div class="w-40"> |           <div class="w-32 min-w-32 sm:w-40 sm:min-w-40"> | ||||||
|             <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span> |             <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span> | ||||||
|           </div> |           </div> | ||||||
|           <div class="text-gray-100">{{ scheduleDescription }}</div> |           <div class="text-gray-100 text-sm sm:text-base">{{ scheduleDescription }}</div> | ||||||
|           <span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer ml-2" @click="showCronBuilder = !showCronBuilder">edit</span> |           <button class="ml-2 text-black-50 hover:text-yellow-500 inline-flex" type="button" @click="showCronBuilder = !showCronBuilder"> | ||||||
|  |             <span class="material-icons text-lg">edit</span> | ||||||
|  |           </button> | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <div v-if="nextBackupDate" class="flex items-center pl-6 py-0.5 px-2"> |         <div v-if="nextBackupDate" class="flex items-center pl-0 sm:pl-6 py-0.5"> | ||||||
|           <span class="material-icons-outlined text-2xl text-black-50 mr-2">event</span> |           <span class="material-icons-outlined text-xl sm:text-2xl text-black-50 mr-2">event</span> | ||||||
|           <div class="w-40"> |           <div class="w-32 min-w-32 sm:w-40 sm:min-w-40"> | ||||||
|             <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span> |             <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span> | ||||||
|           </div> |           </div> | ||||||
|           <div class="text-gray-100">{{ nextBackupDate }}</div> |           <div class="text-gray-100 text-sm sm:text-base">{{ nextBackupDate }}</div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
| @ -49,7 +68,7 @@ | |||||||
|         </ui-tooltip> |         </ui-tooltip> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <tables-backups-table @loaded="backupsLoaded" /> |       <tables-backups-table ref="backupsTable" @loaded="backupsLoaded" /> | ||||||
| 
 | 
 | ||||||
|       <modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" /> |       <modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" /> | ||||||
|     </app-settings-content> |     </app-settings-content> | ||||||
| @ -72,7 +91,10 @@ export default { | |||||||
|       cronExpression: '', |       cronExpression: '', | ||||||
|       newServerSettings: {}, |       newServerSettings: {}, | ||||||
|       showCronBuilder: false, |       showCronBuilder: false, | ||||||
|       backupLocation: '' |       showEditBackupPath: false, | ||||||
|  |       backupLocation: '', | ||||||
|  |       newBackupLocation: '', | ||||||
|  |       savingBackupPath: false | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   watch: { |   watch: { | ||||||
| @ -107,6 +129,39 @@ export default { | |||||||
|   methods: { |   methods: { | ||||||
|     backupsLoaded(backupLocation) { |     backupsLoaded(backupLocation) { | ||||||
|       this.backupLocation = backupLocation |       this.backupLocation = backupLocation | ||||||
|  |       this.newBackupLocation = backupLocation | ||||||
|  |     }, | ||||||
|  |     cancelEditBackupPath() { | ||||||
|  |       this.newBackupLocation = this.backupLocation | ||||||
|  |       this.showEditBackupPath = false | ||||||
|  |     }, | ||||||
|  |     saveBackupPath() { | ||||||
|  |       if (!this.newBackupLocation?.trim()) { | ||||||
|  |         this.$toast.error(this.$strings.MessageBackupsLocationPathEmpty) | ||||||
|  |         return | ||||||
|  |       } | ||||||
|  |       this.newBackupLocation = this.newBackupLocation.trim() | ||||||
|  |       if (this.newBackupLocation === this.backupLocation) { | ||||||
|  |         this.showEditBackupPath = false | ||||||
|  |         return | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       this.savingBackupPath = true | ||||||
|  |       this.$axios | ||||||
|  |         .patch('/api/backups/path', { path: this.newBackupLocation }) | ||||||
|  |         .then(() => { | ||||||
|  |           this.backupLocation = this.newBackupLocation | ||||||
|  |           this.showEditBackupPath = false | ||||||
|  |           this.$refs.backupsTable.loadBackups() | ||||||
|  |         }) | ||||||
|  |         .catch((error) => { | ||||||
|  |           console.error('Failed to save backup path', error) | ||||||
|  |           const errorMsg = error.response?.data || 'Failed to save backup path' | ||||||
|  |           this.$toast.error(errorMsg) | ||||||
|  |         }) | ||||||
|  |         .finally(() => { | ||||||
|  |           this.savingBackupPath = false | ||||||
|  |         }) | ||||||
|     }, |     }, | ||||||
|     updateBackupsSettings() { |     updateBackupsSettings() { | ||||||
|       if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) { |       if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) { | ||||||
|  | |||||||
| @ -597,6 +597,8 @@ | |||||||
|   "MessageAddToPlayerQueue": "Add to player queue", |   "MessageAddToPlayerQueue": "Add to player queue", | ||||||
|   "MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.", |   "MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.", | ||||||
|   "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.", |   "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.", | ||||||
|  |   "MessageBackupsLocationPathEmpty": "Backup location path cannot be empty", | ||||||
|  |   "MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups", | ||||||
|   "MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.", |   "MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.", | ||||||
|   "MessageBookshelfNoCollections": "You haven't made any collections yet", |   "MessageBookshelfNoCollections": "You haven't made any collections yet", | ||||||
|   "MessageBookshelfNoRSSFeeds": "No RSS feeds are open", |   "MessageBookshelfNoRSSFeeds": "No RSS feeds are open", | ||||||
|  | |||||||
| @ -1,5 +1,8 @@ | |||||||
|  | const Path = require('path') | ||||||
|  | const fs = require('../libs/fsExtra') | ||||||
| const Logger = require('../Logger') | const Logger = require('../Logger') | ||||||
| const { encodeUriPath } = require('../utils/fileUtils') | const Database = require('../Database') | ||||||
|  | const fileUtils = require('../utils/fileUtils') | ||||||
| 
 | 
 | ||||||
| class BackupController { | class BackupController { | ||||||
|   constructor() {} |   constructor() {} | ||||||
| @ -31,6 +34,56 @@ class BackupController { | |||||||
|     this.backupManager.uploadBackup(req, res) |     this.backupManager.uploadBackup(req, res) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * PATCH: /api/backups/path | ||||||
|  |    * Update the backup path | ||||||
|  |    * | ||||||
|  |    * @this import('../routers/ApiRouter') | ||||||
|  |    * | ||||||
|  |    * @param {import('express').Request} req | ||||||
|  |    * @param {import('express').Response} res | ||||||
|  |    */ | ||||||
|  |   async updatePath(req, res) { | ||||||
|  |     // Validate path is not empty and is a string
 | ||||||
|  |     if (!req.body.path || !req.body.path?.trim?.()) { | ||||||
|  |       Logger.error('[BackupController] Update backup path invalid') | ||||||
|  |       return res.status(400).send('Invalid request body. Must include path.') | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const newBackupPath = fileUtils.filePathToPOSIX(Path.resolve(req.body.path)) | ||||||
|  | 
 | ||||||
|  |     if (newBackupPath === this.backupManager.backupPath) { | ||||||
|  |       Logger.debug(`[BackupController] Backup path unchanged: ${newBackupPath}`) | ||||||
|  |       return res.status(200).send('Backup path unchanged') | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     Logger.info(`[BackupController] Updating backup path to "${newBackupPath}" from "${this.backupManager.backupPath}"`) | ||||||
|  | 
 | ||||||
|  |     // Check if backup path is set in environment variable
 | ||||||
|  |     if (process.env.BACKUP_PATH) { | ||||||
|  |       Logger.warn(`[BackupController] Backup path is set in environment variable BACKUP_PATH. Backup path will be reverted on server restart.`) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Validate backup path is writable and create folder if it does not exist
 | ||||||
|  |     try { | ||||||
|  |       const direxists = await fs.pathExists(newBackupPath) | ||||||
|  |       if (!direxists) { | ||||||
|  |         // If folder does not exist try to make it
 | ||||||
|  |         await fs.mkdir(newBackupPath) | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       Logger.error(`[BackupController] updatePath: Failed to ensure backup path "${newBackupPath}"`, error) | ||||||
|  |       return res.status(400).send(`Invalid backup path "${req.body.path}"`) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     Database.serverSettings.backupPath = newBackupPath | ||||||
|  |     await Database.updateServerSettings() | ||||||
|  | 
 | ||||||
|  |     await this.backupManager.reload() | ||||||
|  | 
 | ||||||
|  |     res.sendStatus(200) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * api/backups/:id/download |    * api/backups/:id/download | ||||||
|    * |    * | ||||||
| @ -39,7 +92,7 @@ class BackupController { | |||||||
|    */ |    */ | ||||||
|   download(req, res) { |   download(req, res) { | ||||||
|     if (global.XAccel) { |     if (global.XAccel) { | ||||||
|       const encodedURI = encodeUriPath(global.XAccel + req.backup.fullPath) |       const encodedURI = fileUtils.encodeUriPath(global.XAccel + req.backup.fullPath) | ||||||
|       Logger.debug(`Use X-Accel to serve static file ${encodedURI}`) |       Logger.debug(`Use X-Accel to serve static file ${encodedURI}`) | ||||||
|       return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() |       return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -51,6 +51,16 @@ class BackupManager { | |||||||
|     this.scheduleCron() |     this.scheduleCron() | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * Reload backups after updating backup path | ||||||
|  |    */ | ||||||
|  |   async reload() { | ||||||
|  |     Logger.info(`[BackupManager] Reloading backups with backup path "${this.backupPath}"`) | ||||||
|  |     this.backups = [] | ||||||
|  |     await this.loadBackups() | ||||||
|  |     this.updateCronSchedule() | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   scheduleCron() { |   scheduleCron() { | ||||||
|     if (!this.backupSchedule) { |     if (!this.backupSchedule) { | ||||||
|       Logger.info(`[BackupManager] Auto Backups are disabled`) |       Logger.info(`[BackupManager] Auto Backups are disabled`) | ||||||
|  | |||||||
| @ -40,6 +40,7 @@ class ApiRouter { | |||||||
|     this.auth = Server.auth |     this.auth = Server.auth | ||||||
|     this.playbackSessionManager = Server.playbackSessionManager |     this.playbackSessionManager = Server.playbackSessionManager | ||||||
|     this.abMergeManager = Server.abMergeManager |     this.abMergeManager = Server.abMergeManager | ||||||
|  |     /** @type {import('../managers/BackupManager')} */ | ||||||
|     this.backupManager = Server.backupManager |     this.backupManager = Server.backupManager | ||||||
|     /** @type {import('../Watcher')} */ |     /** @type {import('../Watcher')} */ | ||||||
|     this.watcher = Server.watcher |     this.watcher = Server.watcher | ||||||
| @ -193,6 +194,7 @@ class ApiRouter { | |||||||
|     this.router.get('/backups/:id/download', BackupController.middleware.bind(this), BackupController.download.bind(this)) |     this.router.get('/backups/:id/download', BackupController.middleware.bind(this), BackupController.download.bind(this)) | ||||||
|     this.router.get('/backups/:id/apply', BackupController.middleware.bind(this), BackupController.apply.bind(this)) |     this.router.get('/backups/:id/apply', BackupController.middleware.bind(this), BackupController.apply.bind(this)) | ||||||
|     this.router.post('/backups/upload', BackupController.middleware.bind(this), BackupController.upload.bind(this)) |     this.router.post('/backups/upload', BackupController.middleware.bind(this), BackupController.upload.bind(this)) | ||||||
|  |     this.router.patch('/backups/path', BackupController.middleware.bind(this), BackupController.updatePath.bind(this)) | ||||||
| 
 | 
 | ||||||
|     //
 |     //
 | ||||||
|     // File System Routes
 |     // File System Routes
 | ||||||
| @ -308,7 +310,6 @@ class ApiRouter { | |||||||
|     this.router.post('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.create.bind(this)) |     this.router.post('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.create.bind(this)) | ||||||
|     this.router.delete('/custom-metadata-providers/:id', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.delete.bind(this)) |     this.router.delete('/custom-metadata-providers/:id', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.delete.bind(this)) | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|     //
 |     //
 | ||||||
|     // Misc Routes
 |     // Misc Routes
 | ||||||
|     //
 |     //
 | ||||||
| @ -567,10 +568,13 @@ class ApiRouter { | |||||||
|           } |           } | ||||||
|         } |         } | ||||||
|         // Remove authors without an id
 |         // Remove authors without an id
 | ||||||
|         mediaMetadata.authors = mediaMetadata.authors.filter(au => !!au.id) |         mediaMetadata.authors = mediaMetadata.authors.filter((au) => !!au.id) | ||||||
|         if (newAuthors.length) { |         if (newAuthors.length) { | ||||||
|           await Database.createBulkAuthors(newAuthors) |           await Database.createBulkAuthors(newAuthors) | ||||||
|           SocketAuthority.emitter('authors_added', newAuthors.map(au => au.toJSON())) |           SocketAuthority.emitter( | ||||||
|  |             'authors_added', | ||||||
|  |             newAuthors.map((au) => au.toJSON()) | ||||||
|  |           ) | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
| @ -611,10 +615,13 @@ class ApiRouter { | |||||||
|           } |           } | ||||||
|         } |         } | ||||||
|         // Remove series without an id
 |         // Remove series without an id
 | ||||||
|         mediaMetadata.series = mediaMetadata.series.filter(se => se.id) |         mediaMetadata.series = mediaMetadata.series.filter((se) => se.id) | ||||||
|         if (newSeries.length) { |         if (newSeries.length) { | ||||||
|           await Database.createBulkSeries(newSeries) |           await Database.createBulkSeries(newSeries) | ||||||
|           SocketAuthority.emitter('multiple_series_added', newSeries.map(se => se.toJSON())) |           SocketAuthority.emitter( | ||||||
|  |             'multiple_series_added', | ||||||
|  |             newSeries.map((se) => se.toJSON()) | ||||||
|  |           ) | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user