From 80458e24bd94357ccc6178ac4ce0ecde8c6d47c1 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 30 Nov 2023 21:15:25 +0200 Subject: [PATCH 01/47] "[un]abridged" in title candidate generation --- server/finders/BookFinder.js | 1 + test/server/finders/BookFinder.test.js | 2 ++ 2 files changed, 3 insertions(+) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 7d26b6bf..0c5f32d2 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -167,6 +167,7 @@ class BookFinder { [/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''], // Remove edition [/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type [/ a novel.*$/g, ''], // Remove "a novel" + [/(^| )(un)?abridged( |$)/g, ' '], // Remove "unabridged/abridged" [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers ] diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js index 2728f174..ed2442c6 100644 --- a/test/server/finders/BookFinder.test.js +++ b/test/server/finders/BookFinder.test.js @@ -35,6 +35,8 @@ describe('TitleCandidates', () => { ['adds candidate + variant, removing edition 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']], ['adds candidate + variant, removing fie type', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']], ['adds candidate + variant, removing "a novel"', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']], + ['adds candidate + variant, removing "abridged"', 'abridged anna karenina', ['anna karenina', 'abridged anna karenina']], + ['adds candidate + variant, removing "unabridged"', 'anna karenina unabridged', ['anna karenina', 'anna karenina unabridged']], ['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']], ['does not add empty candidate', '', []], ['does not add spaces-only candidate', ' ', []], From 8ac0ce399f8dbe5082fb933c2510423b1251ab8a Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 30 Nov 2023 21:17:13 +0200 Subject: [PATCH 02/47] Remove "et al[.]" in author cleanup --- server/finders/BookFinder.js | 2 ++ test/server/finders/BookFinder.test.js | 1 + 2 files changed, 3 insertions(+) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 0c5f32d2..4422fa98 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -462,6 +462,8 @@ function cleanAuthorForCompares(author) { cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2') // remove middle initials cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '') + // remove et al. + cleanAuthor = cleanAuthor.replace(/et al\.?/g, '') return cleanAuthor } diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js index ed2442c6..5d28bbea 100644 --- a/test/server/finders/BookFinder.test.js +++ b/test/server/finders/BookFinder.test.js @@ -111,6 +111,7 @@ describe('AuthorCandidates', () => { ['adds recognized author if edit distance from candidate is small', 'nicolai gogol', ['nikolai gogol']], ['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []], ['adds normalized recognized candidate (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']], + ['adds normalized recognized candidate (et al removed)', 'nikolai gogol et al.', ['nikolai gogol']], ['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']], ].forEach(([name, author, expected]) => it(name, async () => { authorCandidates.add(author) From 281de48ed4b0bc67d48e7a8ec1f85e4dbdeaa247 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 30 Nov 2023 21:49:24 +0200 Subject: [PATCH 03/47] Fix "et al" cleanup --- server/finders/BookFinder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 4422fa98..b76b8b1d 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -463,7 +463,7 @@ function cleanAuthorForCompares(author) { // remove middle initials cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '') // remove et al. - cleanAuthor = cleanAuthor.replace(/et al\.?/g, '') + cleanAuthor = cleanAuthor.replace(/ et al\.?(?= |$)/g, '') return cleanAuthor } From 80fd2a1a1831b415546194fc2e7809a002f85030 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Mon, 4 Dec 2023 22:36:34 +0100 Subject: [PATCH 04/47] SSO/OpenID: Use a mobile-redirect route (Fixes #2379 and #2381) - Implement /auth/openid/mobile-redirect this will redirect to an app-link like audiobookshelf://oauth - An app must provide an `redirect_uri` parameter with the app-link in the authorization request to /auth/openid - The user will have to whitelist possible URLs, or explicitly allow all - Also modified MultiSelect to allow to hide the menu/popup --- client/components/ui/MultiSelect.vue | 8 ++- client/pages/config/authentication.vue | 22 +++++++++ client/strings/de.json | 2 + client/strings/en-us.json | 2 + server/Auth.js | 59 ++++++++++++++++++++++- server/controllers/MiscController.js | 17 +++++++ server/objects/settings/ServerSettings.js | 9 +++- 7 files changed, 114 insertions(+), 5 deletions(-) diff --git a/client/components/ui/MultiSelect.vue b/client/components/ui/MultiSelect.vue index 4fa8e394..2009b28d 100644 --- a/client/components/ui/MultiSelect.vue +++ b/client/components/ui/MultiSelect.vue @@ -50,7 +50,11 @@ export default { label: String, disabled: Boolean, readonly: Boolean, - showEdit: Boolean + showEdit: Boolean, + menuDisabled: { + type: Boolean, + default: false + }, }, data() { return { @@ -77,7 +81,7 @@ export default { } }, showMenu() { - return this.isFocused + return this.isFocused && !this.menuDisabled }, wrapperClass() { var classes = [] diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index e645569e..ffb1feb7 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -46,6 +46,9 @@ + +

+

@@ -187,6 +190,25 @@ export default { this.$toast.error('Client Secret required') isValid = false } + + function isValidRedirectURI(uri) { + // Check for somestring://someother/string + const pattern = new RegExp('^\\w+://[\\w\\.-]+$', 'i') + return pattern.test(uri) + } + + const uris = this.newAuthSettings.authOpenIDMobileRedirectURIs + if (uris.includes('*') && uris.length > 1) { + this.$toast.error('Mobile Redirect URIs: Asterisk (*) must be the only entry if used') + isValid = false + } else { + uris.forEach(uri => { + if (uri !== '*' && !isValidRedirectURI(uri)) { + this.$toast.error(`Mobile Redirect URIs: Invalid URI ${uri}`) + isValid = false + } + }) + } return isValid }, async saveSettings() { diff --git a/client/strings/de.json b/client/strings/de.json index 78e64804..eb3d59f4 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -337,6 +337,8 @@ "LabelMinute": "Minute", "LabelMissing": "Fehlend", "LabelMissingParts": "Fehlende Teile", + "LabelMobileRedirectURIs": "Erlaubte Weiterleitungs-URIs für die mobile App", + "LabelMobileRedirectURIsDescription": "Dies ist eine Whitelist gültiger Umleitungs-URIs für mobile Apps. Der Standardwert ist audiobookshelf://oauth, den Sie entfernen oder durch zusätzliche URIs für die Integration von Drittanbieter-Apps ergänzen können. Die Verwendung eines Sternchens (*) als alleiniger Eintrag erlaubt jede URI.", "LabelMore": "Mehr", "LabelMoreInfo": "Mehr Info", "LabelName": "Name", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 857627e9..02f9df05 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -343,6 +343,8 @@ "LabelMinute": "Minute", "LabelMissing": "Missing", "LabelMissingParts": "Missing Parts", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "More", "LabelMoreInfo": "More Info", "LabelName": "Name", diff --git a/server/Auth.js b/server/Auth.js index 267bbb45..c20d532a 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -8,6 +8,7 @@ const ExtractJwt = require('passport-jwt').ExtractJwt const OpenIDClient = require('openid-client') const Database = require('./Database') const Logger = require('./Logger') +const e = require('express') /** * @class Class for handling all the authentication related functionality. @@ -15,6 +16,8 @@ const Logger = require('./Logger') class Auth { constructor() { + // Map of openId sessions indexed by oauth2 state-variable + this.openIdAuthSession = new Map() } /** @@ -283,7 +286,26 @@ class Auth { // for API or mobile clients const oidcStrategy = passport._strategy('openid-client') const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' - oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString() + + let redirect_uri = null + + // The client wishes a different redirect_uri + // We will allow if it is in the whitelist, by saving it into this.openIdAuthSession and setting the redirect uri to /auth/openid/mobile-redirect + // where we will handle the redirect to it + 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] === '*')) { + oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString() + redirect_uri = req.query.redirect_uri + } else { + Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri} - not in whitelist`) + return res.status(400).send('Invalid redirect_uri') + } + } else { + oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString() + } + Logger.debug(`[Auth] Set oidc redirect_uri=${oidcStrategy._params.redirect_uri}`) const client = oidcStrategy._client const sessionKey = oidcStrategy._key @@ -327,6 +349,10 @@ class Auth { mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later } + // We cannot save redirect_uri in the session, because it the mobile client uses browser instead of the API + // for the request to mobile-redirect and as such the session is not shared + this.openIdAuthSession.set(params.state, { redirect_uri: redirect_uri }) + // Now get the URL to direct to const authorizationUrl = client.authorizationUrl({ ...params, @@ -347,6 +373,37 @@ class Auth { } }) + // This will be the oauth2 callback route for mobile clients + // It will redirect to an app-link like audiobookshelf://oauth + router.get('/auth/openid/mobile-redirect', (req, res) => { + 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') + return res.status(400).send('State parameter mismatch') + } + + let redirect_uri = this.openIdAuthSession.get(state).redirect_uri + + if (!redirect_uri) { + Logger.error('[Auth] No redirect URI') + return res.status(400).send('No redirect URI') + } + + this.openIdAuthSession.delete(state) + + const redirectUri = `${redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + // Redirect to the overwrite URI saved in the map + res.redirect(redirectUri) + } catch (error) { + Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}`) + res.status(500).send('Internal Server Error') + } + }) + // openid strategy callback route (this receives the token from the configured openid login provider) router.get('/auth/openid/callback', (req, res, next) => { const oidcStrategy = passport._strategy('openid-client') diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 26a9d77b..e209fac9 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -629,6 +629,23 @@ class MiscController { } else { Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`) } + } else if (key === 'authOpenIDMobileRedirectURIs') { + function isValidRedirectURI(uri) { + const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i'); + return pattern.test(uri); + } + + const uris = settingsUpdate[key] + if (!Array.isArray(uris) || + (uris.includes('*') && uris.length > 1) || + uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) { + Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`) + continue + } + + // Update the URIs + Database.serverSettings[key] = uris + hasUpdates = true } else { const updatedValueType = typeof settingsUpdate[key] if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) { diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index bf3db557..6e9d8456 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -71,6 +71,7 @@ class ServerSettings { this.authOpenIDAutoLaunch = false this.authOpenIDAutoRegister = false this.authOpenIDMatchExistingBy = null + this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] if (settings) { this.construct(settings) @@ -126,6 +127,7 @@ class ServerSettings { this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null + this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth'] if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] @@ -211,7 +213,8 @@ class ServerSettings { authOpenIDButtonText: this.authOpenIDButtonText, authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoRegister: this.authOpenIDAutoRegister, - authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy + authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, + authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client } } @@ -220,6 +223,7 @@ class ServerSettings { delete json.tokenSecret delete json.authOpenIDClientID delete json.authOpenIDClientSecret + delete json.authOpenIDMobileRedirectURIs return json } @@ -254,7 +258,8 @@ class ServerSettings { authOpenIDButtonText: this.authOpenIDButtonText, authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoRegister: this.authOpenIDAutoRegister, - authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy + authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, + authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client } } From e6ab28365fa740b72295668b924ee5b1d6640f09 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Tue, 5 Dec 2023 00:18:58 +0100 Subject: [PATCH 05/47] SSO/OpenID: Remove modifying redirect_uri in the callback The redirect URI will be now correctly set to either /callback or /mobile-redirect in the /auth/openid route --- server/Auth.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index c20d532a..b5bc7d40 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -359,7 +359,7 @@ class Auth { scope: 'openid profile email', response_type: 'code', code_challenge, - code_challenge_method, + code_challenge_method }) // params (isRest, callback) to a cookie that will be send to the client @@ -460,11 +460,8 @@ class Auth { // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided - if (req.session[sessionKey].mobile) { - return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' }, passportCallback(req, res, next))(req, res, next) - } else { - return passport.authenticate('openid-client', passportCallback(req, res, next))(req, res, next) - } + // This is already done in the strategy in the route to /auth/openid using oidcStrategy._params.redirect_uri + return passport.authenticate('openid-client', passportCallback(req, res, next))(req, res, next) }, // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this)) From cf00650c6d3bd74ddb9fae92138c00f808511150 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Tue, 5 Dec 2023 09:43:06 +0100 Subject: [PATCH 06/47] SSO/OpenID: Also fix possible race condition - We need to define redirect_uri in the callback again, because the global params of passport can change between calls to the first route (ie. if multiple users log in at same time) - Removed is_rest parameter as requirement for mobile flow (to maximise compatibility with possible oauth libraries) - Also renamed some variables for clarity --- server/Auth.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index b5bc7d40..0a282c9c 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -190,9 +190,10 @@ class Auth { * @param {import('express').Response} res */ paramsToCookies(req, res) { - if (req.query.isRest?.toLowerCase() == 'true') { + // Set if isRest flag is set or if mobile oauth flow is used + if (req.query.isRest?.toLowerCase() == 'true' || req.query.redirect_uri) { // store the isRest flag to the is_rest cookie - res.cookie('is_rest', req.query.isRest.toLowerCase(), { + res.cookie('is_rest', 'true', { maxAge: 120000, // 2 min httpOnly: true }) @@ -287,7 +288,7 @@ class Auth { const oidcStrategy = passport._strategy('openid-client') const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' - let redirect_uri = null + let mobile_redirect_uri = null // The client wishes a different redirect_uri // We will allow if it is in the whitelist, by saving it into this.openIdAuthSession and setting the redirect uri to /auth/openid/mobile-redirect @@ -297,7 +298,7 @@ class Auth { if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) { oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString() - redirect_uri = req.query.redirect_uri + mobile_redirect_uri = req.query.redirect_uri } else { Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri} - not in whitelist`) return res.status(400).send('Invalid redirect_uri') @@ -306,7 +307,7 @@ class Auth { oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString() } - Logger.debug(`[Auth] Set oidc redirect_uri=${oidcStrategy._params.redirect_uri}`) + Logger.debug(`[Auth] Oidc redirect_uri=${oidcStrategy._params.redirect_uri}`) const client = oidcStrategy._client const sessionKey = oidcStrategy._key @@ -346,12 +347,13 @@ class Auth { req.session[sessionKey] = { ...req.session[sessionKey], ...pick(params, 'nonce', 'state', 'max_age', 'response_type'), - mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later + mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out + sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback } // We cannot save redirect_uri in the session, because it the mobile client uses browser instead of the API // for the request to mobile-redirect and as such the session is not shared - this.openIdAuthSession.set(params.state, { redirect_uri: redirect_uri }) + this.openIdAuthSession.set(params.state, { mobile_redirect_uri: mobile_redirect_uri }) // Now get the URL to direct to const authorizationUrl = client.authorizationUrl({ @@ -386,16 +388,16 @@ class Auth { return res.status(400).send('State parameter mismatch') } - let redirect_uri = this.openIdAuthSession.get(state).redirect_uri + let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri - if (!redirect_uri) { + if (!mobile_redirect_uri) { Logger.error('[Auth] No redirect URI') return res.status(400).send('No redirect URI') } this.openIdAuthSession.delete(state) - const redirectUri = `${redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` // Redirect to the overwrite URI saved in the map res.redirect(redirectUri) } catch (error) { @@ -460,8 +462,8 @@ class Auth { // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided - // This is already done in the strategy in the route to /auth/openid using oidcStrategy._params.redirect_uri - return passport.authenticate('openid-client', passportCallback(req, res, next))(req, res, next) + // We set it here again because the passport param can change between requests + return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next) }, // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this)) From b5e255a3848637c636f92dd1110010fbdcb69e6c Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 6 Dec 2023 17:31:36 -0600 Subject: [PATCH 07/47] Update:Clean series sequence response from audible provider #2380 - Removes Book prefix - Splits on spaces and takes first, removes trailing comma --- server/providers/Audible.js | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/server/providers/Audible.js b/server/providers/Audible.js index 31719eef..e46ed323 100644 --- a/server/providers/Audible.js +++ b/server/providers/Audible.js @@ -18,6 +18,27 @@ class Audible { } } + /** + * Audible will sometimes send sequences with "Book 1" or "2, Dramatized Adaptation" + * @see https://github.com/advplyr/audiobookshelf/issues/2380 + * @see https://github.com/advplyr/audiobookshelf/issues/1339 + * + * @param {string} seriesName + * @param {string} sequence + * @returns {string} + */ + cleanSeriesSequence(seriesName, sequence) { + if (!sequence) return '' + let updatedSequence = sequence.replace(/Book /, '').trim() + if (updatedSequence.includes(' ')) { + updatedSequence = updatedSequence.split(' ').shift().replace(/,$/, '') + } + if (sequence !== updatedSequence) { + Logger.debug(`[Audible] Series "${seriesName}" sequence was cleaned from "${sequence}" to "${updatedSequence}"`) + } + return updatedSequence + } + cleanResult(item) { const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item @@ -25,13 +46,13 @@ class Audible { if (seriesPrimary) { series.push({ series: seriesPrimary.name, - sequence: (seriesPrimary.position || '').replace(/Book /, '') // Can be badly formatted see #1339 + sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '') }) } if (seriesSecondary) { series.push({ series: seriesSecondary.name, - sequence: (seriesSecondary.position || '').replace(/Book /, '') + sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '') }) } @@ -64,7 +85,7 @@ class Audible { } asinSearch(asin, region) { - asin = encodeURIComponent(asin); + asin = encodeURIComponent(asin) var regionQuery = region ? `?region=${region}` : '' var url = `https://api.audnex.us/books/${asin}${regionQuery}` Logger.debug(`[Audible] ASIN url: ${url}`) From 341a0452da4044fe8bec7745d8c54b28a5c5eb6b Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 7 Dec 2023 17:01:33 -0600 Subject: [PATCH 08/47] Update auth settings endpoint to return updated flag and show whether updates were made in client toast --- client/pages/config/authentication.vue | 8 ++++++-- server/controllers/MiscController.js | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index ffb1feb7..9e028307 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -202,7 +202,7 @@ export default { this.$toast.error('Mobile Redirect URIs: Asterisk (*) must be the only entry if used') isValid = false } else { - uris.forEach(uri => { + uris.forEach((uri) => { if (uri !== '*' && !isValidRedirectURI(uri)) { this.$toast.error(`Mobile Redirect URIs: Invalid URI ${uri}`) isValid = false @@ -230,7 +230,11 @@ export default { .$patch('/api/auth-settings', this.newAuthSettings) .then((data) => { this.$store.commit('setServerSettings', data.serverSettings) - this.$toast.success('Server settings updated') + if (data.updated) { + this.$toast.success('Server settings updated') + } else { + this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary) + } }) .catch((error) => { console.error('Failed to update server settings', error) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index e209fac9..db4110e0 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -631,21 +631,25 @@ class MiscController { } } else if (key === 'authOpenIDMobileRedirectURIs') { function isValidRedirectURI(uri) { - const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i'); - return pattern.test(uri); + if (typeof uri !== 'string') return false + const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i') + return pattern.test(uri) } const uris = settingsUpdate[key] - if (!Array.isArray(uris) || - (uris.includes('*') && uris.length > 1) || - uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) { + if (!Array.isArray(uris) || + (uris.includes('*') && uris.length > 1) || + uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) { Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`) continue } // Update the URIs - Database.serverSettings[key] = uris - hasUpdates = true + if (Database.serverSettings[key].some(uri => !uris.includes(uri)) || uris.some(uri => !Database.serverSettings[key].includes(uri))) { + Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${Database.serverSettings[key]}" to "${uris}"`) + Database.serverSettings[key] = uris + hasUpdates = true + } } else { const updatedValueType = typeof settingsUpdate[key] if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) { @@ -688,6 +692,7 @@ class MiscController { } res.json({ + updated: hasUpdates, serverSettings: Database.serverSettings.toJSONForBrowser() }) } From 98104a3c03591af2c8b8885631ce5bf87c556682 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 7 Dec 2023 17:05:52 -0600 Subject: [PATCH 09/47] Map new translations to other files --- client/strings/cs.json | 2 ++ client/strings/da.json | 2 ++ client/strings/es.json | 2 ++ client/strings/fr.json | 2 ++ client/strings/gu.json | 2 ++ client/strings/hi.json | 2 ++ client/strings/hr.json | 2 ++ client/strings/it.json | 2 ++ client/strings/lt.json | 2 ++ client/strings/nl.json | 2 ++ client/strings/no.json | 2 ++ client/strings/pl.json | 2 ++ client/strings/ru.json | 2 ++ client/strings/sv.json | 2 ++ client/strings/zh-cn.json | 2 ++ 15 files changed, 30 insertions(+) diff --git a/client/strings/cs.json b/client/strings/cs.json index b8936024..6d39569e 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -343,6 +343,8 @@ "LabelMinute": "Minuta", "LabelMissing": "Chybějící", "LabelMissingParts": "Chybějící díly", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Více", "LabelMoreInfo": "Více informací", "LabelName": "Jméno", diff --git a/client/strings/da.json b/client/strings/da.json index a93507c0..fa28dd24 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -343,6 +343,8 @@ "LabelMinute": "Minut", "LabelMissing": "Mangler", "LabelMissingParts": "Manglende dele", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Mere", "LabelMoreInfo": "Mere info", "LabelName": "Navn", diff --git a/client/strings/es.json b/client/strings/es.json index fc2f0316..47315301 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -343,6 +343,8 @@ "LabelMinute": "Minuto", "LabelMissing": "Ausente", "LabelMissingParts": "Partes Ausentes", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Más", "LabelMoreInfo": "Más Información", "LabelName": "Nombre", diff --git a/client/strings/fr.json b/client/strings/fr.json index f10a51f4..f6efa428 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -343,6 +343,8 @@ "LabelMinute": "Minute", "LabelMissing": "Manquant", "LabelMissingParts": "Parties manquantes", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Plus", "LabelMoreInfo": "Plus d’info", "LabelName": "Nom", diff --git a/client/strings/gu.json b/client/strings/gu.json index d65bb13e..0317e2f9 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -343,6 +343,8 @@ "LabelMinute": "Minute", "LabelMissing": "Missing", "LabelMissingParts": "Missing Parts", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "More", "LabelMoreInfo": "More Info", "LabelName": "Name", diff --git a/client/strings/hi.json b/client/strings/hi.json index b172c2e5..eb4f074f 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -343,6 +343,8 @@ "LabelMinute": "Minute", "LabelMissing": "Missing", "LabelMissingParts": "Missing Parts", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "More", "LabelMoreInfo": "More Info", "LabelName": "Name", diff --git a/client/strings/hr.json b/client/strings/hr.json index 50f384e7..eb7d27d8 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -343,6 +343,8 @@ "LabelMinute": "Minuta", "LabelMissing": "Nedostaje", "LabelMissingParts": "Nedostajali dijelovi", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Više", "LabelMoreInfo": "More Info", "LabelName": "Ime", diff --git a/client/strings/it.json b/client/strings/it.json index 638e3468..7e526721 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -343,6 +343,8 @@ "LabelMinute": "Minuto", "LabelMissing": "Altro", "LabelMissingParts": "Parti rimantenti", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Molto", "LabelMoreInfo": "Più Info", "LabelName": "Nome", diff --git a/client/strings/lt.json b/client/strings/lt.json index 3e3fda41..9c4b9a63 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -343,6 +343,8 @@ "LabelMinute": "Minutė", "LabelMissing": "Trūksta", "LabelMissingParts": "Trūkstamos dalys", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Daugiau", "LabelMoreInfo": "Daugiau informacijos", "LabelName": "Pavadinimas", diff --git a/client/strings/nl.json b/client/strings/nl.json index 08845488..d4779abd 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -343,6 +343,8 @@ "LabelMinute": "Minuut", "LabelMissing": "Ontbrekend", "LabelMissingParts": "Ontbrekende delen", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Meer", "LabelMoreInfo": "Meer info", "LabelName": "Naam", diff --git a/client/strings/no.json b/client/strings/no.json index 8cbfd919..511c8b86 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -343,6 +343,8 @@ "LabelMinute": "Minutt", "LabelMissing": "Mangler", "LabelMissingParts": "Manglende deler", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Mer", "LabelMoreInfo": "Mer info", "LabelName": "Navn", diff --git a/client/strings/pl.json b/client/strings/pl.json index bf34cbac..b51084e9 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -343,6 +343,8 @@ "LabelMinute": "Minuta", "LabelMissing": "Brakujący", "LabelMissingParts": "Brakujące cześci", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Więcej", "LabelMoreInfo": "More Info", "LabelName": "Nazwa", diff --git a/client/strings/ru.json b/client/strings/ru.json index b0ba0f6a..b48e0dbd 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -343,6 +343,8 @@ "LabelMinute": "Минуты", "LabelMissing": "Потеряно", "LabelMissingParts": "Потерянные части", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Еще", "LabelMoreInfo": "Больше информации", "LabelName": "Имя", diff --git a/client/strings/sv.json b/client/strings/sv.json index 6883af39..fde0cd87 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -343,6 +343,8 @@ "LabelMinute": "Minut", "LabelMissing": "Saknad", "LabelMissingParts": "Saknade delar", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Mer", "LabelMoreInfo": "Mer information", "LabelName": "Namn", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 14bfcc0b..7c559489 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -343,6 +343,8 @@ "LabelMinute": "分钟", "LabelMissing": "丢失", "LabelMissingParts": "丢失的部分", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "更多", "LabelMoreInfo": "更多..", "LabelName": "名称", From 8d3d6363290aa820f7fd6e93c4d7bca91950d891 Mon Sep 17 00:00:00 2001 From: JBlond Date: Fri, 8 Dec 2023 09:39:04 +0100 Subject: [PATCH 10/47] Follow up for sso-redirecturi and #2305 #2333 8f4c65ec8c8838e71d7810266f60a85664927c27 / 7c9c278cc40acb2887f4d2b7b3c40241096ae38e sso-redirecturi 2f6756eddf03f758bea0f5dc7a154ba57ab1e69d #2333 2e5822b7c88ad362d7174c55becf320c0c834675 #2305 --- client/strings/de.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index da8af1d8..3fa18960 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -87,9 +87,9 @@ "ButtonUserEdit": "Benutzer {0} bearbeiten", "ButtonViewAll": "Alles anzeigen", "ButtonYes": "Ja", - "ErrorUploadFetchMetadataAPI": "Error fetching metadata", - "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", - "ErrorUploadLacksTitle": "Must have a title", + "ErrorUploadFetchMetadataAPI": "Fehler beim Abrufen der Metadaten", + "ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden. Versuchen Sie den Titel und oder den Autor zu updaten", + "ErrorUploadLacksTitle": "Es muss ein Titel eingegeben werden", "HeaderAccount": "Konto", "HeaderAdvanced": "Erweitert", "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", @@ -199,8 +199,8 @@ "LabelAuthorLastFirst": "Autor (Nachname, Vorname)", "LabelAuthors": "Autoren", "LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen", - "LabelAutoFetchMetadata": "Auto Fetch Metadata", - "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", + "LabelAutoFetchMetadata": "Automatisches Abholen der Metadaten", + "LabelAutoFetchMetadataHelp": "Abholen der Metadaten von Titel, Autor und Serien, um das Hochladen zu optimieren. Möglicherweise müssen zusätzliche Metadaten nach dem Hochladen abgeglichen werden.", "LabelAutoLaunch": "Automatischer Start", "LabelAutoLaunchDescription": "Automatische Weiterleitung zum Authentifizierungsanbieter beim Navigieren zur Anmeldeseite (manueller Überschreibungspfad /login?autoLaunch=0)", "LabelAutoRegister": "Automatische Registrierung", @@ -271,7 +271,7 @@ "LabelExample": "Beispiel", "LabelExplicit": "Explizit (Altersbeschränkung)", "LabelFeedURL": "Feed URL", - "LabelFetchingMetadata": "Fetching Metadata", + "LabelFetchingMetadata": "Abholen der Metadaten", "LabelFile": "Datei", "LabelFileBirthtime": "Datei erstellt", "LabelFileModified": "Datei geändert", @@ -289,7 +289,7 @@ "LabelHardDeleteFile": "Datei dauerhaft löschen", "LabelHasEbook": "mit E-Book", "LabelHasSupplementaryEbook": "mit zusätlichem E-Book", - "LabelHighestPriority": "Highest priority", + "LabelHighestPriority": "Höchste Priorität", "LabelHost": "Host", "LabelHour": "Stunde", "LabelIcon": "Symbol", @@ -331,12 +331,12 @@ "LabelLogLevelInfo": "Informationen", "LabelLogLevelWarn": "Warnungen", "LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum", - "LabelLowestPriority": "Lowest Priority", + "LabelLowestPriority": "Niedrigste Priorität", "LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit", "LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID von Ihrem SSO-Anbieter zugeordnet", "LabelMediaPlayer": "Mediaplayer", "LabelMediaType": "Medientyp", - "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", + "LabelMetadataOrderOfPrecedenceDescription": "Eine Höhere Priorität Quelle für Metadaten wird die Metadaten aus eine Quelle mit niedrigerer Priorität überschreiben.", "LabelMetadataProvider": "Metadatenanbieter", "LabelMetaTag": "Meta Schlagwort", "LabelMetaTags": "Meta Tags", @@ -523,7 +523,7 @@ "LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird", "LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern", "LabelUploaderDropFiles": "Dateien löschen", - "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", + "LabelUploaderItemFetchMetadataHelp": "Automatisches Abholden von Titel, Author und Serien", "LabelUseChapterTrack": "Kapiteldatei verwenden", "LabelUseFullTrack": "Gesamte Datei verwenden", "LabelUser": "Benutzer", From 0282a0521b8466c0af521f017c5a16dd8fcdfa8a Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 9 Dec 2023 00:33:06 +0200 Subject: [PATCH 11/47] Sort audible match results by duration difference --- client/components/modals/item/tabs/Match.vue | 1 + server/controllers/SearchController.js | 5 +- server/finders/BookFinder.js | 26 ++++++++-- server/scanner/Scanner.js | 2 +- test/server/finders/BookFinder.test.js | 54 ++++++++++++++++---- 5 files changed, 70 insertions(+), 18 deletions(-) diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue index 1c682919..b57e9612 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -332,6 +332,7 @@ export default { if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}` var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}` if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}` + if (this.libraryItemId) searchQuery += `&id=${this.libraryItemId}` return searchQuery }, submitSearch() { diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 93587bc4..e52e6973 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -3,15 +3,18 @@ const BookFinder = require('../finders/BookFinder') const PodcastFinder = require('../finders/PodcastFinder') const AuthorFinder = require('../finders/AuthorFinder') const MusicFinder = require('../finders/MusicFinder') +const Database = require("../Database") class SearchController { constructor() { } async findBooks(req, res) { + const id = req.query.id + const libraryItem = await Database.libraryItemModel.getOldById(id) const provider = req.query.provider || 'google' const title = req.query.title || '' const author = req.query.author || '' - const results = await BookFinder.search(provider, title, author) + const results = await BookFinder.search(libraryItem, provider, title, author) res.json(results) } diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index b76b8b1d..8704a964 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -299,6 +299,7 @@ class BookFinder { /** * Search for books including fuzzy searches * + * @param {Object} libraryItem * @param {string} provider * @param {string} title * @param {string} author @@ -307,7 +308,7 @@ class BookFinder { * @param {{titleDistance:number, authorDistance:number, maxFuzzySearches:number}} options * @returns {Promise} */ - async search(provider, title, author, isbn, asin, options = {}) { + async search(libraryItem, provider, title, author, isbn, asin, options = {}) { let books = [] const maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4 const maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4 @@ -336,6 +337,7 @@ class BookFinder { for (const titlePart of titleParts) authorCandidates.add(titlePart) authorCandidates = await authorCandidates.getCandidates() + loop_author: for (const authorCandidate of authorCandidates) { let titleCandidates = new BookFinder.TitleCandidates(authorCandidate) for (const titlePart of titleParts) @@ -343,13 +345,27 @@ class BookFinder { titleCandidates = titleCandidates.getCandidates() for (const titleCandidate of titleCandidates) { if (titleCandidate == title && authorCandidate == author) continue // We already tried this - if (++numFuzzySearches > maxFuzzySearches) return books + if (++numFuzzySearches > maxFuzzySearches) break loop_author books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance) - if (books.length) return books + if (books.length) break loop_author } } } + if (books.length) { + const resultsHaveDuration = provider.startsWith('audible') + if (resultsHaveDuration && libraryItem && libraryItem.media?.duration) { + const libraryItemDurationMinutes = libraryItem.media.duration/60 + // If provider results have duration, sort by ascendinge duration difference from libraryItem + books.sort((a, b) => { + const aDuration = a.duration || Number.POSITIVE_INFINITY + const bDuration = b.duration || Number.POSITIVE_INFINITY + const aDurationDiff = Math.abs(aDuration - libraryItemDurationMinutes) + const bDurationDiff = Math.abs(bDuration - libraryItemDurationMinutes) + return aDurationDiff - bDurationDiff + }) + } + } return books } @@ -393,12 +409,12 @@ class BookFinder { if (provider === 'all') { for (const providerString of this.providers) { - const providerResults = await this.search(providerString, title, author, options) + const providerResults = await this.search(null, providerString, title, author, options) Logger.debug(`[BookFinder] Found ${providerResults.length} covers from ${providerString}`) searchResults.push(...providerResults) } } else { - searchResults = await this.search(provider, title, author, options) + searchResults = await this.search(null, provider, title, author, options) } Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`) diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 616baf29..040053e4 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -37,7 +37,7 @@ class Scanner { var searchISBN = options.isbn || libraryItem.media.metadata.isbn var searchASIN = options.asin || libraryItem.media.metadata.asin - var results = await BookFinder.search(provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 }) + var results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 }) if (!results.length) { return { warning: `No ${provider} match found` diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js index 5d28bbea..03f81f12 100644 --- a/test/server/finders/BookFinder.test.js +++ b/test/server/finders/BookFinder.test.js @@ -225,14 +225,14 @@ describe('search', () => { describe('search title is empty', () => { it('returns empty result', async () => { - expect(await bookFinder.search('', '', a)).to.deep.equal([]) + expect(await bookFinder.search(null, '', '', a)).to.deep.equal([]) sinon.assert.callCount(bookFinder.runSearch, 0) }) }) describe('search title is a recognized title and search author is a recognized author', () => { it('returns non-empty result (no fuzzy searches)', async () => { - expect(await bookFinder.search('', t, a)).to.deep.equal(r) + expect(await bookFinder.search(null, '', t, a)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 1) }) }) @@ -254,7 +254,7 @@ describe('search', () => { [`2022_${t}_HQ`], ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r) + expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 2) }) }); @@ -264,7 +264,7 @@ describe('search', () => { [`${a} - series 01 - ${t}`], ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => { - expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r) + expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 3) }) }); @@ -274,7 +274,7 @@ describe('search', () => { [`${t} junk`], ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns an empty result`, async () => { - expect(await bookFinder.search('', searchTitle, a)).to.deep.equal([]) + expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal([]) }) }) @@ -283,7 +283,7 @@ describe('search', () => { [`${t} - ${a}`], ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => { - expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([]) + expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([]) sinon.assert.callCount(bookFinder.runSearch, 1) }) }) @@ -295,7 +295,7 @@ describe('search', () => { [`${a} - series 01 - ${t}`], ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([]) + expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([]) sinon.assert.callCount(bookFinder.runSearch, 2) }) }) @@ -308,7 +308,7 @@ describe('search', () => { [`${a} - ${t}`], ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, '')).to.deep.equal(r) + expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 2) }) }); @@ -319,7 +319,7 @@ describe('search', () => { [`${u} - ${t}`] ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '') returns an empty result`, async () => { - expect(await bookFinder.search('', searchTitle, '')).to.deep.equal([]) + expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal([]) }) }) }) @@ -330,7 +330,7 @@ describe('search', () => { [`${u} - ${t}`] ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r) + expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 2) }) }); @@ -339,9 +339,41 @@ describe('search', () => { [`${t}`] ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r) + expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 1) }) }) }) + + describe('search provider results have duration', () => { + const libraryItem = { media: { duration: 60 * 1000 } } + const provider = 'audible' + const unsorted = [{ duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }] + const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }] + runSearchStub.withArgs(t, a, provider).resolves(unsorted) + + it('returns results sorted by library item duration diff', async () => { + expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted) + }) + + it('returns unsorted results if library item is null', async () => { + expect(await bookFinder.search(null, provider, t, a)).to.deep.equal(unsorted) + }) + + it('returns unsorted results if library item duration is undefined', async () => { + expect(await bookFinder.search({ media: {} }, provider, t, a)).to.deep.equal(unsorted) + }) + + it('returns unsorted results if library item media is undefined', async () => { + expect(await bookFinder.search({ }, provider, t, a)).to.deep.equal(unsorted) + }) + + it ('should return a result last if it has no duration', async () => { + const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }] + const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}] + runSearchStub.withArgs(t, a, provider).resolves(unsorted) + + expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted) + }) + }) }) From f659c3f11c2afbafd5769807111b1422effd1215 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 9 Dec 2023 13:51:28 -0600 Subject: [PATCH 12/47] Fix:Podcast RSS feed request header to include application/rss+xml #2401 --- server/utils/podcastUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index cf1567f9..87b080d7 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -218,7 +218,7 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`) - return axios.get(feedUrl, { timeout: 12000, responseType: 'arraybuffer' }).then(async (data) => { + return axios.get(feedUrl, { timeout: 12000, responseType: 'arraybuffer', headers: { Accept: 'application/rss+xml' } }).then(async (data) => { // Adding support for ios-8859-1 encoded RSS feeds. // See: https://github.com/advplyr/audiobookshelf/issues/1489 From b580a23e7e05818a3d7ba38abf40f3450852eece Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 10 Dec 2023 10:35:21 -0600 Subject: [PATCH 13/47] BookFinder formatting update --- server/finders/BookFinder.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 8704a964..466c8701 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -354,8 +354,8 @@ class BookFinder { if (books.length) { const resultsHaveDuration = provider.startsWith('audible') - if (resultsHaveDuration && libraryItem && libraryItem.media?.duration) { - const libraryItemDurationMinutes = libraryItem.media.duration/60 + if (resultsHaveDuration && libraryItem?.media?.duration) { + const libraryItemDurationMinutes = libraryItem.media.duration / 60 // If provider results have duration, sort by ascendinge duration difference from libraryItem books.sort((a, b) => { const aDuration = a.duration || Number.POSITIVE_INFINITY @@ -472,7 +472,7 @@ function cleanTitleForCompares(title) { function cleanAuthorForCompares(author) { if (!author) return '' author = stripRedundantSpaces(author) - + let cleanAuthor = replaceAccentedChars(author).toLowerCase() // separate initials cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2') From 6f26fd72380e524125306691df0fda1366074040 Mon Sep 17 00:00:00 2001 From: Dmitry Naboychenko Date: Tue, 12 Dec 2023 22:56:05 +0300 Subject: [PATCH 14/47] Update Russian localization --- client/strings/ru.json | 82 +++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index b48e0dbd..03e7385f 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -1,10 +1,10 @@ { "ButtonAdd": "Добавить", "ButtonAddChapters": "Добавить главы", - "ButtonAddDevice": "Add Device", - "ButtonAddLibrary": "Add Library", + "ButtonAddDevice": "Добавить устройство", + "ButtonAddLibrary": "Добавить библиотеку", "ButtonAddPodcasts": "Добавить подкасты", - "ButtonAddUser": "Add User", + "ButtonAddUser": "Добавить пользователя", "ButtonAddYourFirstLibrary": "Добавьте Вашу первую библиотеку", "ButtonApply": "Применить", "ButtonApplyChapters": "Применить главы", @@ -62,7 +62,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию", "ButtonReScan": "Пересканировать", "ButtonReset": "Сбросить", - "ButtonResetToDefault": "Reset to default", + "ButtonResetToDefault": "Сборосить по умолчанию", "ButtonRestore": "Восстановить", "ButtonSave": "Сохранить", "ButtonSaveAndClose": "Сохранить и закрыть", @@ -78,7 +78,7 @@ "ButtonStartM4BEncode": "Начать кодирование M4B", "ButtonStartMetadataEmbed": "Начать встраивание метаданных", "ButtonSubmit": "Применить", - "ButtonTest": "Test", + "ButtonTest": "Тест", "ButtonUpload": "Загрузить", "ButtonUploadBackup": "Загрузить бэкап", "ButtonUploadCover": "Загрузить обложку", @@ -87,15 +87,15 @@ "ButtonUserEdit": "Редактировать пользователя {0}", "ButtonViewAll": "Посмотреть все", "ButtonYes": "Да", - "ErrorUploadFetchMetadataAPI": "Error fetching metadata", - "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", - "ErrorUploadLacksTitle": "Must have a title", + "ErrorUploadFetchMetadataAPI": "Ошибка при получении метаданных", + "ErrorUploadFetchMetadataNoResults": "Не удалось получить метаданные - попробуйте обновить название и/или автора", + "ErrorUploadLacksTitle": "Название должно быть заполнено", "HeaderAccount": "Учетная запись", "HeaderAdvanced": "Дополнительно", "HeaderAppriseNotificationSettings": "Настройки оповещений", "HeaderAudiobookTools": "Инструменты файлов аудиокниг", "HeaderAudioTracks": "Аудио треки", - "HeaderAuthentication": "Authentication", + "HeaderAuthentication": "Аутентификация", "HeaderBackups": "Бэкапы", "HeaderChangePassword": "Изменить пароль", "HeaderChapters": "Главы", @@ -130,15 +130,15 @@ "HeaderManageTags": "Редактировать теги", "HeaderMapDetails": "Найти подробности", "HeaderMatch": "Поиск", - "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", + "HeaderMetadataOrderOfPrecedence": "Порядок приоритета метаданных", "HeaderMetadataToEmbed": "Метаинформация для встраивания", "HeaderNewAccount": "Новая учетная запись", "HeaderNewLibrary": "Новая библиотека", "HeaderNotifications": "Уведомления", - "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", + "HeaderOpenIDConnectAuthentication": "Аутентификация OpenID Connect", "HeaderOpenRSSFeed": "Открыть RSS-канал", "HeaderOtherFiles": "Другие файлы", - "HeaderPasswordAuthentication": "Password Authentication", + "HeaderPasswordAuthentication": "Аутентификация по паролю", "HeaderPermissions": "Разрешения", "HeaderPlayerQueue": "Очередь воспроизведения", "HeaderPlaylist": "Плейлист", @@ -187,11 +187,11 @@ "LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию", "LabelAddToPlaylist": "Добавить в плейлист", "LabelAddToPlaylistBatch": "Добавить {0} элементов в плейлист", - "LabelAdminUsersOnly": "Admin users only", + "LabelAdminUsersOnly": "Только для пользователей с правами администратора", "LabelAll": "Все", "LabelAllUsers": "Все пользователи", - "LabelAllUsersExcludingGuests": "All users excluding guests", - "LabelAllUsersIncludingGuests": "All users including guests", + "LabelAllUsersExcludingGuests": "Все пользователи, кроме гостей", + "LabelAllUsersIncludingGuests": "Все пользователи, включая гостей", "LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке", "LabelAppend": "Добавить", "LabelAuthor": "Автор", @@ -199,14 +199,14 @@ "LabelAuthorLastFirst": "Автор (Фамилия, Имя)", "LabelAuthors": "Авторы", "LabelAutoDownloadEpisodes": "Скачивать эпизоды автоматически", - "LabelAutoFetchMetadata": "Auto Fetch Metadata", - "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", - "LabelAutoLaunch": "Auto Launch", - "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path /login?autoLaunch=0)", - "LabelAutoRegister": "Auto Register", - "LabelAutoRegisterDescription": "Automatically create new users after logging in", + "LabelAutoFetchMetadata": "Автоматическое извлечение метаданных", + "LabelAutoFetchMetadataHelp": "Извлекает метаданные для названия, автора и серии для упрощения загрузки. После загрузки может потребоваться сопоставление дополнительных метаданных.", + "LabelAutoLaunch": "Автозапуск", + "LabelAutoLaunchDescription": "Редирект на провайдера аутентификации автоматически при переходе на страницу входа (путь ручного переопределения /login?autoLaunch=0)", + "LabelAutoRegister": "Автоматическая регистрация", + "LabelAutoRegisterDescription": "Автоматическое создание новых пользователей после входа в систему", "LabelBackToUser": "Назад к пользователю", - "LabelBackupLocation": "Backup Location", + "LabelBackupLocation": "Путь для бэкапов", "LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование", "LabelBackupsEnableAutomaticBackupsHelp": "Бэкапы сохраняются в /metadata/backups", "LabelBackupsMaxBackupSize": "Максимальный размер бэкапа (в GB)", @@ -215,13 +215,13 @@ "LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.", "LabelBitrate": "Битрейт", "LabelBooks": "Книги", - "LabelButtonText": "Button Text", + "LabelButtonText": "Текст кнопки", "LabelChangePassword": "Изменить пароль", "LabelChannels": "Каналы", "LabelChapters": "Главы", "LabelChaptersFound": "глав найдено", "LabelChapterTitle": "Название главы", - "LabelClickForMoreInfo": "Click for more info", + "LabelClickForMoreInfo": "Нажмите, чтобы узнать больше", "LabelClosePlayer": "Закрыть проигрыватель", "LabelCodec": "Кодек", "LabelCollapseSeries": "Свернуть серии", @@ -240,12 +240,12 @@ "LabelCurrently": "Текущее:", "LabelCustomCronExpression": "Пользовательское выражение Cron:", "LabelDatetime": "Дата и время", - "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", + "LabelDeleteFromFileSystemCheckbox": "Удалить из файловой системы (снимите флажок, чтобы удалить только из базы данных)", "LabelDescription": "Описание", "LabelDeselectAll": "Снять выделение", "LabelDevice": "Устройство", "LabelDeviceInfo": "Информация об устройстве", - "LabelDeviceIsAvailableTo": "Device is available to...", + "LabelDeviceIsAvailableTo": "Устройство доступно для...", "LabelDirectory": "Каталог", "LabelDiscFromFilename": "Диск из Имени файла", "LabelDiscFromMetadata": "Диск из Метаданных", @@ -271,7 +271,7 @@ "LabelExample": "Пример", "LabelExplicit": "Явный", "LabelFeedURL": "URL канала", - "LabelFetchingMetadata": "Fetching Metadata", + "LabelFetchingMetadata": "Извлечение метаданных", "LabelFile": "Файл", "LabelFileBirthtime": "Дата создания", "LabelFileModified": "Дата модификации", @@ -289,11 +289,11 @@ "LabelHardDeleteFile": "Жесткое удаление файла", "LabelHasEbook": "Есть e-книга", "LabelHasSupplementaryEbook": "Есть дополнительная e-книга", - "LabelHighestPriority": "Highest priority", + "LabelHighestPriority": "Наивысший приоритет", "LabelHost": "Хост", "LabelHour": "Часы", "LabelIcon": "Иконка", - "LabelImageURLFromTheWeb": "Image URL from the web", + "LabelImageURLFromTheWeb": "URL-адрес изображения из Интернета", "LabelIncludeInTracklist": "Включать в список воспроизведения", "LabelIncomplete": "Не завершен", "LabelInProgress": "В процессе", @@ -331,20 +331,20 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты", - "LabelLowestPriority": "Lowest Priority", - "LabelMatchExistingUsersBy": "Match existing users by", - "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", + "LabelLowestPriority": "Самый низкий приоритет", + "LabelMatchExistingUsersBy": "Сопоставление существующих пользователей по", + "LabelMatchExistingUsersByDescription": "Используется для подключения существующих пользователей. После подключения пользователям будет присвоен уникальный идентификатор от поставщика единого входа", "LabelMediaPlayer": "Медиа проигрыватель", "LabelMediaType": "Тип медиа", - "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", + "LabelMetadataOrderOfPrecedenceDescription": "Источники метаданных с более высоким приоритетом будут переопределять источники метаданных с более низким приоритетом", "LabelMetadataProvider": "Провайдер", "LabelMetaTag": "Мета тег", "LabelMetaTags": "Мета теги", "LabelMinute": "Минуты", "LabelMissing": "Потеряно", "LabelMissingParts": "Потерянные части", - "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", - "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", + "LabelMobileRedirectURIs": "Разрешенные URI перенаправления с мобильных устройств", + "LabelMobileRedirectURIsDescription": "Это белый список допустимых URI перенаправления для мобильных приложений. По умолчанию используется audiobookshelf://oauth, который можно удалить или дополнить дополнительными URI для интеграции со сторонними приложениями. Использование звездочки (*) в качестве единственной записи разрешает любой URI.", "LabelMore": "Еще", "LabelMoreInfo": "Больше информации", "LabelName": "Имя", @@ -523,7 +523,7 @@ "LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены", "LabelUploaderDragAndDrop": "Перетащите файлы или каталоги", "LabelUploaderDropFiles": "Перетащите файлы", - "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", + "LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии", "LabelUseChapterTrack": "Показывать время главы", "LabelUseFullTrack": "Показывать время книги", "LabelUser": "Пользователь", @@ -557,17 +557,17 @@ "MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?", "MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?", "MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?", - "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", - "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItem": "Это приведет к удалению элемента библиотеки из базы данных и файловой системы. Вы уверены?", + "MessageConfirmDeleteLibraryItems": "Это приведет к удалению {0} элементов библиотеки из базы данных и файловой системы. Вы уверены?", "MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?", "MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?", "MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все эпизоды как завершенные?", "MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все эпизоды как не завершенные?", "MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как завершенные?", "MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как не завершенные?", - "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files.

Would you like to continue?", + "MessageConfirmQuickEmbed": "Предупреждение! Быстрое встраивание не позволяет создавать резервные копии аудиофайлов. Убедитесь, что у вас есть резервная копия аудиофайлов.

Хотите продолжить?", "MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?", - "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", + "MessageConfirmRemoveAuthor": "Вы уверены, что хотите удалить автора \"{0}\"?", "MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?", "MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?", "MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?", @@ -579,7 +579,7 @@ "MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?", "MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.", "MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".", - "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", + "MessageConfirmReScanLibraryItems": "Вы уверены, что хотите пересканировать {0} элементов?", "MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?", "MessageDownloadingEpisode": "Эпизод скачивается", "MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков", From d3256d59d56da99f2acb42ba228696e60c779a5a Mon Sep 17 00:00:00 2001 From: JBlond Date: Wed, 13 Dec 2023 20:12:25 +0100 Subject: [PATCH 15/47] - Translate more strings - Add missing least empty line --- client/strings/de.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 3fa18960..6975b794 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -1,10 +1,10 @@ { "ButtonAdd": "Hinzufügen", "ButtonAddChapters": "Kapitel hinzufügen", - "ButtonAddDevice": "Add Device", - "ButtonAddLibrary": "Add Library", + "ButtonAddDevice": "Gerät hinzufügen", + "ButtonAddLibrary": "Bibliothek hinzufügen", "ButtonAddPodcasts": "Podcasts hinzufügen", - "ButtonAddUser": "Add User", + "ButtonAddUser": "Benutzer hinzufügen", "ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek", "ButtonApply": "Übernehmen", "ButtonApplyChapters": "Kapitel anwenden", @@ -58,11 +58,11 @@ "ButtonRemoveAll": "Alles löschen", "ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge", "ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste", - "ButtonRemoveFromContinueReading": "Remove from Continue Reading", + "ButtonRemoveFromContinueReading": "Lösche die Serie aus der Lesefortsetzungsliste", "ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste", "ButtonReScan": "Neu scannen", "ButtonReset": "Zurücksetzen", - "ButtonResetToDefault": "Reset to default", + "ButtonResetToDefault": "Zurücksetzen auf Standard", "ButtonRestore": "Wiederherstellen", "ButtonSave": "Speichern", "ButtonSaveAndClose": "Speichern & Schließen", @@ -221,7 +221,7 @@ "LabelChapters": "Kapitel", "LabelChaptersFound": "gefundene Kapitel", "LabelChapterTitle": "Kapitelüberschrift", - "LabelClickForMoreInfo": "Click for more info", + "LabelClickForMoreInfo": "Klicken für mehr Informationen", "LabelClosePlayer": "Player schließen", "LabelCodec": "Codec", "LabelCollapseSeries": "Serien zusammenfassen", @@ -251,7 +251,7 @@ "LabelDiscFromMetadata": "CD aus den Metadaten", "LabelDiscover": "Entdecken", "LabelDownload": "Herunterladen", - "LabelDownloadNEpisodes": "Download {0} episodes", + "LabelDownloadNEpisodes": "Download {0} Episoden", "LabelDuration": "Laufzeit", "LabelDurationFound": "Gefundene Laufzeit:", "LabelEbook": "E-Book", @@ -747,4 +747,4 @@ "ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen", "ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden", "ToastUserDeleteSuccess": "Benutzer gelöscht" -} \ No newline at end of file +} From fae383a04520f36928b9f77ee2269cec7de26b90 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 14 Dec 2023 15:45:34 -0600 Subject: [PATCH 16/47] Fix:RSS feeds for collections not updating #2414 --- server/managers/RssFeedManager.js | 14 ++++++++++++-- server/models/Feed.js | 8 ++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 7eb1cce7..3149689d 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -103,19 +103,29 @@ class RssFeedManager { await Database.updateFeed(feed) } } else if (feed.entityType === 'collection') { - const collection = await Database.collectionModel.findByPk(feed.entityId) + const collection = await Database.collectionModel.findByPk(feed.entityId, { + include: Database.collectionBookModel + }) if (collection) { const collectionExpanded = await collection.getOldJsonExpanded() // Find most recently updated item in collection let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate + // Check for most recently updated book collectionExpanded.books.forEach((libraryItem) => { if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) { mostRecentlyUpdatedAt = libraryItem.updatedAt } }) + // Check for most recently added collection book + collection.collectionBooks.forEach((collectionBook) => { + if (collectionBook.createdAt.valueOf() > mostRecentlyUpdatedAt) { + mostRecentlyUpdatedAt = collectionBook.createdAt.valueOf() + } + }) + const hasBooksRemoved = collection.collectionBooks.length < feed.episodes.length - if (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt) { + if (!feed.entityUpdatedAt || hasBooksRemoved || mostRecentlyUpdatedAt > feed.entityUpdatedAt) { Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`) feed.updateFromCollection(collectionExpanded) diff --git a/server/models/Feed.js b/server/models/Feed.js index 72ea146c..d8c5a2a7 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -108,7 +108,7 @@ class Feed extends Model { /** * Find all library item ids that have an open feed (used in library filter) - * @returns {Promise>} array of library item ids + * @returns {Promise} array of library item ids */ static async findAllLibraryItemIds() { const feeds = await this.findAll({ @@ -122,8 +122,8 @@ class Feed extends Model { /** * Find feed where and return oldFeed - * @param {object} where sequelize where object - * @returns {Promise} oldFeed + * @param {Object} where sequelize where object + * @returns {Promise} oldFeed */ static async findOneOld(where) { if (!where) return null @@ -140,7 +140,7 @@ class Feed extends Model { /** * Find feed and return oldFeed * @param {string} id - * @returns {Promise} oldFeed + * @returns {Promise} oldFeed */ static async findByPkOld(id) { if (!id) return null From 1d41904fc3400e268a8ed0f3fd26986245d8cf6e Mon Sep 17 00:00:00 2001 From: nichwall Date: Thu, 14 Dec 2023 21:04:37 -0700 Subject: [PATCH 17/47] Added comments to the Docker Compose file --- docker-compose.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index da3fa1f2..68e012fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,12 +3,28 @@ version: "3.7" services: audiobookshelf: - image: ghcr.io/advplyr/audiobookshelf + image: ghcr.io/advplyr/audiobookshelf:latest + # ABS runs on port 13378 by default. If you want to change + # the port, only change the external port, not the internal port ports: - 13378:80 volumes: + # These volumes are needed to keep your library persistent + # and allow media to be accessed by the ABS server. + # The path to the left of the colon is the path on your computer, + # and the path to the right of the colon is where the data is + # available to ABS in Docker. + # You can change these media directories or add as many as you want - ./audiobooks:/audiobooks - ./podcasts:/podcasts + # The metadata directory can be stored anywhere on your computer - ./metadata:/metadata + # The config directory needs to be on the same physical machine + # you are running ABS on - ./config:/config restart: unless-stopped + # You can use the following environment variable to run the ABS + # docker container as a specific user. You will need to change + # the UID and GID to the correct values for your user. + #environment: + # - user=1000:1000 From 39ceb025004b302bb4650cc382267bacfa0af36f Mon Sep 17 00:00:00 2001 From: SunX Date: Fri, 15 Dec 2023 19:04:56 +0800 Subject: [PATCH 18/47] Update zh-cn.json --- client/strings/zh-cn.json | 76 +++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 7c559489..edf09040 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -1,10 +1,10 @@ { "ButtonAdd": "增加", "ButtonAddChapters": "添加章节", - "ButtonAddDevice": "Add Device", - "ButtonAddLibrary": "Add Library", + "ButtonAddDevice": "添加设备", + "ButtonAddLibrary": "添加库", "ButtonAddPodcasts": "添加播客", - "ButtonAddUser": "Add User", + "ButtonAddUser": "添加用户", "ButtonAddYourFirstLibrary": "添加第一个媒体库", "ButtonApply": "应用", "ButtonApplyChapters": "应用到章节", @@ -62,7 +62,7 @@ "ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除", "ButtonReScan": "重新扫描", "ButtonReset": "重置", - "ButtonResetToDefault": "Reset to default", + "ButtonResetToDefault": "重置为默认", "ButtonRestore": "恢复", "ButtonSave": "保存", "ButtonSaveAndClose": "保存并关闭", @@ -87,15 +87,15 @@ "ButtonUserEdit": "编辑用户 {0}", "ButtonViewAll": "查看全部", "ButtonYes": "确定", - "ErrorUploadFetchMetadataAPI": "Error fetching metadata", - "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", - "ErrorUploadLacksTitle": "Must have a title", + "ErrorUploadFetchMetadataAPI": "获取元数据时出错", + "ErrorUploadFetchMetadataNoResults": "无法获取元数据 - 尝试更新标题和/或作者", + "ErrorUploadLacksTitle": "必须有标题", "HeaderAccount": "帐户", "HeaderAdvanced": "高级", "HeaderAppriseNotificationSettings": "测试通知设置", "HeaderAudiobookTools": "有声读物文件管理工具", "HeaderAudioTracks": "音轨", - "HeaderAuthentication": "Authentication", + "HeaderAuthentication": "身份验证", "HeaderBackups": "备份", "HeaderChangePassword": "更改密码", "HeaderChapters": "章节", @@ -130,15 +130,15 @@ "HeaderManageTags": "管理标签", "HeaderMapDetails": "编辑详情", "HeaderMatch": "匹配", - "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", + "HeaderMetadataOrderOfPrecedence": "元数据优先级", "HeaderMetadataToEmbed": "嵌入元数据", "HeaderNewAccount": "新建帐户", "HeaderNewLibrary": "新建媒体库", "HeaderNotifications": "通知", - "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", + "HeaderOpenIDConnectAuthentication": "OpenID 连接身份验证", "HeaderOpenRSSFeed": "打开 RSS 源", "HeaderOtherFiles": "其他文件", - "HeaderPasswordAuthentication": "Password Authentication", + "HeaderPasswordAuthentication": "密码认证", "HeaderPermissions": "权限", "HeaderPlayerQueue": "播放队列", "HeaderPlaylist": "播放列表", @@ -187,11 +187,11 @@ "LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏", "LabelAddToPlaylist": "添加到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", - "LabelAdminUsersOnly": "Admin users only", + "LabelAdminUsersOnly": "仅限管理员用户", "LabelAll": "全部", "LabelAllUsers": "所有用户", - "LabelAllUsersExcludingGuests": "All users excluding guests", - "LabelAllUsersIncludingGuests": "All users including guests", + "LabelAllUsersExcludingGuests": "除访客外的所有用户", + "LabelAllUsersIncludingGuests": "包括访客的所有用户", "LabelAlreadyInYourLibrary": "已存在你的库中", "LabelAppend": "附加", "LabelAuthor": "作者", @@ -199,12 +199,12 @@ "LabelAuthorLastFirst": "作者 (名, 姓)", "LabelAuthors": "作者", "LabelAutoDownloadEpisodes": "自动下载剧集", - "LabelAutoFetchMetadata": "Auto Fetch Metadata", - "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", - "LabelAutoLaunch": "Auto Launch", - "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path /login?autoLaunch=0)", - "LabelAutoRegister": "Auto Register", - "LabelAutoRegisterDescription": "Automatically create new users after logging in", + "LabelAutoFetchMetadata": "自动获取元数据", + "LabelAutoFetchMetadataHelp": "获取标题, 作者和系列的元数据以简化上传. 上传后可能需要匹配其他元数据.", + "LabelAutoLaunch": "自动启动", + "LabelAutoLaunchDescription": "导航到登录页面时自动重定向到身份验证提供程序 (手动覆盖路径 /login?autoLaunch=0)", + "LabelAutoRegister": "自动注册", + "LabelAutoRegisterDescription": "登录后自动创建新用户", "LabelBackToUser": "返回到用户", "LabelBackupLocation": "备份位置", "LabelBackupsEnableAutomaticBackups": "启用自动备份", @@ -215,13 +215,13 @@ "LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.", "LabelBitrate": "比特率", "LabelBooks": "图书", - "LabelButtonText": "Button Text", + "LabelButtonText": "按钮文本", "LabelChangePassword": "修改密码", "LabelChannels": "声道", "LabelChapters": "章节", "LabelChaptersFound": "找到的章节", "LabelChapterTitle": "章节标题", - "LabelClickForMoreInfo": "Click for more info", + "LabelClickForMoreInfo": "点击了解更多信息", "LabelClosePlayer": "关闭播放器", "LabelCodec": "编解码", "LabelCollapseSeries": "折叠系列", @@ -240,12 +240,12 @@ "LabelCurrently": "当前:", "LabelCustomCronExpression": "自定义计划任务表达式:", "LabelDatetime": "日期时间", - "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", + "LabelDeleteFromFileSystemCheckbox": "从文件系统删除 (取消选中仅从数据库中删除)", "LabelDescription": "描述", "LabelDeselectAll": "全部取消选择", "LabelDevice": "设备", "LabelDeviceInfo": "设备信息", - "LabelDeviceIsAvailableTo": "Device is available to...", + "LabelDeviceIsAvailableTo": "设备可用于...", "LabelDirectory": "目录", "LabelDiscFromFilename": "从文件名获取光盘", "LabelDiscFromMetadata": "从元数据获取光盘", @@ -271,7 +271,7 @@ "LabelExample": "示例", "LabelExplicit": "信息准确", "LabelFeedURL": "源 URL", - "LabelFetchingMetadata": "Fetching Metadata", + "LabelFetchingMetadata": "正在获取元数据", "LabelFile": "文件", "LabelFileBirthtime": "文件创建时间", "LabelFileModified": "文件修改时间", @@ -289,7 +289,7 @@ "LabelHardDeleteFile": "完全删除文件", "LabelHasEbook": "有电子书", "LabelHasSupplementaryEbook": "有补充电子书", - "LabelHighestPriority": "Highest priority", + "LabelHighestPriority": "最高优先级", "LabelHost": "主机", "LabelHour": "小时", "LabelIcon": "图标", @@ -331,20 +331,20 @@ "LabelLogLevelInfo": "信息", "LabelLogLevelWarn": "警告", "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", - "LabelLowestPriority": "Lowest Priority", - "LabelMatchExistingUsersBy": "Match existing users by", - "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", + "LabelLowestPriority": "最低优先级", + "LabelMatchExistingUsersBy": "匹配现有用户", + "LabelMatchExistingUsersByDescription": "用于连接现有用户. 连接后, 用户将通过SSO提供商提供的唯一 id 进行匹配", "LabelMediaPlayer": "媒体播放器", "LabelMediaType": "媒体类型", - "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", + "LabelMetadataOrderOfPrecedenceDescription": "较高优先级的元数据源将覆盖较低优先级的元数据源", "LabelMetadataProvider": "元数据提供者", "LabelMetaTag": "元数据标签", "LabelMetaTags": "元标签", "LabelMinute": "分钟", "LabelMissing": "丢失", "LabelMissingParts": "丢失的部分", - "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", - "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", + "LabelMobileRedirectURIs": "允许移动应用重定向 URI", + "LabelMobileRedirectURIsDescription": "这是移动应用程序的有效重定向 URI 白名单. 默认值为 audiobookshelf://oauth,您可以删除它或添加其他 URI 以进行第三方应用集成. 使用星号 (*) 作为唯一条目允许任何 URI.", "LabelMore": "更多", "LabelMoreInfo": "更多..", "LabelName": "名称", @@ -523,7 +523,7 @@ "LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息", "LabelUploaderDragAndDrop": "拖放文件或文件夹", "LabelUploaderDropFiles": "删除文件", - "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", + "LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列", "LabelUseChapterTrack": "使用章节音轨", "LabelUseFullTrack": "使用完整音轨", "LabelUser": "用户", @@ -557,15 +557,15 @@ "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?", - "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", - "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItem": "这将从数据库和文件系统中删除库项目. 你确定吗?", + "MessageConfirmDeleteLibraryItems": "这将从数据库和文件系统中删除 {0} 个库项目. 你确定吗?", "MessageConfirmDeleteSession": "你确定要删除此会话吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?", "MessageConfirmMarkAllEpisodesFinished": "你确定要将所有剧集都标记为已完成吗?", "MessageConfirmMarkAllEpisodesNotFinished": "你确定要将所有剧集都标记为未完成吗?", "MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?", "MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?", - "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files.

Would you like to continue?", + "MessageConfirmQuickEmbed": "警告! 快速嵌入不会备份你的音频文件. 确保你有音频文件的备份.

你是否想继续吗?", "MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?", "MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?", "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", @@ -579,7 +579,7 @@ "MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?", "MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.", "MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".", - "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", + "MessageConfirmReScanLibraryItems": "你确定要重新扫描 {0} 个项目吗?", "MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?", "MessageDownloadingEpisode": "正在下载剧集", "MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序", @@ -747,4 +747,4 @@ "ToastSocketFailedToConnect": "网络连接失败", "ToastUserDeleteFailed": "删除用户失败", "ToastUserDeleteSuccess": "用户已删除" -} \ No newline at end of file +} From 728496010cbfcee5b7b54001c9f79e02ede30d82 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Dec 2023 10:41:39 -0600 Subject: [PATCH 19/47] 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 0a282c9c..d2334de2 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 20/47] 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 cde84468..3be851ce 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 30/47] 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 00000000..fa57020a --- /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 9b8f7ea5..b527ea38 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 b6895008..f9b9684e 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 31/47] 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 fa57020a..74c57065 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 32/47] 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 439d6c7d..58770aa8 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 58155499..632e38ec 100644 --- a/client/components/ui/Dropdown.vue +++ b/client/components/ui/Dropdown.vue @@ -1,6 +1,6 @@