From 728496010cbfcee5b7b54001c9f79e02ede30d82 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Dec 2023 10:41:39 -0600 Subject: [PATCH 01/24] Update:/auth/openid/config API endpoint to require admin user and validate issuer URL --- server/Auth.js | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 0a282c9c9..d2334de26 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -296,7 +296,7 @@ class Auth { if (req.query.redirect_uri) { // Check if the redirect_uri is in the whitelist if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) || - (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) { + (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) { oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString() mobile_redirect_uri = req.query.redirect_uri } else { @@ -381,7 +381,7 @@ class Auth { try { // Extract the state parameter from the request const { state, code } = req.query - + // Check if the state provided is in our list if (!state || !this.openIdAuthSession.has(state)) { Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch') @@ -469,17 +469,38 @@ class Auth { this.handleLoginSuccessBasedOnCookie.bind(this)) /** - * Used to auto-populate the openid URLs in config/authentication + * Helper route used to auto-populate the openid URLs in config/authentication + * Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration" + * + * @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/ */ - router.get('/auth/openid/config', async (req, res) => { + router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => { + if (!req.user.isAdminOrUp) { + Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`) + return res.sendStatus(403) + } + if (!req.query.issuer) { return res.status(400).send('Invalid request. Query param \'issuer\' is required') } + + // Strip trailing slash let issuerUrl = req.query.issuer if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) - const configUrl = `${issuerUrl}/.well-known/openid-configuration` - axios.get(configUrl).then(({ data }) => { + // Append config pathname and validate URL + let configUrl = null + try { + configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`) + if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) { + throw new Error('Invalid pathname') + } + } catch (error) { + Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) + return res.status(400).send('Invalid request. Query param \'issuer\' is invalid') + } + + axios.get(configUrl.toString()).then(({ data }) => { res.json({ issuer: data.issuer, authorization_endpoint: data.authorization_endpoint, From 8966dbbcd1fa24825e03695a1d3fb87f01a6066e Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Dec 2023 11:06:03 -0600 Subject: [PATCH 02/24] Fix:Restrict podcast search page to admins --- client/pages/library/_library/podcast/search.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/pages/library/_library/podcast/search.vue b/client/pages/library/_library/podcast/search.vue index cde844681..3be851ce7 100644 --- a/client/pages/library/_library/podcast/search.vue +++ b/client/pages/library/_library/podcast/search.vue @@ -45,6 +45,11 @@ From 2b7122c7447943afe8d581a91bed916e1c044fdf Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 20 Dec 2023 17:18:21 -0600 Subject: [PATCH 12/24] Update:Year stats API endpoint & generate year in review image #2373 --- client/components/stats/YearInReview.vue | 175 +++++++++++++++++++++++ client/pages/config/stats.vue | 18 ++- server/utils/queries/userStats.js | 93 +++++++----- 3 files changed, 248 insertions(+), 38 deletions(-) create mode 100644 client/components/stats/YearInReview.vue diff --git a/client/components/stats/YearInReview.vue b/client/components/stats/YearInReview.vue new file mode 100644 index 000000000..fa57020a1 --- /dev/null +++ b/client/components/stats/YearInReview.vue @@ -0,0 +1,175 @@ + + + \ No newline at end of file diff --git a/client/pages/config/stats.vue b/client/pages/config/stats.vue index 9b8f7ea59..b527ea38a 100644 --- a/client/pages/config/stats.vue +++ b/client/pages/config/stats.vue @@ -62,6 +62,13 @@ + + Year in Review +
+
+ + +
@@ -71,7 +78,9 @@ export default { data() { return { listeningStats: null, - windowWidth: 0 + windowWidth: 0, + showYearInReview: false, + processingYearInReview: false } }, watch: { @@ -114,6 +123,13 @@ export default { } }, methods: { + clickShowYearInReview() { + if (this.showYearInReview) { + this.$refs.yearInReview.refresh() + } else { + this.showYearInReview = true + } + }, async init() { this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => { console.error('Failed to load listening sesions', err) diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js index b6895008a..f9b9684ef 100644 --- a/server/utils/queries/userStats.js +++ b/server/utils/queries/userStats.js @@ -2,7 +2,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') const PlaybackSession = require('../../models/PlaybackSession') const MediaProgress = require('../../models/MediaProgress') -const { elapsedPretty } = require('../index') +const fsExtra = require('../../libs/fsExtra') module.exports = { /** @@ -18,8 +18,21 @@ module.exports = { createdAt: { [Sequelize.Op.gte]: `${year}-01-01`, [Sequelize.Op.lt]: `${year + 1}-01-01` + }, + timeListening: { + [Sequelize.Op.gt]: 5 } - } + }, + include: { + model: Database.bookModel, + attributes: ['id', 'coverPath'], + include: { + model: Database.libraryItemModel, + attributes: ['id', 'mediaId', 'mediaType'] + }, + required: false + }, + order: Database.sequelize.random() }) return sessions }, @@ -42,6 +55,10 @@ module.exports = { }, include: { model: Database.bookModel, + include: { + model: Database.libraryItemModel, + attributes: ['id', 'mediaId', 'mediaType'] + }, required: true } }) @@ -63,8 +80,15 @@ module.exports = { let genreListeningMap = {} let narratorListeningMap = {} let monthListeningMap = {} + let bookListeningMap = {} + const booksWithCovers = [] + + for (const ls of listeningSessions) { + // Grab first 16 that have a cover + if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 16 && await fsExtra.pathExists(ls.mediaItem.coverPath)) { + booksWithCovers.push(ls.mediaItem.libraryItem.id) + } - listeningSessions.forEach((ls) => { const listeningSessionListeningTime = ls.timeListening || 0 const lsMonth = ls.createdAt.getMonth() @@ -75,6 +99,12 @@ module.exports = { if (ls.mediaItemType === 'book') { totalBookListeningTime += listeningSessionListeningTime + if (ls.displayTitle && !bookListeningMap[ls.displayTitle]) { + bookListeningMap[ls.displayTitle] = listeningSessionListeningTime + } else if (ls.displayTitle) { + bookListeningMap[ls.displayTitle] += listeningSessionListeningTime + } + const authors = ls.mediaMetadata.authors || [] authors.forEach((au) => { if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0 @@ -96,64 +126,54 @@ module.exports = { } else { totalPodcastListeningTime += listeningSessionListeningTime } - }) + } totalListeningTime = Math.round(totalListeningTime) totalBookListeningTime = Math.round(totalBookListeningTime) totalPodcastListeningTime = Math.round(totalPodcastListeningTime) - let mostListenedAuthor = null - for (const authorName in authorListeningMap) { - if (!mostListenedAuthor?.time || authorListeningMap[authorName] > mostListenedAuthor.time) { - mostListenedAuthor = { - time: Math.round(authorListeningMap[authorName]), - pretty: elapsedPretty(Math.round(authorListeningMap[authorName])), - name: authorName - } - } - } + let topAuthors = null + topAuthors = Object.keys(authorListeningMap).map(authorName => ({ + name: authorName, + time: Math.round(authorListeningMap[authorName]) + })).sort((a, b) => b.time - a.time).slice(0, 3) + let mostListenedNarrator = null for (const narrator in narratorListeningMap) { if (!mostListenedNarrator?.time || narratorListeningMap[narrator] > mostListenedNarrator.time) { mostListenedNarrator = { time: Math.round(narratorListeningMap[narrator]), - pretty: elapsedPretty(Math.round(narratorListeningMap[narrator])), name: narrator } } } - let mostListenedGenre = null - for (const genre in genreListeningMap) { - if (!mostListenedGenre?.time || genreListeningMap[genre] > mostListenedGenre.time) { - mostListenedGenre = { - time: Math.round(genreListeningMap[genre]), - pretty: elapsedPretty(Math.round(genreListeningMap[genre])), - name: genre - } - } - } + + let topGenres = null + topGenres = Object.keys(genreListeningMap).map(genre => ({ + genre, + time: Math.round(genreListeningMap[genre]) + })).sort((a, b) => b.time - a.time).slice(0, 3) + let mostListenedMonth = null for (const month in monthListeningMap) { if (!mostListenedMonth?.time || monthListeningMap[month] > mostListenedMonth.time) { mostListenedMonth = { month: Number(month), - time: Math.round(monthListeningMap[month]), - pretty: elapsedPretty(Math.round(monthListeningMap[month])) + time: Math.round(monthListeningMap[month]) } } } - const bookProgresses = await this.getBookMediaProgressFinishedForYear(userId, year) + const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year) - const numBooksFinished = bookProgresses.length + const numBooksFinished = bookProgressesFinished.length let longestAudiobookFinished = null - bookProgresses.forEach((mediaProgress) => { + bookProgressesFinished.forEach((mediaProgress) => { if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) { longestAudiobookFinished = { id: mediaProgress.mediaItem.id, title: mediaProgress.mediaItem.title, duration: Math.round(mediaProgress.duration), - durationPretty: elapsedPretty(Math.round(mediaProgress.duration)), finishedAt: mediaProgress.finishedAt } } @@ -162,17 +182,16 @@ module.exports = { return { totalListeningSessions: listeningSessions.length, totalListeningTime, - totalListeningTimePretty: elapsedPretty(totalListeningTime), totalBookListeningTime, - totalBookListeningTimePretty: elapsedPretty(totalBookListeningTime), totalPodcastListeningTime, - totalPodcastListeningTimePretty: elapsedPretty(totalPodcastListeningTime), - mostListenedAuthor, + topAuthors, + topGenres, mostListenedNarrator, - mostListenedGenre, mostListenedMonth, numBooksFinished, - longestAudiobookFinished + numBooksListened: Object.keys(bookListeningMap).length, + longestAudiobookFinished, + booksWithCovers } } } From 46ec59c74e5c9622b4053420dcfdc97f1b51d710 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 21 Dec 2023 09:44:37 -0600 Subject: [PATCH 13/24] Update:Year in review card prevent text overflow for narrator, author and genre #2373 --- client/components/stats/YearInReview.vue | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/client/components/stats/YearInReview.vue b/client/components/stats/YearInReview.vue index fa57020a1..74c57065a 100644 --- a/client/components/stats/YearInReview.vue +++ b/client/components/stats/YearInReview.vue @@ -37,10 +37,24 @@ export default { ctx.stroke() } - const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y) => { + const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => { ctx.fillStyle = color ctx.font = `${fontWeight} ${fontSize} Source Sans Pro` ctx.letterSpacing = letterSpacing + + // If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis + if (maxWidth) { + let txtWidth = ctx.measureText(text).width + while (txtWidth > maxWidth) { + console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`) + if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time + else text = text.slice(0, -3) // First check remove last 3 chars + text += '...' + txtWidth = ctx.measureText(text).width + console.log(`Checking text "${text}" (width:${txtWidth})`) + } + } + ctx.fillText(text, x, y) } @@ -131,21 +145,21 @@ export default { const topNarrator = this.yearStats.mostListenedNarrator if (topNarrator) { addText('TOP NARRATOR', '12px', 'normal', tanColor, '1px', 20, 260) - addText(topNarrator.name, '18px', 'bolder', 'white', '0px', 20, 282) + addText(topNarrator.name, '18px', 'bolder', 'white', '0px', 20, 282, 180) addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '14px', 'lighter', 'white', '1px', 20, 302) } const topGenre = this.yearStats.topGenres[0] if (topGenre) { addText('TOP GENRE', '12px', 'normal', tanColor, '1px', 215, 260) - addText(topGenre.genre, '18px', 'bolder', 'white', '0px', 215, 282) + addText(topGenre.genre, '18px', 'bolder', 'white', '0px', 215, 282, 180) addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '14px', 'lighter', 'white', '1px', 215, 302) } const topAuthor = this.yearStats.topAuthors[0] if (topAuthor) { addText('TOP AUTHOR', '12px', 'normal', tanColor, '1px', 20, 335) - addText(topAuthor.name, '18px', 'bolder', 'white', '0px', 20, 357) + addText(topAuthor.name, '18px', 'bolder', 'white', '0px', 20, 357, 180) addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '14px', 'lighter', 'white', '1px', 20, 377) } From 76119445a302f0c1109bc4fdb44100f60be7107e Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 21 Dec 2023 13:52:42 -0600 Subject: [PATCH 14/24] Update:Listening sessions table for multi-select, sorting and rows per page - Updated get all sessions API endpoint to include sorting - Added sessions API endpoint for batch deleting --- client/components/ui/Checkbox.vue | 6 +- client/components/ui/Dropdown.vue | 4 +- client/components/ui/InputDropdown.vue | 2 +- client/pages/config/sessions.vue | 205 +++++++++++++++++++++--- client/strings/cs.json | 3 + client/strings/da.json | 3 + client/strings/de.json | 5 +- client/strings/en-us.json | 5 +- client/strings/es.json | 3 + client/strings/fr.json | 3 + client/strings/gu.json | 3 + client/strings/hi.json | 3 + client/strings/hr.json | 3 + client/strings/it.json | 3 + client/strings/lt.json | 3 + client/strings/nl.json | 3 + client/strings/no.json | 3 + client/strings/pl.json | 3 + client/strings/ru.json | 3 + client/strings/sv.json | 3 + client/strings/zh-cn.json | 5 +- client/tailwind.config.js | 1 + server/controllers/SessionController.js | 137 ++++++++++++++-- server/routers/ApiRouter.js | 13 +- server/utils/index.js | 12 ++ 25 files changed, 375 insertions(+), 62 deletions(-) diff --git a/client/components/ui/Checkbox.vue b/client/components/ui/Checkbox.vue index 439d6c7d5..58770aa89 100644 --- a/client/components/ui/Checkbox.vue +++ b/client/components/ui/Checkbox.vue @@ -2,7 +2,8 @@ @@ -31,7 +32,8 @@ export default { type: String, default: '' }, - disabled: Boolean + disabled: Boolean, + partial: Boolean }, data() { return {} diff --git a/client/components/ui/Dropdown.vue b/client/components/ui/Dropdown.vue index 581554998..632e38ecf 100644 --- a/client/components/ui/Dropdown.vue +++ b/client/components/ui/Dropdown.vue @@ -1,6 +1,6 @@