Add:Purge media progress button & api endpoint for items that no longer exist #921

This commit is contained in:
advplyr 2022-09-25 17:11:39 -05:00
parent 97da73baf3
commit 162a1b7971
4 changed files with 129 additions and 53 deletions

View File

@ -46,7 +46,14 @@
<div class="w-full h-px bg-white bg-opacity-10 my-2" /> <div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-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> <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"> <tr class="bg-primary bg-opacity-40">
<th class="w-16 text-left">Item</th> <th class="w-16 text-left">Item</th>
<th class="text-left"></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">Started At</th>
<th class="w-40 hidden sm:table-cell">Last Update</th> <th class="w-40 hidden sm:table-cell">Last Update</th>
</tr> </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> <td>
<covers-book-cover :width="50" :library-item="item" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :width="50" :library-item="item" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</td> </td>
<td class="font-book"> <td class="font-book">
<p>{{ item.media && item.media.metadata ? item.media.metadata.title : 'Unknown' }}</p> <template v-if="item.media && item.media.metadata && item.episode">
<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> <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>
<td class="text-center"> <td class="text-center">
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p> <p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
@ -98,7 +111,8 @@ export default {
data() { data() {
return { return {
listeningSessions: [], listeningSessions: [],
listeningStats: {} listeningStats: {},
purgingMediaProgress: false
} }
}, },
computed: { computed: {
@ -117,6 +131,12 @@ export default {
mediaProgress() { mediaProgress() {
return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate) 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() { totalListeningTime() {
return this.listeningStats.totalTime || 0 return this.listeningStats.totalTime || 0
}, },
@ -150,6 +170,24 @@ export default {
return [] return []
}) })
console.log('Loaded user listening data', this.listeningSessions, this.listeningStats) 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() { mounted() {

View File

@ -40,13 +40,15 @@ export const getters = {
// Absolute URL covers (should no longer be used) // Absolute URL covers (should no longer be used)
if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath
var userToken = rootGetters['user/getToken'] const userToken = rootGetters['user/getToken']
var lastUpdate = libraryItem.updatedAt || Date.now() 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 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') => { getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = '/book_placeholder.jpg') => {
if (!libraryItemId) return placeholder if (!libraryItemId) return placeholder

View File

@ -28,10 +28,6 @@ class UserController {
} }
async create(req, res) { 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 account = req.body
var username = account.username var username = account.username
@ -58,15 +54,7 @@ class UserController {
} }
async update(req, res) { async update(req, res) {
if (!req.user.isAdminOrUp) { var user = req.reqUser
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)
}
if (user.type === 'root' && !req.user.isRoot) { if (user.type === 'root' && !req.user.isRoot) {
Logger.error(`[UserController] Admin user attempted to update root user`, req.user.username) 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`) Logger.info(`[UserController] User ${user.username} was generated a new api token`)
} }
await this.db.updateEntity('user', user) 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({ res.json({
success: true, success: true,
user: user.toJSONForBrowser() user: user.toJSONForBrowser()
@ -107,24 +95,15 @@ class UserController {
} }
async delete(req, res) { 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') { if (req.params.id === 'root') {
Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted')
return res.sendStatus(500) return res.sendStatus(500)
} }
if (req.user.id === req.params.id) { 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) return res.sendStatus(500)
} }
var user = this.db.users.find(u => u.id === req.params.id) var user = req.reqUser
if (!user) {
Logger.error('User not found')
return res.json({
error: 'User not found'
})
}
// delete user collections // delete user collections
var userCollections = this.db.collections.filter(c => c.userId === user.id) var userCollections = this.db.collections.filter(c => c.userId === user.id)
@ -145,10 +124,6 @@ class UserController {
// GET: api/users/:id/listening-sessions // GET: api/users/:id/listening-sessions
async getListeningSessions(req, res) { 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) var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10 const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
@ -170,11 +145,59 @@ class UserController {
// GET: api/users/:id/listening-stats // GET: api/users/:id/listening-stats
async getListeningStats(req, res) { 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) var listeningStats = await this.getUserListeningStatsHelpers(req.params.id)
res.json(listeningStats) 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() module.exports = new UserController()

View File

@ -109,14 +109,15 @@ class ApiRouter {
// //
// User Routes // User Routes
// //
this.router.post('/users', UserController.create.bind(this)) this.router.post('/users', UserController.middleware.bind(this), UserController.create.bind(this))
this.router.get('/users', UserController.findAll.bind(this)) this.router.get('/users', UserController.middleware.bind(this), UserController.findAll.bind(this))
this.router.get('/users/:id', UserController.findOne.bind(this)) this.router.get('/users/:id', UserController.middleware.bind(this), UserController.findOne.bind(this))
this.router.patch('/users/:id', UserController.update.bind(this)) this.router.patch('/users/:id', UserController.middleware.bind(this), UserController.update.bind(this))
this.router.delete('/users/:id', UserController.delete.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-sessions', UserController.middleware.bind(this), UserController.getListeningSessions.bind(this))
this.router.get('/users/:id/listening-stats', UserController.getListeningStats.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 // Collection Routes
@ -274,12 +275,24 @@ class ApiRouter {
} }
json.mediaProgress = json.mediaProgress.map(lip => { 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) { if (!libraryItem) {
Logger.warn('[ApiRouter] Library item not found for users progress ' + lip.id) Logger.warn('[ApiRouter] Library item not found for users progress ' + lip.libraryItemId)
return null 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()
}
} }
lip.media = libraryItem.media.toJSONExpanded()
return lip return lip
}).filter(lip => !!lip) }).filter(lip => !!lip)