mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Add:Purge media progress button & api endpoint for items that no longer exist #921
This commit is contained in:
		
							parent
							
								
									97da73baf3
								
							
						
					
					
						commit
						162a1b7971
					
				| @ -46,7 +46,14 @@ | ||||
|       <div class="w-full h-px bg-white bg-opacity-10 my-2" /> | ||||
|       <div class="py-2"> | ||||
|         <h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Saved Media Progress</h1> | ||||
|         <table v-if="mediaProgress.length" class="userAudiobooksTable"> | ||||
| 
 | ||||
|         <div v-if="mediaProgressWithoutMedia.length" class="flex items-center py-2 mb-2"> | ||||
|           <p class="text-error">User has media progress for {{ mediaProgressWithoutMedia.length }} items that no longer exist.</p> | ||||
|           <div class="flex-grow" /> | ||||
|           <ui-btn small :loading="purgingMediaProgress" @click.stop="purgeMediaProgress">Purge Media Progress</ui-btn> | ||||
|         </div> | ||||
| 
 | ||||
|         <table v-if="mediaProgressWithMedia.length" class="userAudiobooksTable"> | ||||
|           <tr class="bg-primary bg-opacity-40"> | ||||
|             <th class="w-16 text-left">Item</th> | ||||
|             <th class="text-left"></th> | ||||
| @ -54,13 +61,19 @@ | ||||
|             <th class="w-40 hidden sm:table-cell">Started At</th> | ||||
|             <th class="w-40 hidden sm:table-cell">Last Update</th> | ||||
|           </tr> | ||||
|           <tr v-for="item in mediaProgress" :key="item.id" :class="!item.isFinished ? '' : 'isFinished'"> | ||||
|           <tr v-for="item in mediaProgressWithMedia" :key="item.id" :class="!item.isFinished ? '' : 'isFinished'"> | ||||
|             <td> | ||||
|               <covers-book-cover :width="50" :library-item="item" :book-cover-aspect-ratio="bookCoverAspectRatio" /> | ||||
|             </td> | ||||
|             <td class="font-book"> | ||||
|               <p>{{ item.media && item.media.metadata ? item.media.metadata.title : 'Unknown' }}</p> | ||||
|               <p v-if="item.media && item.media.metadata && item.media.metadata.authorName" class="text-white text-opacity-50 text-sm font-sans">by {{ item.media.metadata.authorName }}</p> | ||||
|               <template v-if="item.media && item.media.metadata && item.episode"> | ||||
|                 <p>{{ item.episode.title || 'Unknown' }}</p> | ||||
|                 <p class="text-white text-opacity-50 text-sm font-sans">{{ item.media.metadata.title }}</p> | ||||
|               </template> | ||||
|               <template v-else-if="item.media && item.media.metadata"> | ||||
|                 <p>{{ item.media.metadata.title || 'Unknown' }}</p> | ||||
|                 <p v-if="item.media.metadata.authorName" class="text-white text-opacity-50 text-sm font-sans">by {{ item.media.metadata.authorName }}</p> | ||||
|               </template> | ||||
|             </td> | ||||
|             <td class="text-center"> | ||||
|               <p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p> | ||||
| @ -98,7 +111,8 @@ export default { | ||||
|   data() { | ||||
|     return { | ||||
|       listeningSessions: [], | ||||
|       listeningStats: {} | ||||
|       listeningStats: {}, | ||||
|       purgingMediaProgress: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
| @ -117,6 +131,12 @@ export default { | ||||
|     mediaProgress() { | ||||
|       return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate) | ||||
|     }, | ||||
|     mediaProgressWithMedia() { | ||||
|       return this.mediaProgress.filter((mp) => mp.media) | ||||
|     }, | ||||
|     mediaProgressWithoutMedia() { | ||||
|       return this.mediaProgress.filter((mp) => !mp.media) | ||||
|     }, | ||||
|     totalListeningTime() { | ||||
|       return this.listeningStats.totalTime || 0 | ||||
|     }, | ||||
| @ -150,6 +170,24 @@ export default { | ||||
|         return [] | ||||
|       }) | ||||
|       console.log('Loaded user listening data', this.listeningSessions, this.listeningStats) | ||||
|     }, | ||||
|     purgeMediaProgress() { | ||||
|       this.purgingMediaProgress = true | ||||
| 
 | ||||
|       this.$axios | ||||
|         .$post(`/api/users/${this.user.id}/purge-media-progress`) | ||||
|         .then((updatedUser) => { | ||||
|           console.log('Updated user', updatedUser) | ||||
|           this.$toast.success('Media progress purged') | ||||
|           this.user = updatedUser | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Failed to purge media progress', error) | ||||
|           this.$toast.error('Failed to purge media progress') | ||||
|         }) | ||||
|         .finally(() => { | ||||
|           this.purgingMediaProgress = false | ||||
|         }) | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|  | ||||
| @ -40,13 +40,15 @@ export const getters = { | ||||
|     // Absolute URL covers (should no longer be used)
 | ||||
|     if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath | ||||
| 
 | ||||
|     var userToken = rootGetters['user/getToken'] | ||||
|     var lastUpdate = libraryItem.updatedAt || Date.now() | ||||
|     const userToken = rootGetters['user/getToken'] | ||||
|     const lastUpdate = libraryItem.updatedAt || Date.now() | ||||
|     const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers
 | ||||
| 
 | ||||
|     if (process.env.NODE_ENV !== 'production') { // Testing
 | ||||
|       return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}` | ||||
|       return `http://localhost:3333/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}` | ||||
|     } | ||||
|     return `/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}` | ||||
| 
 | ||||
|     return `/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}` | ||||
|   }, | ||||
|   getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = '/book_placeholder.jpg') => { | ||||
|     if (!libraryItemId) return placeholder | ||||
|  | ||||
| @ -28,10 +28,6 @@ class UserController { | ||||
|   } | ||||
| 
 | ||||
|   async create(req, res) { | ||||
|     if (!req.user.isAdminOrUp) { | ||||
|       Logger.warn('Non-admin user attempted to create user', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var account = req.body | ||||
| 
 | ||||
|     var username = account.username | ||||
| @ -58,15 +54,7 @@ class UserController { | ||||
|   } | ||||
| 
 | ||||
|   async update(req, res) { | ||||
|     if (!req.user.isAdminOrUp) { | ||||
|       Logger.error('[UserController] User other than admin attempting to update user', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
| 
 | ||||
|     var user = this.db.users.find(u => u.id === req.params.id) | ||||
|     if (!user) { | ||||
|       return res.sendStatus(404) | ||||
|     } | ||||
|     var user = req.reqUser | ||||
| 
 | ||||
|     if (user.type === 'root' && !req.user.isRoot) { | ||||
|       Logger.error(`[UserController] Admin user attempted to update root user`, req.user.username) | ||||
| @ -97,9 +85,9 @@ class UserController { | ||||
|         Logger.info(`[UserController] User ${user.username} was generated a new api token`) | ||||
|       } | ||||
|       await this.db.updateEntity('user', user) | ||||
|       this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser()) | ||||
|     } | ||||
| 
 | ||||
|     this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser()) | ||||
|     res.json({ | ||||
|       success: true, | ||||
|       user: user.toJSONForBrowser() | ||||
| @ -107,24 +95,15 @@ class UserController { | ||||
|   } | ||||
| 
 | ||||
|   async delete(req, res) { | ||||
|     if (!req.user.isAdminOrUp) { | ||||
|       Logger.error('User other than admin attempting to delete user', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     if (req.params.id === 'root') { | ||||
|       Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted') | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
|     if (req.user.id === req.params.id) { | ||||
|       Logger.error('Attempting to delete themselves...') | ||||
|       Logger.error(`[UserController] ${req.user.username} is attempting to delete themselves... why? WHY?`) | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
|     var user = this.db.users.find(u => u.id === req.params.id) | ||||
|     if (!user) { | ||||
|       Logger.error('User not found') | ||||
|       return res.json({ | ||||
|         error: 'User not found' | ||||
|       }) | ||||
|     } | ||||
|     var user = req.reqUser | ||||
| 
 | ||||
|     // delete user collections
 | ||||
|     var userCollections = this.db.collections.filter(c => c.userId === user.id) | ||||
| @ -145,10 +124,6 @@ class UserController { | ||||
| 
 | ||||
|   // GET: api/users/:id/listening-sessions
 | ||||
|   async getListeningSessions(req, res) { | ||||
|     if (!req.user.isAdminOrUp && req.user.id !== req.params.id) { | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
| 
 | ||||
|     var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id) | ||||
| 
 | ||||
|     const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10 | ||||
| @ -170,11 +145,59 @@ class UserController { | ||||
| 
 | ||||
|   // GET: api/users/:id/listening-stats
 | ||||
|   async getListeningStats(req, res) { | ||||
|     if (!req.user.isAdminOrUp && req.user.id !== req.params.id) { | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var listeningStats = await this.getUserListeningStatsHelpers(req.params.id) | ||||
|     res.json(listeningStats) | ||||
|   } | ||||
| 
 | ||||
|   // POST: api/users/:id/purge-media-progress
 | ||||
|   async purgeMediaProgress(req, res) { | ||||
|     const user = req.reqUser | ||||
| 
 | ||||
|     if (user.type === 'root' && !req.user.isRoot) { | ||||
|       Logger.error(`[UserController] Admin user attempted to purge media progress of root user`, req.user.username) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
| 
 | ||||
|     var progressPurged = 0 | ||||
|     user.mediaProgress = user.mediaProgress.filter(mp => { | ||||
|       const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId) | ||||
|       if (!libraryItem) { | ||||
|         progressPurged++ | ||||
|         return false | ||||
|       } else if (mp.episodeId) { | ||||
|         const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(mp.episodeId) : null | ||||
|         if (!episode) { // Episode not found
 | ||||
|           progressPurged++ | ||||
|           return false | ||||
|         } | ||||
|       } | ||||
|       return true | ||||
|     }) | ||||
| 
 | ||||
|     if (progressPurged) { | ||||
|       Logger.info(`[UserController] Purged ${progressPurged} media progress for user ${user.username}`) | ||||
|       await this.db.updateEntity('user', user) | ||||
|       this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser()) | ||||
|     } | ||||
| 
 | ||||
|     res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot)) | ||||
|   } | ||||
| 
 | ||||
|   middleware(req, res, next) { | ||||
|     if (!req.user.isAdminOrUp && req.user.id !== req.params.id) { | ||||
|       return res.sendStatus(403) | ||||
|     } else if ((req.method == 'PATCH' || req.method == 'POST' || req.method == 'DELETE') && !req.user.isAdminOrUp) { | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
| 
 | ||||
|     if (req.params.id) { | ||||
|       req.reqUser = this.db.users.find(u => u.id === req.params.id) | ||||
|       if (!req.reqUser) { | ||||
|         return res.sendStatus(404) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     next() | ||||
|   } | ||||
| } | ||||
| module.exports = new UserController() | ||||
| @ -109,14 +109,15 @@ class ApiRouter { | ||||
|     //
 | ||||
|     // User Routes
 | ||||
|     //
 | ||||
|     this.router.post('/users', UserController.create.bind(this)) | ||||
|     this.router.get('/users', UserController.findAll.bind(this)) | ||||
|     this.router.get('/users/:id', UserController.findOne.bind(this)) | ||||
|     this.router.patch('/users/:id', UserController.update.bind(this)) | ||||
|     this.router.delete('/users/:id', UserController.delete.bind(this)) | ||||
|     this.router.post('/users', UserController.middleware.bind(this), UserController.create.bind(this)) | ||||
|     this.router.get('/users', UserController.middleware.bind(this), UserController.findAll.bind(this)) | ||||
|     this.router.get('/users/:id', UserController.middleware.bind(this), UserController.findOne.bind(this)) | ||||
|     this.router.patch('/users/:id', UserController.middleware.bind(this), UserController.update.bind(this)) | ||||
|     this.router.delete('/users/:id', UserController.middleware.bind(this), UserController.delete.bind(this)) | ||||
| 
 | ||||
|     this.router.get('/users/:id/listening-sessions', UserController.getListeningSessions.bind(this)) | ||||
|     this.router.get('/users/:id/listening-stats', UserController.getListeningStats.bind(this)) | ||||
|     this.router.get('/users/:id/listening-sessions', UserController.middleware.bind(this), UserController.getListeningSessions.bind(this)) | ||||
|     this.router.get('/users/:id/listening-stats', UserController.middleware.bind(this), UserController.getListeningStats.bind(this)) | ||||
|     this.router.post('/users/:id/purge-media-progress', UserController.middleware.bind(this), UserController.purgeMediaProgress.bind(this)) | ||||
| 
 | ||||
|     //
 | ||||
|     // Collection Routes
 | ||||
| @ -274,12 +275,24 @@ class ApiRouter { | ||||
|     } | ||||
| 
 | ||||
|     json.mediaProgress = json.mediaProgress.map(lip => { | ||||
|       var libraryItem = this.db.libraryItems.find(li => li.id === lip.id) | ||||
|       var libraryItem = this.db.libraryItems.find(li => li.id === lip.libraryItemId) | ||||
|       if (!libraryItem) { | ||||
|         Logger.warn('[ApiRouter] Library item not found for users progress ' + lip.id) | ||||
|         return null | ||||
|       } | ||||
|         Logger.warn('[ApiRouter] Library item not found for users progress ' + lip.libraryItemId) | ||||
|         lip.media = null | ||||
|       } else { | ||||
|         if (lip.episodeId) { | ||||
|           const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(lip.episodeId) : null | ||||
|           if (!episode) { | ||||
|             Logger.warn(`[ApiRouter] Episode ${lip.episodeId} not found for user media progress, podcast: ${libraryItem.media.metadata.title}`) | ||||
|             lip.media = null | ||||
|           } else { | ||||
|             lip.media = libraryItem.media.toJSONExpanded() | ||||
|             lip.episode = episode.toJSON() | ||||
|           } | ||||
|         } else { | ||||
|           lip.media = libraryItem.media.toJSONExpanded() | ||||
|         } | ||||
|       } | ||||
|       return lip | ||||
|     }).filter(lip => !!lip) | ||||
| 
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user