From 387e58a7146807063521fff10789ffe986348efd Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 21 May 2025 09:57:44 +0300 Subject: [PATCH 001/182] Add levenshteinSimilarity function to utils --- server/utils/index.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/utils/index.js b/server/utils/index.js index 9f7d961c..36962027 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -34,6 +34,14 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => { } module.exports.levenshteinDistance = levenshteinDistance +const levenshteinSimilarity = (str1, str2, caseSensitive = false) => { + const distance = levenshteinDistance(str1, str2, caseSensitive) + const maxLength = Math.max(str1.length, str2.length) + if (maxLength === 0) return 1 + return 1 - distance / maxLength +} +module.exports.levenshteinSimilarity = levenshteinSimilarity + module.exports.isObject = (val) => { return val !== null && typeof val === 'object' } From a894ceb9cf0b34f4c1511cc4183b5b7b7c007cb0 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 21 May 2025 10:25:42 +0300 Subject: [PATCH 002/182] Match confidence calculation for audible results --- server/finders/BookFinder.js | 149 ++++++++++++++- test/server/finders/BookFinder.test.js | 251 ++++++++++++++++++++++++- 2 files changed, 383 insertions(+), 17 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 8fde7bc4..2d7b57f1 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -7,7 +7,7 @@ const FantLab = require('../providers/FantLab') const AudiobookCovers = require('../providers/AudiobookCovers') const CustomProviderAdapter = require('../providers/CustomProviderAdapter') const Logger = require('../Logger') -const { levenshteinDistance, escapeRegExp } = require('../utils/index') +const { levenshteinDistance, levenshteinSimilarity, escapeRegExp, isValidASIN } = require('../utils/index') const htmlSanitizer = require('../utils/htmlSanitizer') class BookFinder { @@ -385,7 +385,11 @@ class BookFinder { if (!title) return books - books = await this.runSearch(title, author, provider, asin, maxTitleDistance, maxAuthorDistance) + const isTitleAsin = isValidASIN(title.toUpperCase()) + + let actualTitleQuery = title + let actualAuthorQuery = author + books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance) if (!books.length && maxFuzzySearches > 0) { // Normalize title and author @@ -408,19 +412,26 @@ class BookFinder { for (const titlePart of titleParts) titleCandidates.add(titlePart) titleCandidates = titleCandidates.getCandidates() for (const titleCandidate of titleCandidates) { - if (titleCandidate == title && authorCandidate == author) continue // We already tried this + if (titleCandidate == actualTitleQuery && authorCandidate == actualAuthorQuery) continue // We already tried this if (++numFuzzySearches > maxFuzzySearches) break loop_author - books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance) + actualTitleQuery = titleCandidate + actualAuthorQuery = authorCandidate + books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance) if (books.length) break loop_author } } } if (books.length) { - const resultsHaveDuration = provider.startsWith('audible') - if (resultsHaveDuration && libraryItem?.media?.duration) { - const libraryItemDurationMinutes = libraryItem.media.duration / 60 - // If provider results have duration, sort by ascendinge duration difference from libraryItem + const isAudibleProvider = provider.startsWith('audible') + const libraryItemDurationMinutes = libraryItem?.media?.duration ? libraryItem.media.duration / 60 : null + + books.forEach((book) => { + if (typeof book !== 'object' || !isAudibleProvider) return + book.matchConfidence = this.calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin) + }) + + if (isAudibleProvider && libraryItemDurationMinutes) { books.sort((a, b) => { const aDuration = a.duration || Number.POSITIVE_INFINITY const bDuration = b.duration || Number.POSITIVE_INFINITY @@ -433,6 +444,120 @@ class BookFinder { return books } + /** + * Calculate match confidence score for a book + * @param {Object} book - The book object to calculate confidence for + * @param {number|null} libraryItemDurationMinutes - Duration of library item in minutes + * @param {string} actualTitleQuery - Actual title query + * @param {string} actualAuthorQuery - Actual author query + * @param {boolean} isTitleAsin - Whether the title is an ASIN + * @returns {number|null} - Match confidence score or null if not applicable + */ + calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin) { + // ASIN results are always a match + if (isTitleAsin) return 1.0 + + let durationScore + if (libraryItemDurationMinutes && typeof book.duration === 'number') { + const durationDiff = Math.abs(book.duration - libraryItemDurationMinutes) + // Duration scores: + // diff | score + // 0 | 1.0 + // 1 | 1.0 + // 2 | 0.9 + // 3 | 0.8 + // 4 | 0.7 + // 5 | 0.6 + // 6 | 0.48 + // 7 | 0.36 + // 8 | 0.24 + // 9 | 0.12 + // 10 | 0.0 + if (durationDiff <= 1) { + // Covers durationDiff = 0 for score 1.0 + durationScore = 1.0 + } else if (durationDiff <= 5) { + // (1, 5] - Score from 1.0 down to 0.6 + // Linearly interpolates between (1, 1.0) and (5, 0.6) + // Equation: y = 1.0 - 0.08 * x + durationScore = 1.1 - 0.1 * durationDiff + } else if (durationDiff <= 10) { + // (5, 10] - Score from 0.6 down to 0.0 + // Linearly interpolates between (5, 0.6) and (10, 0.0) + // Equation: y = 1.2 - 0.12 * x + durationScore = 1.2 - 0.12 * durationDiff + } else { + // durationDiff > 10 - Score is 0.0 + durationScore = 0.0 + } + Logger.debug(`[BookFinder] Duration diff: ${durationDiff}, durationScore: ${durationScore}`) + } else { + // Default score if library item duration or book duration is not available + durationScore = 0.1 + } + + const calculateTitleScore = (titleQuery, book, keepSubtitle = false) => { + const cleanTitle = cleanTitleForCompares(book.title || '', keepSubtitle) + const cleanSubtitle = keepSubtitle && book.subtitle ? `: ${book.subtitle}` : '' + const normBookTitle = `${cleanTitle}${cleanSubtitle}` + const normTitleQuery = cleanTitleForCompares(titleQuery, keepSubtitle) + const titleSimilarity = levenshteinSimilarity(normTitleQuery, normBookTitle) + Logger.debug(`[BookFinder] keepSubtitle: ${keepSubtitle}, normBookTitle: ${normBookTitle}, normTitleQuery: ${normTitleQuery}, titleSimilarity: ${titleSimilarity}`) + return titleSimilarity + } + const titleQueryHasSubtitle = hasSubtitle(actualTitleQuery) + const titleScore = calculateTitleScore(actualTitleQuery, book, titleQueryHasSubtitle) + + let authorScore + const normAuthorQuery = cleanAuthorForCompares(actualAuthorQuery) + const normBookAuthor = cleanAuthorForCompares(book.author || '') + if (!normAuthorQuery) { + // Original query had no author + authorScore = 1.0 // Neutral score + } else { + // Original query HAS an author (cleanedQueryAuthorForScore is not empty) + if (normBookAuthor) { + const bookAuthorParts = normBookAuthor.split(',').map((name) => name.trim().toLowerCase()) + // Filter out empty parts that might result from ", ," or trailing/leading commas + const validBookAuthorParts = bookAuthorParts.filter((p) => p.length > 0) + + if (validBookAuthorParts.length === 0) { + // Book author string was present but effectively empty (e.g. ",,") + // Since cleanedQueryAuthorForScore is non-empty here, this is a mismatch. + authorScore = 0.0 + } else { + let maxPartScore = levenshteinSimilarity(normAuthorQuery, normBookAuthor) + Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, normBookAuthor: ${normBookAuthor}, similarity: ${maxPartScore}`) + if (validBookAuthorParts.length > 1 || normBookAuthor.includes(',')) { + validBookAuthorParts.forEach((part) => { + // part is guaranteed to be non-empty here + // cleanedQueryAuthorForScore is also guaranteed non-empty here. + // levenshteinDistance lowercases by default, but part is already lowercased. + const similarity = levenshteinSimilarity(normAuthorQuery, part) + Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, bookAuthorPart: ${part}, similarity: ${similarity}`) + const currentPartScore = similarity + maxPartScore = Math.max(maxPartScore, currentPartScore) + }) + } + authorScore = maxPartScore + } + } else { + // Book has NO author (or not a string, or empty string) + // Query has an author (cleanedQueryAuthorForScore is non-empty), book does not. + authorScore = 0.0 + } + } + + const W_DURATION = 0.7 + const W_TITLE = 0.2 + const W_AUTHOR = 0.1 + + Logger.debug(`[BookFinder] Duration score: ${durationScore}, Title score: ${titleScore}, Author score: ${authorScore}`) + const confidence = W_DURATION * durationScore + W_TITLE * titleScore + W_AUTHOR * authorScore + Logger.debug(`[BookFinder] Confidence: ${confidence}`) + return Math.max(0, Math.min(1, confidence)) + } + /** * Search for books * @@ -464,6 +589,7 @@ class BookFinder { } else { books = await this.getGoogleBooksResults(title, author) } + books.forEach((book) => { if (book.description) { book.description = htmlSanitizer.sanitize(book.description) @@ -505,6 +631,9 @@ class BookFinder { } module.exports = new BookFinder() +function hasSubtitle(title) { + return title.includes(':') || title.includes(' - ') +} function stripSubtitle(title) { if (title.includes(':')) { return title.split(':')[0].trim() @@ -523,12 +652,12 @@ function replaceAccentedChars(str) { } } -function cleanTitleForCompares(title) { +function cleanTitleForCompares(title, keepSubtitle = false) { if (!title) return '' title = stripRedundantSpaces(title) // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book") - let stripped = stripSubtitle(title) + let stripped = keepSubtitle ? title : stripSubtitle(title) // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game") let cleaned = stripped.replace(/ *\([^)]*\) */g, '') diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js index c986cc98..6578ca82 100644 --- a/test/server/finders/BookFinder.test.js +++ b/test/server/finders/BookFinder.test.js @@ -5,6 +5,12 @@ const bookFinder = require('../../../server/finders/BookFinder') const { LogLevel } = require('../../../server/utils/constants') const Logger = require('../../../server/Logger') Logger.setLogLevel(LogLevel.INFO) +const { levenshteinDistance } = require('../../../server/utils/index') + +// levenshteinDistance is needed for manual calculation of expected scores in tests. +// Assuming it's accessible for testing purposes or we mock/replicate its basic behavior if needed. +// For now, we'll assume bookFinder.search uses it internally correctly. +// const { levenshteinDistance } = require('../../../server/utils/index') // Not used directly in test logic, but for reasoning. describe('TitleCandidates', () => { describe('cleanAuthor non-empty', () => { @@ -326,31 +332,262 @@ describe('search', () => { const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }] beforeEach(() => { - runSearchStub.withArgs(t, a, provider).resolves(unsorted) + runSearchStub.withArgs(t, a, provider).resolves(structuredClone(unsorted)) + }) + + afterEach(() => { + sinon.restore() }) it('returns results sorted by library item duration diff', async () => { - expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted) + const result = (await bookFinder.search(libraryItem, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {})) + expect(result).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) + const result = (await bookFinder.search(null, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {})) + expect(result).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) + const result = (await bookFinder.search({ media: {} }, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {})) + expect(result).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) + const result = (await bookFinder.search({}, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {})) + expect(result).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) + runSearchStub.withArgs(t, a, provider).resolves(structuredClone(unsorted)) + const result = (await bookFinder.search(libraryItem, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {})) + expect(result).to.deep.equal(sorted) + }) + }) - expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted) + describe('matchConfidence score', () => { + const W_DURATION = 0.7 + const W_TITLE = 0.2 + const W_AUTHOR = 0.1 + const DEFAULT_DURATION_SCORE_MISSING_INFO = 0.1 + + const libraryItemPerfectDuration = { media: { duration: 600 } } // 10 minutes + + // Helper to calculate expected title/author score based on Levenshtein + // Assumes queryPart and bookPart are already "cleaned" for length calculation consistency with BookFinder.js + const calculateStringMatchScore = (cleanedQueryPart, cleanedBookPart) => { + if (!cleanedQueryPart) return cleanedBookPart ? 0 : 1 // query empty: 1 if book empty, else 0 + if (!cleanedBookPart) return 0 // query non-empty, book empty: 0 + + // Use the imported levenshteinDistance. It defaults to case-insensitive, which is what we want. + const distance = levenshteinDistance(cleanedQueryPart, cleanedBookPart) + return Math.max(0, 1 - distance / Math.max(cleanedQueryPart.length, cleanedBookPart.length)) + } + + beforeEach(() => { + runSearchStub.resolves([]) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('for audible provider', () => { + const provider = 'audible' + + it('should be 1.0 for perfect duration, title, and author match', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }] + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe') + // durationScore = 1.0 (diff 0 <= 1 min) + // titleScore = 1.0 (exact match) + // authorScore = 1.0 (exact match) + const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0 + expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001) + }) + + it('should correctly score a large duration mismatch', async () => { + const bookResults = [{ duration: 21, title: 'The Great Novel', author: 'John Doe' }] // 21 min, diff = 11 min + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe') + // durationScore = 0.0 + // titleScore = 1.0 + // authorScore = 1.0 + const expectedConfidence = W_DURATION * 0.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0 + expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001) + }) + + it('should correctly score a medium duration mismatch', async () => { + const bookResults = [{ duration: 16, title: 'The Great Novel', author: 'John Doe' }] // 16 min, diff = 6 min + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe') + // durationScore = 1.2 - 6 * 0.12 = 0.48 + // titleScore = 1.0 + // authorScore = 1.0 + const expectedConfidence = W_DURATION * 0.48 + W_TITLE * 1.0 + W_AUTHOR * 1.0 + expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001) + }) + + it('should correctly score a minor duration mismatch', async () => { + const bookResults = [{ duration: 14, title: 'The Great Novel', author: 'John Doe' }] // 14 min, diff = 4 min + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe') + // durationScore = 1.1 - 4 * 0.1 = 0.7 + // titleScore = 1.0 + // authorScore = 1.0 + const expectedConfidence = W_DURATION * 0.7 + W_TITLE * 1.0 + W_AUTHOR * 1.0 + expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001) + }) + + it('should correctly score a tiny duration mismatch', async () => { + const bookResults = [{ duration: 11, title: 'The Great Novel', author: 'John Doe' }] // 11 min, diff = 1 min + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe') + // durationScore = 1.0 + // titleScore = 1.0 + // authorScore = 1.0 + const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0 + expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001) + }) + + it('should use default duration score if libraryItem duration is missing', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }] + runSearchStub.resolves(bookResults) + const results = await bookFinder.search({ media: {} }, provider, 'The Great Novel', 'John Doe') + // durationScore = DEFAULT_DURATION_SCORE_MISSING_INFO (0.2) + const expectedConfidence = W_DURATION * DEFAULT_DURATION_SCORE_MISSING_INFO + W_TITLE * 1.0 + W_AUTHOR * 1.0 + expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001) + }) + + it('should use default duration score if book duration is missing', async () => { + const bookResults = [{ title: 'The Great Novel', author: 'John Doe' }] // No duration in book + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe') + // durationScore = DEFAULT_DURATION_SCORE_MISSING_INFO (0.2) + const expectedConfidence = W_DURATION * DEFAULT_DURATION_SCORE_MISSING_INFO + W_TITLE * 1.0 + W_AUTHOR * 1.0 + expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001) + }) + + it('should correctly score a partial title match', async () => { + const bookResults = [{ duration: 10, title: 'Novel', author: 'John Doe' }] + runSearchStub.resolves(bookResults) + // Query: 'Novel Ex', Book: 'Novel' + // cleanTitleForCompares('Novel Ex') -> 'novel ex' (length 8) + // cleanTitleForCompares('Novel') -> 'novel' (length 5) + // levenshteinDistance('novel ex', 'novel') = 3 + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'Novel Ex', 'John Doe') + const expectedTitleScore = calculateStringMatchScore('novel ex', 'novel') // 1 - (3/8) = 0.625 + const expectedConfidence = W_DURATION * 1.0 + W_TITLE * expectedTitleScore + W_AUTHOR * 1.0 + expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001) + }) + + it('should correctly score a partial author match (comma-separated)', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'Jane Smith, Jon Doee' }] + runSearchStub.resolves(bookResults) + // Query: 'Jon Doe', Book part: 'Jon Doee' + // cleanAuthorForCompares('Jon Doe') -> 'jon doe' (length 7) + // book author part (already lowercased) -> 'jon doee' (length 8) + // levenshteinDistance('jon doe', 'jon doee') = 1 + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'Jon Doe') + // For the author part 'jon doee': + const expectedAuthorPartScore = calculateStringMatchScore('jon doe', 'jon doee') // 1 - (1/7) + // Assuming 'jane smith' gives a lower or 0 score, max score will be from 'jon doee' + const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * expectedAuthorPartScore + expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001) + }) + + it('should give authorScore 0 if query has author but book does not', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', author: null }] + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe') + // authorScore = 0.0 + const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 0.0 + expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001) + }) + + it('should give authorScore 1.0 if query has no author', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }] + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', '') // Empty author + expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001) + }) + + it('handles book author string that is only commas correctly (score 0)', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', author: ',, ,, ,' }] + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe') + // cleanedQueryAuthorForScore = "john doe" + // book.author leads to validBookAuthorParts being empty. + // authorScore = 0.0 + const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 0.0 + expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001) + }) + + it('should return 1.0 for ASIN results', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }] + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'B000F28ZJ4', null) + expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001) + }) + + it('should return 1.0 when author matches one of the book authors', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe, Jane Smith' }] + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe') + expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001) + }) + + it('should return 1.0 when author query and multiple book authors are the same', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe, Jane Smith' }] + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe, Jane Smith') + expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001) + }) + + it('should correctly score against a book with a subtitle when the query has a subtitle', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', subtitle: 'A Novel', author: 'John Doe' }] + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel: A Novel', 'John Doe') + expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001) + }) + + it('should correctly score against a book with a subtitle when the query does not have a subtitle', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', subtitle: 'A Novel', author: 'John Doe' }] + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe') + expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001) + }) + + describe('after fuzzy searches', () => { + it('should return 1.0 for a title candidate match', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }] + runSearchStub.resolves([]) + runSearchStub.withArgs('the great novel', 'john doe').resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel - A Novel', 'John Doe') + expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001) + }) + + it('should return 1.0 for an author candidate match', async () => { + const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }] + runSearchStub.resolves([]) + runSearchStub.withArgs('the great novel', 'john doe').resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe, Jane Smith') + expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001) + }) + }) + }) + + describe('for non-audible provider (e.g., google)', () => { + const provider = 'google' + it('should have not have matchConfidence', async () => { + const bookResults = [{ title: 'The Great Novel', author: 'John Doe' }] + runSearchStub.resolves(bookResults) + const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe') + expect(results[0]).to.not.have.property('matchConfidence') + }) }) }) }) From de25763a74ec57074489596d855550f8665bcf81 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 21 May 2025 11:16:46 +0300 Subject: [PATCH 003/182] Add match confidence display to BookMatchCard --- client/components/cards/BookMatchCard.vue | 1 + client/strings/en-us.json | 1 + 2 files changed, 2 insertions(+) diff --git a/client/components/cards/BookMatchCard.vue b/client/components/cards/BookMatchCard.vue index 87aa0a71..17f49752 100644 --- a/client/components/cards/BookMatchCard.vue +++ b/client/components/cards/BookMatchCard.vue @@ -16,6 +16,7 @@

{{ $getString('LabelByAuthor', [book.author]) }}

{{ $strings.LabelNarrators }}: {{ book.narrator }}

{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}

+

{{ $strings.LabelMatchConfidence }}: {{ book.matchConfidence.toFixed(3) }}

diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 939eb9f4..9bcc5353 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -425,6 +425,7 @@ "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelLowestPriority": "Lowest Priority", + "LabelMatchConfidence": "Match Confidence", "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", "LabelMaxEpisodesToDownload": "Max # of episodes to download. Use 0 for unlimited.", From 6aa7c8a3d8734b55f932e8c270d830bc8644d36f Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:34:18 +0200 Subject: [PATCH 004/182] added notification --- server/managers/NotificationManager.js | 48 ++++++++++++++++++++++++++ server/managers/PodcastManager.js | 2 ++ server/utils/notifications.js | 32 +++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/server/managers/NotificationManager.js b/server/managers/NotificationManager.js index 8edcf428..4f7072be 100644 --- a/server/managers/NotificationManager.js +++ b/server/managers/NotificationManager.js @@ -71,6 +71,54 @@ class NotificationManager { this.triggerNotification('onBackupCompleted', eventData) } + /** + * Handles RSS feed updates + * @param feedUrl + * @param numFailed + * @param title + * @returns {Promise} + */ + async onRSSFeedFailed(feedUrl, numFailed, title) { + if (!Database.notificationSettings.isUseable) return + + if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onRSSFeedFailed')) { + Logger.debug(`[NotificationManager] onRSSFeedFailed: No active notifications`) + return + } + + Logger.debug(`[NotificationManager] onRSSFeedFailed: RSS feed update failed for ${feedUrl}`) + const eventData = { + feedUrl: feedUrl, + numFailed: numFailed || 0, + title: title || 'Unknown Title' + } + this.triggerNotification('onRSSFeedFailed', eventData) + } + + /** + * Handles RSS feed being disabled due to too many failed updates + * @param feedUrl + * @param numFailed + * @param title + * @returns {Promise} + */ + async onRSSFeedDisabled(feedUrl, numFailed, title) { + if (!Database.notificationSettings.isUseable) return + + if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onRSSFeedDisabled')) { + Logger.debug(`[NotificationManager] onRSSFeedDisabled: No active notifications`) + return + } + + Logger.debug(`[NotificationManager] onRSSFeedDisabled: RSS feed disabled due to ${numFailed} failed updates for ${feedUrl}`) + const eventData = { + feedUrl: feedUrl, + numFailed: numFailed || 0, + title: title || 'Unknown Title' + } + this.triggerNotification('onRSSFeedDisabled', eventData) + } + /** * * @param {string} errorMsg diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 625688c9..e5da8fe6 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -347,10 +347,12 @@ class PodcastManager { this.failedCheckMap[libraryItem.id]++ if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) { Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`) + void NotificationManager.onRSSFeedDisabled(libraryItem.media.feedURL, this.failedCheckMap[libraryItem.id], libraryItem.media.title) libraryItem.media.autoDownloadEpisodes = false delete this.failedCheckMap[libraryItem.id] } else { Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`) + void NotificationManager.onRSSFeedFailed(libraryItem.media.feedURL, this.failedCheckMap[libraryItem.id], libraryItem.media.title) } } else if (newEpisodes.length) { delete this.failedCheckMap[libraryItem.id] diff --git a/server/utils/notifications.js b/server/utils/notifications.js index 7a3e1198..1b9612b9 100644 --- a/server/utils/notifications.js +++ b/server/utils/notifications.js @@ -60,6 +60,38 @@ module.exports.notificationData = { errorMsg: 'Example error message' } }, + { + name: 'onRSSFeedFailed', + requiresLibrary: true, + description: 'Triggered when an RSS feed request/update fails, but gets not disabled', + descriptionKey: 'NotificationOnRSSFeedFailedDescription', + variables: ['feedUrl', 'numFailed', 'title'], + defaults: { + title: 'RSS Feed Update Failed', + body: 'Failed to update RSS feed for {{title}}.\nFeed URL: {{feedUrl}}\nNumber of failed attempts: {{numFailed}}' + }, + testData: { + title: 'Test RSS Feed', + feedUrl: 'https://example.com/rss', + numFailed: 3 + } + }, + { + name: 'onRSSFeedDisabled', + requiresLibrary: true, + description: 'Triggered when an RSS feed is disabled due to too many failed attempts', + descriptionKey: 'NotificationOnRSSFeedDisabledDescription', + variables: ['feedUrl', 'numFailed', 'title'], + defaults: { + title: 'RSS Feed Disabled', + body: 'RSS feed for {{title}} has been disabled due to too many failed updates.\nFeed URL: {{feedUrl}}\nNumber of failed attempts: {{numFailed}}' + }, + testData: { + title: 'Test RSS Feed', + feedUrl: 'https://example.com/rss', + numFailed: 5 + } + }, { name: 'onTest', requiresLibrary: false, From 346df3680ce8dd415ce3d77d5ce789686e8dd5f9 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Thu, 5 Jun 2025 14:02:29 +0200 Subject: [PATCH 005/182] local strings --- client/strings/en-us.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 939eb9f4..65fd5f0e 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -918,6 +918,8 @@ "NotificationOnBackupCompletedDescription": "Triggered when a backup is completed", "NotificationOnBackupFailedDescription": "Triggered when a backup fails", "NotificationOnEpisodeDownloadedDescription": "Triggered when a podcast episode is auto-downloaded", + "NotificationOnRSSFeedFailedDescription": "Triggered when an RSS feed request/update fails, but gets not disabled", + "NotificationOnRSSFeedDisabledDescription": "Triggered when an RSS feed is disabled due to too many failed attempts", "NotificationOnTestDescription": "Event for testing the notification system", "PlaceholderNewCollection": "New collection name", "PlaceholderNewFolderPath": "New folder path", From f0525d4f0de8392b87d788fda3a0a6bda57d15c8 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Thu, 5 Jun 2025 14:09:35 +0200 Subject: [PATCH 006/182] abc is hard --- client/strings/en-us.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 65fd5f0e..afecd671 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -918,8 +918,8 @@ "NotificationOnBackupCompletedDescription": "Triggered when a backup is completed", "NotificationOnBackupFailedDescription": "Triggered when a backup fails", "NotificationOnEpisodeDownloadedDescription": "Triggered when a podcast episode is auto-downloaded", - "NotificationOnRSSFeedFailedDescription": "Triggered when an RSS feed request/update fails, but gets not disabled", "NotificationOnRSSFeedDisabledDescription": "Triggered when an RSS feed is disabled due to too many failed attempts", + "NotificationOnRSSFeedFailedDescription": "Triggered when an RSS feed request/update fails, but gets not disabled", "NotificationOnTestDescription": "Event for testing the notification system", "PlaceholderNewCollection": "New collection name", "PlaceholderNewFolderPath": "New folder path", From eda7036f70c9a3a1cd699231c8ac9531368aee31 Mon Sep 17 00:00:00 2001 From: Jan Kubovy Date: Fri, 6 Jun 2025 10:43:52 +0000 Subject: [PATCH 007/182] Use fuse.js for podcast episode search Replace levenshtein distance with fuse.js fuzzy searching library. Search in episode's title and subtitle --- package-lock.json | 9 +++++++++ package.json | 1 + server/utils/podcastUtils.js | 39 +++++++++++++++++------------------- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index a8074794..9147d9e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "cookie-parser": "^1.4.6", "express": "^4.17.1", "express-session": "^1.17.3", + "fuse.js": "^7.1.0", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", @@ -2105,6 +2106,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/package.json b/package.json index d4831736..2a77ec87 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "cookie-parser": "^1.4.6", "express": "^4.17.1", "express-session": "^1.17.3", + "fuse.js": "^7.1.0", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 3a1df198..74a71cc1 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -3,6 +3,7 @@ const ssrfFilter = require('ssrf-req-filter') const Logger = require('../Logger') const { xmlToJSON, levenshteinDistance, timestampToSeconds } = require('./index') const htmlSanitizer = require('../utils/htmlSanitizer') +const Fuse = require('fuse.js') /** * @typedef RssPodcastChapter @@ -407,7 +408,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { }) } -// Return array of episodes ordered by closest match (Levenshtein distance of 6 or less) +// Return array of episodes ordered by closest match using fuse.js module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => { const feed = await this.getPodcastFeed(feedUrl).catch(() => { return null @@ -420,32 +421,28 @@ module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => { * * @param {RssPodcast} feed * @param {string} searchTitle - * @returns {Array<{ episode: RssPodcastEpisode, levenshtein: number }>} + * @returns {Array<{ episode: RssPodcastEpisode }>} */ module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => { - searchTitle = searchTitle.toLowerCase().trim() if (!feed?.episodes) { return null } + const fuseOptions = { + ignoreDiacritics: true, + threshold: 0.4, // default 0.6 return too many matches + keys: [ + {name: 'title', weight: 0.7}, // prefer match in title + {name: 'subtitle', weight: 0.3} + ] + } + const fuse = new Fuse(feed.episodes, fuseOptions) + const matches = [] - feed.episodes.forEach((ep) => { - if (!ep.title) return - const epTitle = ep.title.toLowerCase().trim() - if (epTitle === searchTitle) { - matches.push({ - episode: ep, - levenshtein: 0 - }) - } else { - const levenshtein = levenshteinDistance(searchTitle, epTitle, true) - if (levenshtein <= 6 && epTitle.length > levenshtein) { - matches.push({ - episode: ep, - levenshtein - }) - } - } + fuse.search(searchTitle).forEach((match) => { + matches.push({ + episode: match.item + }) }) - return matches.sort((a, b) => a.levenshtein - b.levenshtein) + return matches } From 81640464ba71a917d199a885f86658ea38a9fb5a Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 6 Jun 2025 17:05:07 -0500 Subject: [PATCH 008/182] Update cleanDatabase to remove duplicate mediaProgresses --- server/Database.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/Database.js b/server/Database.js index 52827e3f..2413a269 100644 --- a/server/Database.js +++ b/server/Database.js @@ -765,6 +765,15 @@ class Database { if (badSessionsRemoved > 0) { Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`) } + + // Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt) + const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT id, mediaItemId FROM mediaProgresses WHERE (mediaItemId, updatedAt) IN (SELECT mediaItemId, MIN(updatedAt) FROM mediaProgresses GROUP BY mediaItemId HAVING COUNT(*) > 1)`) + for (const duplicateMediaProgress of duplicateMediaProgresses) { + Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`) + await this.mediaProgressModel.destroy({ + where: { id: duplicateMediaProgress.id } + }) + } } async createTextSearchQuery(query) { From 0c5d05d3199c3aea64769a1f5f2c1535d8b3c9c6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 7 Jun 2025 17:10:23 -0500 Subject: [PATCH 009/182] Fix chapter table on audiobook tools page uneven column widths --- client/pages/audiobook/_id/manage.vue | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/pages/audiobook/_id/manage.vue b/client/pages/audiobook/_id/manage.vue index 37734934..40672af2 100644 --- a/client/pages/audiobook/_id/manage.vue +++ b/client/pages/audiobook/_id/manage.vue @@ -28,14 +28,14 @@

-
{{ $strings.LabelMetaTag }}
-
{{ $strings.LabelValue }}
+
{{ $strings.LabelMetaTag }}
+
{{ $strings.LabelValue }}
@@ -33,10 +33,12 @@ export default { }, data() { return { + loadingUsers: false, selectedApiKey: null, showApiKeyModal: false, showApiKeyCreatedModal: false, - numApiKeys: 0 + numApiKeys: 0, + users: [] } }, methods: { @@ -45,7 +47,6 @@ export default { this.selectedApiKey = apiKey this.showApiKeyCreatedModal = true if (this.$refs.apiKeysTable) { - console.log('apiKeyCreated', apiKey) this.$refs.apiKeysTable.addApiKey(apiKey) } }, @@ -60,9 +61,27 @@ export default { setShowApiKeyModal(selectedApiKey) { this.selectedApiKey = selectedApiKey this.showApiKeyModal = true + }, + loadUsers() { + this.loadingUsers = true + this.$axios + .$get('/api/users') + .then((res) => { + this.users = res.users.sort((a, b) => { + return a.createdAt - b.createdAt + }) + }) + .catch((error) => { + console.error('Failed', error) + }) + .finally(() => { + this.loadingUsers = false + }) } }, - mounted() {}, + mounted() { + this.loadUsers() + }, beforeDestroy() {} } diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 62443e0b..0d76f2e7 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -242,6 +242,8 @@ "LabelAlreadyInYourLibrary": "Already in your library", "LabelApiKeyCreated": "API Key \"{0}\" created successfully.", "LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.", + "LabelApiKeyUser": "Act on behalf of user", + "LabelApiKeyUserDescription": "This API key will have the same permissions as the user it is acting on behalf of. This will appear the same in logs as if the user was making the request.", "LabelApiToken": "API Token", "LabelAppend": "Append", "LabelAudioBitrate": "Audio Bitrate (e.g. 128k)", @@ -353,6 +355,7 @@ "LabelExample": "Example", "LabelExpandSeries": "Expand Series", "LabelExpandSubSeries": "Expand Sub Series", + "LabelExpired": "Expired", "LabelExpiresAt": "Expires At", "LabelExpiresInSeconds": "Expires in (seconds)", "LabelExpiresNever": "Never", @@ -418,7 +421,6 @@ "LabelLastSeen": "Last Seen", "LabelLastTime": "Last Time", "LabelLastUpdate": "Last Update", - "LabelLastUsed": "Last Used", "LabelLayout": "Layout", "LabelLayoutSinglePage": "Single page", "LabelLayoutSplitPage": "Split page", @@ -556,6 +558,7 @@ "LabelSelectAll": "Select all", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUser": "Select user", "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Sequence", @@ -1046,6 +1049,7 @@ "ToastMustHaveAtLeastOnePath": "Must have at least one path", "ToastNameEmailRequired": "Name and email are required", "ToastNameRequired": "Name is required", + "ToastNewApiKeyUserError": "Must select a user", "ToastNewEpisodesFound": "{0} new episodes found", "ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"", "ToastNewUserCreatedSuccess": "New account created", diff --git a/server/Auth.js b/server/Auth.js index 1839d27f..b1d94d41 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -65,7 +65,9 @@ class Auth { new JwtStrategy( { jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), - secretOrKey: Database.serverSettings.tokenSecret + secretOrKey: Database.serverSettings.tokenSecret, + // Handle expiration manaully in order to disable api keys that are expired + ignoreExpiration: true }, this.jwtAuthCheck.bind(this) ) @@ -1044,6 +1046,7 @@ class Auth { } await Database.updateServerSettings() + // TODO: Old method of non-expiring tokens // New token secret creation added in v2.1.0 so generate new API tokens for each user const users = await Database.userModel.findAll({ attributes: ['id', 'username', 'token'] @@ -1057,22 +1060,49 @@ class Auth { } /** - * Checks if the user in the validated jwt_payload really exists and is active. + * Checks if the user or api key in the validated jwt_payload exists and is active. * @param {Object} jwt_payload * @param {function} done */ async jwtAuthCheck(jwt_payload, done) { - // load user by id from the jwt token - const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) + if (jwt_payload.type === 'api') { + const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId) - if (!user?.isActive) { - // deny login - done(null, null) - return + if (!apiKey?.isActive) { + done(null, null) + return + } + + // Check if the api key is expired and deactivate it + if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) { + done(null, null) + + apiKey.isActive = false + await apiKey.save() + Logger.info(`[Auth] API key ${apiKey.id} is expired - deactivated`) + return + } + + const user = await Database.userModel.getUserById(apiKey.userId) + done(null, user) + } else { + // Check if the jwt is expired + if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) { + done(null, null) + return + } + + // load user by id from the jwt token + const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) + + if (!user?.isActive) { + // deny login + done(null, null) + return + } + // approve login + done(null, user) } - // approve login - done(null, user) - return } /** diff --git a/server/Database.js b/server/Database.js index b632d040..213c2c61 100644 --- a/server/Database.js +++ b/server/Database.js @@ -670,6 +670,7 @@ class Database { * Remove playback sessions that are 3 seconds or less * Remove duplicate mediaProgresses * Remove expired auth sessions + * Deactivate expired api keys */ async cleanDatabase() { // Remove invalid Podcast records @@ -802,6 +803,23 @@ WHERE EXISTS ( // Remove expired Session records await this.cleanupExpiredSessions() + + // Deactivate expired api keys + await this.deactivateExpiredApiKeys() + } + + /** + * Deactivate expired api keys + */ + async deactivateExpiredApiKeys() { + try { + const affectedCount = await this.apiKeyModel.deactivateExpiredApiKeys() + if (affectedCount > 0) { + Logger.info(`[Database] Deactivated ${affectedCount} expired api keys`) + } + } catch (error) { + Logger.error(`[Database] Error deactivating expired api keys: ${error.message}`) + } } /** diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js index 776ddcbe..0166479f 100644 --- a/server/controllers/ApiKeyController.js +++ b/server/controllers/ApiKeyController.js @@ -20,7 +20,19 @@ class ApiKeyController { * @param {Response} res */ async getAll(req, res) { - const apiKeys = await Database.apiKeyModel.findAll() + const apiKeys = await Database.apiKeyModel.findAll({ + include: [ + { + model: Database.userModel, + attributes: ['id', 'username', 'type'] + }, + { + model: Database.userModel, + as: 'createdByUser', + attributes: ['id', 'username', 'type'] + } + ] + }) return res.json({ apiKeys: apiKeys.map((a) => a.toJSON()) @@ -42,10 +54,21 @@ class ApiKeyController { Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`) return res.sendStatus(400) } + if (!req.body.userId || typeof req.body.userId !== 'string') { + Logger.warn(`[ApiKeyController] create: Invalid userId: ${req.body.userId}`) + return res.sendStatus(400) + } + const user = await Database.userModel.getUserById(req.body.userId) + if (!user) { + Logger.warn(`[ApiKeyController] create: User not found: ${req.body.userId}`) + return res.sendStatus(400) + } + if (user.type === 'root' && !req.user.isRoot) { + Logger.warn(`[ApiKeyController] create: Root user API key cannot be created by non-root user`) + return res.sendStatus(403) + } const keyId = uuidv4() // Generate key id ahead of time to use in JWT - - const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions) const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn) if (!apiKey) { @@ -60,9 +83,9 @@ class ApiKeyController { id: keyId, name: req.body.name, expiresAt, - permissions, - userId: req.user.id, - isActive: !!req.body.isActive + userId: req.body.userId, + isActive: !!req.body.isActive, + createdByUserId: req.user.id }) Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`) @@ -76,34 +99,64 @@ class ApiKeyController { /** * PATCH: /api/api-keys/:id - * Only isActive and permissions can be updated because name and expiresIn are in the JWT + * Only isActive and userId can be updated because name and expiresIn are in the JWT * * @param {RequestWithUser} req * @param {Response} res */ async update(req, res) { - const apiKey = await Database.apiKeyModel.findByPk(req.params.id) + const apiKey = await Database.apiKeyModel.findByPk(req.params.id, { + include: { + model: Database.userModel + } + }) if (!apiKey) { return res.sendStatus(404) } + // Only root user can update root user API keys + if (apiKey.user.type === 'root' && !req.user.isRoot) { + Logger.warn(`[ApiKeyController] update: Root user API key cannot be updated by non-root user`) + return res.sendStatus(403) + } + + let hasUpdates = false + if (req.body.userId !== undefined) { + if (typeof req.body.userId !== 'string') { + Logger.warn(`[ApiKeyController] update: Invalid userId: ${req.body.userId}`) + return res.sendStatus(400) + } + const user = await Database.userModel.getUserById(req.body.userId) + if (!user) { + Logger.warn(`[ApiKeyController] update: User not found: ${req.body.userId}`) + return res.sendStatus(400) + } + if (user.type === 'root' && !req.user.isRoot) { + Logger.warn(`[ApiKeyController] update: Root user API key cannot be created by non-root user`) + return res.sendStatus(403) + } + if (apiKey.userId !== req.body.userId) { + apiKey.userId = req.body.userId + hasUpdates = true + } + } if (req.body.isActive !== undefined) { if (typeof req.body.isActive !== 'boolean') { return res.sendStatus(400) } - - apiKey.isActive = req.body.isActive + if (apiKey.isActive !== req.body.isActive) { + apiKey.isActive = req.body.isActive + hasUpdates = true + } } - if (req.body.permissions && Object.keys(req.body.permissions).length > 0) { - const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions) - apiKey.permissions = permissions + if (hasUpdates) { + await apiKey.save() + Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`) + } else { + Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`) } - await apiKey.save() - - Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`) - return res.json({ apiKey: apiKey.toJSON() }) diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index adc14177..d3e65212 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -36,6 +36,7 @@ class CronManager { * Closes open share sessions that have not been updated in 24 hours * Closes open playback sessions that have not been updated in 36 hours * Cleans up expired auth sessions + * Deactivates expired api keys * TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner */ initOpenSessionCleanupCron() { @@ -44,6 +45,7 @@ class CronManager { ShareManager.closeStaleOpenShareSessions() await this.playbackSessionManager.closeStaleOpenSessions() await Database.cleanupExpiredSessions() + await Database.deactivateExpiredApiKeys() }) } diff --git a/server/migrations/v2.26.0-create-auth-tables.js b/server/migrations/v2.26.0-create-auth-tables.js index 2c86411e..a1480462 100644 --- a/server/migrations/v2.26.0-create-auth-tables.js +++ b/server/migrations/v2.26.0-create-auth-tables.js @@ -80,7 +80,11 @@ async function up({ context: { queryInterface, logger } }) { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - name: DataTypes.STRING, + name: { + type: DataTypes.STRING, + allowNull: false + }, + description: DataTypes.TEXT, expiresAt: DataTypes.DATE, lastUsedAt: DataTypes.DATE, isActive: { @@ -105,6 +109,17 @@ async function up({ context: { queryInterface, logger } }) { }, key: 'id' }, + onDelete: 'CASCADE' + }, + createdByUserId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'users', + as: 'createdByUser' + }, + key: 'id' + }, onDelete: 'SET NULL' } }) diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js index 54cc036a..7b61f731 100644 --- a/server/models/ApiKey.js +++ b/server/models/ApiKey.js @@ -1,5 +1,6 @@ const { DataTypes, Model, Op } = require('sequelize') const jwt = require('jsonwebtoken') +const { LRUCache } = require('lru-cache') const Logger = require('../Logger') /** @@ -17,6 +18,32 @@ const Logger = require('../Logger') * @property {string[]} itemTagsSelected */ +class ApiKeyCache { + constructor() { + this.cache = new LRUCache({ max: 100 }) + } + + getById(id) { + const apiKey = this.cache.get(id) + return apiKey + } + + set(apiKey) { + apiKey.fromCache = true + this.cache.set(apiKey.id, apiKey) + } + + delete(apiKeyId) { + this.cache.delete(apiKeyId) + } + + maybeInvalidate(apiKey) { + if (!apiKey.fromCache) this.delete(apiKey.id) + } +} + +const apiKeyCache = new ApiKeyCache() + class ApiKey extends Model { constructor(values, options) { super(values, options) @@ -25,13 +52,15 @@ class ApiKey extends Model { this.id /** @type {string} */ this.name + /** @type {string} */ + this.description /** @type {Date} */ this.expiresAt /** @type {Date} */ this.lastUsedAt /** @type {boolean} */ this.isActive - /** @type {Object} */ + /** @type {ApiKeyPermissions} */ this.permissions /** @type {Date} */ this.createdAt @@ -39,6 +68,8 @@ class ApiKey extends Model { this.updatedAt /** @type {UUIDV4} */ this.userId + /** @type {UUIDV4} */ + this.createdByUserId // Expanded properties @@ -104,18 +135,24 @@ class ApiKey extends Model { } /** - * Clean up expired api keys from the database - * @returns {Promise} Number of api keys deleted + * Deactivate expired api keys + * @returns {Promise} Number of api keys affected */ - static async cleanupExpiredApiKeys() { - const deletedCount = await ApiKey.destroy({ - where: { - expiresAt: { - [Op.lt]: new Date() + static async deactivateExpiredApiKeys() { + const [affectedCount] = await ApiKey.update( + { + isActive: false + }, + { + where: { + isActive: true, + expiresAt: { + [Op.lt]: new Date() + } } } - }) - return deletedCount + ) + return affectedCount } /** @@ -152,6 +189,24 @@ class ApiKey extends Model { }) } + /** + * Get an api key by id, from cache or database + * @param {string} apiKeyId + * @returns {Promise} + */ + static async getById(apiKeyId) { + if (!apiKeyId) return null + + const cachedApiKey = apiKeyCache.getById(apiKeyId) + if (cachedApiKey) return cachedApiKey + + const apiKey = await ApiKey.findByPk(apiKeyId) + if (!apiKey) return null + + apiKeyCache.set(apiKey) + return apiKey + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize @@ -164,7 +219,11 @@ class ApiKey extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - name: DataTypes.STRING, + name: { + type: DataTypes.STRING, + allowNull: false + }, + description: DataTypes.TEXT, expiresAt: DataTypes.DATE, lastUsedAt: DataTypes.DATE, isActive: { @@ -182,9 +241,30 @@ class ApiKey extends Model { const { user } = sequelize.models user.hasMany(ApiKey, { - onDelete: 'SET NULL' + onDelete: 'CASCADE' }) ApiKey.belongsTo(user) + + user.hasMany(ApiKey, { + foreignKey: 'createdByUserId', + onDelete: 'SET NULL' + }) + ApiKey.belongsTo(user, { as: 'createdByUser', foreignKey: 'createdByUserId' }) + } + + async update(values, options) { + apiKeyCache.maybeInvalidate(this) + return await super.update(values, options) + } + + async save(options) { + apiKeyCache.maybeInvalidate(this) + return await super.save(options) + } + + async destroy(options) { + apiKeyCache.delete(this.id) + await super.destroy(options) } } From 8b995a179ddf52fedcd1275edbe0a091465485e7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 30 Jun 2025 17:31:31 -0500 Subject: [PATCH 070/182] Add support for returning refresh token for mobile clients --- client/store/user.js | 5 +++- server/Auth.js | 36 +++++++++++++++++++++------- server/controllers/UserController.js | 3 +++ server/models/User.js | 5 ---- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/client/store/user.js b/client/store/user.js index 787d67db..e37568f1 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -152,8 +152,11 @@ export const mutations = { setUser(state, user) { state.user = user if (user) { + // Use accessToken from user if included in response (for login) if (user.accessToken) localStorage.setItem('token', user.accessToken) - else { + else if (localStorage.getItem('token')) { + user.accessToken = localStorage.getItem('token') + } else { console.error('No access token found for user', user) } } else { diff --git a/server/Auth.js b/server/Auth.js index b1d94d41..b811a5db 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -466,14 +466,29 @@ class Auth { // return the user login response json if the login was successfull const userResponse = await this.getUserLoginResponsePayload(req.user) - this.setRefreshTokenCookie(req, res, req.user.refreshToken) + // Check if mobile app wants refresh token in response + const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' + + userResponse.user.refreshToken = returnTokens ? req.user.refreshToken : null + userResponse.user.accessToken = req.user.accessToken + + if (!returnTokens) { + this.setRefreshTokenCookie(req, res, req.user.refreshToken) + } res.json(userResponse) }) // Refresh token route router.post('/auth/refresh', async (req, res) => { - const refreshToken = req.cookies.refresh_token + let refreshToken = req.cookies.refresh_token + + // For mobile clients, the refresh token is sent in the authorization header + let shouldReturnRefreshToken = false + if (!refreshToken && req.headers.authorization?.startsWith('Bearer ')) { + refreshToken = req.headers.authorization.split(' ')[1] + shouldReturnRefreshToken = true + } if (!refreshToken) { return res.status(401).json({ error: 'No refresh token provided' }) @@ -507,10 +522,12 @@ class Auth { return res.status(401).json({ error: 'User not found or inactive' }) } - const newAccessToken = await this.rotateTokensForSession(session, user, req, res) + const newTokens = await this.rotateTokensForSession(session, user, req, res) - user.accessToken = newAccessToken const userResponse = await this.getUserLoginResponsePayload(user) + + userResponse.user.accessToken = newTokens.accessToken + userResponse.user.refreshToken = shouldReturnRefreshToken ? newTokens.refreshToken : null res.json(userResponse) } catch (error) { if (error.name === 'TokenExpiredError') { @@ -961,7 +978,7 @@ class Auth { * @param {import('./models/User')} user * @param {Request} req * @param {Response} res - * @returns {Promise} newAccessToken + * @returns {Promise<{ accessToken:string, refreshToken:string }>} */ async rotateTokensForSession(session, user, req, res) { // Generate new tokens @@ -978,7 +995,10 @@ class Auth { // Set new refresh token cookie this.setRefreshTokenCookie(req, res, newRefreshToken) - return newAccessToken + return { + accessToken: newAccessToken, + refreshToken: newRefreshToken + } } /** @@ -996,7 +1016,7 @@ class Auth { // So rotate token for current session const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) if (currentSession) { - const newAccessToken = await this.rotateTokensForSession(currentSession, user, req, res) + const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) // Invalidate all sessions for the user except the current one await Database.sessionModel.destroy({ @@ -1008,7 +1028,7 @@ class Auth { } }) - return newAccessToken + return newTokens.accessToken } else { Logger.error(`[Auth] No session found to rotate tokens for refresh token ${currentRefreshToken}`) } diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 0a99b84e..48c98150 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -336,6 +336,9 @@ class UserController { const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res) if (newAccessToken) { user.accessToken = newAccessToken + // Refresh tokens are only returned for mobile clients + // Mobile apps currently do not use this API endpoint so always set to null + user.refreshToken = null Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`) } else { Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`) diff --git a/server/models/User.js b/server/models/User.js index 9b26b0ff..588b53bb 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -112,10 +112,6 @@ class User extends Model { this.updatedAt /** @type {import('./MediaProgress')[]?} - Only included when extended */ this.mediaProgresses - - // Temporary accessToken, not stored in database - /** @type {string} */ - this.accessToken } // Excludes "root" since their can only be 1 root user @@ -526,7 +522,6 @@ class User extends Model { type: this.type, // TODO: Old non-expiring token token: this.type === 'root' && hideRootToken ? '' : this.token, - accessToken: this.accessToken || null, mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], seriesHideFromContinueListening: [...seriesHideFromContinueListening], bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [], From 293851d9310dca2fb1807ed610aefae1208e491f Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 30 Jun 2025 17:49:05 -0500 Subject: [PATCH 071/182] Fix missing translation in remove podcast episode modal #4434 --- client/components/modals/podcast/RemoveEpisode.vue | 2 +- client/strings/en-us.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/components/modals/podcast/RemoveEpisode.vue b/client/components/modals/podcast/RemoveEpisode.vue index 38dd71cf..b2cebe84 100644 --- a/client/components/modals/podcast/RemoveEpisode.vue +++ b/client/components/modals/podcast/RemoveEpisode.vue @@ -11,7 +11,7 @@ {{ $getString('MessageConfirmRemoveEpisode', [episodeTitle]) }}

{{ $getString('MessageConfirmRemoveEpisodes', [episodes.length]) }}

-

Note: This does not delete the audio file unless toggling "Hard delete file"

+

{{ $strings.MessageConfirmRemoveEpisodeNote }}

diff --git a/client/strings/en-us.json b/client/strings/en-us.json index f6288912..3ce20e1e 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "Note: This does not delete the audio file unless toggling \"Hard delete file\"", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveMetadataFiles": "Are you sure you want to remove all metadata.{0} files in your library item folders?", From 44ff90a6f2aa354a2cc4c58bd4b7c7ac9d8efe81 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 1 Jul 2025 16:31:26 -0500 Subject: [PATCH 072/182] Update refresh endpoint to support override cookie token --- server/Auth.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/Auth.js b/server/Auth.js index b811a5db..df2d2115 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -484,8 +484,9 @@ class Auth { let refreshToken = req.cookies.refresh_token // For mobile clients, the refresh token is sent in the authorization header + // Force return refresh token if x-return-tokens header is true let shouldReturnRefreshToken = false - if (!refreshToken && req.headers.authorization?.startsWith('Bearer ')) { + if (req.headers.authorization?.startsWith('Bearer ') && (!refreshToken || req.headers['x-return-tokens'] === 'true')) { refreshToken = req.headers.authorization.split(' ')[1] shouldReturnRefreshToken = true } From 596bddf791de0781010b17fba5536a367a68e8b4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 1 Jul 2025 16:48:07 -0500 Subject: [PATCH 073/182] Fix manually setting updatedAt of mediaProgresses using progress sync lastUpdate timestamp #4366 --- server/models/MediaProgress.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index 1638a3e4..0ebe2f59 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -183,7 +183,7 @@ class MediaProgress extends Model { * @param {import('./User').ProgressUpdatePayload} progressPayload * @returns {Promise} */ - applyProgressUpdate(progressPayload) { + async applyProgressUpdate(progressPayload) { if (!this.extraData) this.extraData = {} if (progressPayload.isFinished !== undefined) { if (progressPayload.isFinished && !this.isFinished) { @@ -243,14 +243,23 @@ class MediaProgress extends Model { this.finishedAt = null } + await this.save() + // For local sync if (progressPayload.lastUpdate) { - this.updatedAt = progressPayload.lastUpdate - Logger.info(`[MediaProgress] Manually setting updatedAt to ${this.updatedAt} (media item ${this.mediaItemId})`) - this.changed('updatedAt', true) + if (isNaN(new Date(progressPayload.lastUpdate))) { + Logger.warn(`[MediaProgress] Invalid date provided for lastUpdate: ${progressPayload.lastUpdate} (media item ${this.mediaItemId})`) + } else { + const escapedDate = this.sequelize.escape(new Date(progressPayload.lastUpdate)) + Logger.info(`[MediaProgress] Manually setting updatedAt to ${escapedDate} (media item ${this.mediaItemId})`) + + await this.sequelize.query(`UPDATE "mediaProgresses" SET "updatedAt" = ${escapedDate} WHERE "id" = '${this.id}'`) + + await this.reload() + } } - return this.save({ silent: !!progressPayload.lastUpdate }) + return this } } From df60aeb4569cdea4625658e987a8b29ceb24a8ef Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 2 Jul 2025 17:30:00 -0500 Subject: [PATCH 074/182] Update narrator name to be clickable to filter by narrator --- client/pages/library/_library/narrators.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pages/library/_library/narrators.vue b/client/pages/library/_library/narrators.vue index 17160213..9e58bf14 100644 --- a/client/pages/library/_library/narrators.vue +++ b/client/pages/library/_library/narrators.vue @@ -10,7 +10,7 @@ -

{{ narrator.name }}

+ {{ narrator.name }}
From f127a7beb548e29969873027c6d4389dadb9a13a Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 3 Jul 2025 17:31:38 -0500 Subject: [PATCH 075/182] Update router for internal-api routes --- server/Server.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/Server.js b/server/Server.js index 22a53a3a..7bf3e048 100644 --- a/server/Server.js +++ b/server/Server.js @@ -309,12 +309,14 @@ class Server { }) ) router.use(express.urlencoded({ extended: true, limit: '5mb' })) - router.use(express.json({ limit: '10mb' })) router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router) router.use('/hls', this.hlsRouter.router) router.use('/public', this.publicRouter.router) + // Skip JSON parsing for internal-api routes + router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' })) + // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) @@ -404,6 +406,7 @@ class Server { const handle = nextApp.getRequestHandler() await nextApp.prepare() router.get('*', (req, res) => handle(req, res)) + router.post('/internal-api/*', (req, res) => handle(req, res)) } const unixSocketPrefix = 'unix/' From cdc37ddb0f71c74c4caf1f53dbb9cf891b3768a7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 4 Jul 2025 13:54:37 -0500 Subject: [PATCH 076/182] Use x-refresh-token for alt method of passing refresh token, check x-refresh-token for logout --- server/Auth.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index df2d2115..1b3ba601 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -483,11 +483,11 @@ class Auth { router.post('/auth/refresh', async (req, res) => { let refreshToken = req.cookies.refresh_token - // For mobile clients, the refresh token is sent in the authorization header - // Force return refresh token if x-return-tokens header is true + // If x-refresh-token header is present, use it instead of the cookie + // and return the refresh token in the response let shouldReturnRefreshToken = false - if (req.headers.authorization?.startsWith('Bearer ') && (!refreshToken || req.headers['x-return-tokens'] === 'true')) { - refreshToken = req.headers.authorization.split(' ')[1] + if (req.headers['x-refresh-token']) { + refreshToken = req.headers['x-refresh-token'] shouldReturnRefreshToken = true } @@ -495,6 +495,8 @@ class Auth { return res.status(401).json({ error: 'No refresh token provided' }) } + Logger.debug(`[Auth] refreshing token. shouldReturnRefreshToken: ${shouldReturnRefreshToken}`) + try { // Verify the refresh token const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) @@ -820,7 +822,9 @@ class Auth { // Logout route router.post('/logout', async (req, res) => { - const refreshToken = req.cookies.refresh_token + // Refresh token be alternatively be sent in the header + const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token'] + // Clear refresh token cookie res.clearCookie('refresh_token', { path: '/' @@ -829,12 +833,15 @@ class Auth { // Invalidate the session in database using refresh token if (refreshToken) { try { + Logger.info(`[Auth] logout: Invalidating session for refresh token: ${refreshToken}`) await Database.sessionModel.destroy({ where: { refreshToken } }) } catch (error) { Logger.error(`[Auth] Error destroying session: ${error.message}`) } + } else { + Logger.info(`[Auth] logout: No refresh token on request`) } // TODO: invalidate possible JWTs From 8dbe1e4e5d2fac4ed1b20e4cc3dcd52abc14a168 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 4 Jul 2025 16:49:45 -0500 Subject: [PATCH 077/182] Fix express.json position --- server/Server.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/Server.js b/server/Server.js index 7bf3e048..639ae210 100644 --- a/server/Server.js +++ b/server/Server.js @@ -310,13 +310,13 @@ class Server { ) router.use(express.urlencoded({ extended: true, limit: '5mb' })) + // Skip JSON parsing for internal-api routes + router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' })) + router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router) router.use('/hls', this.hlsRouter.router) router.use('/public', this.publicRouter.router) - // Skip JSON parsing for internal-api routes - router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' })) - // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) From e59babdf24cfa3283b9ea6fd6c8f0e22a76f15ec Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 17:46:18 -0500 Subject: [PATCH 078/182] Force re-login if using old token, show alert if admin user, add isOldToken flag to user --- client/pages/login.vue | 30 +++++++++++++++++++++++++++--- server/Auth.js | 14 ++++++++++++++ server/models/User.js | 3 +++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 3f48509f..71fa8c2b 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -40,6 +40,15 @@

{{ error }}

+
+ +
+

Authentication has been improved for security. All users will be required to re-login.

+ More info +
+
+
+
@@ -85,7 +94,8 @@ export default { MetadataPath: '', login_local: true, login_openid: false, - authFormData: null + authFormData: null, + showNewAuthSystemAdminMessage: false } }, watch: { @@ -184,6 +194,7 @@ export default { }, async submitForm() { this.error = null + this.showNewAuthSystemAdminMessage = false this.processing = true const payload = { @@ -217,15 +228,28 @@ export default { } }) .then((res) => { + // Force re-login if user is using an old token with no expiration + if (res.user.isOldToken) { + if (res.user.type === 'admin' || res.user.type === 'root') { + this.username = res.user.username + // Show message to admin users about new auth system + this.showNewAuthSystemAdminMessage = true + } else { + // Regular users just shown login + this.username = res.user.username + } + return false + } this.setUser(res) - this.processing = false return true }) .catch((error) => { console.error('Authorize error', error) - this.processing = false return false }) + .finally(() => { + this.processing = false + }) }, checkStatus() { this.processing = true diff --git a/server/Auth.js b/server/Auth.js index 1b3ba601..d2250f17 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -492,6 +492,7 @@ class Auth { } if (!refreshToken) { + Logger.error(`[Auth] Failed to refresh token. No refresh token provided`) return res.status(401).json({ error: 'No refresh token provided' }) } @@ -502,6 +503,7 @@ class Auth { const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) if (decoded.type !== 'refresh') { + Logger.error(`[Auth] Failed to refresh token. Invalid token type: ${decoded.type}`) return res.status(401).json({ error: 'Invalid token type' }) } @@ -510,6 +512,7 @@ class Auth { }) if (!session) { + Logger.error(`[Auth] Failed to refresh token. Session not found for refresh token: ${refreshToken}`) return res.status(401).json({ error: 'Invalid refresh token' }) } @@ -522,6 +525,7 @@ class Auth { const user = await Database.userModel.getUserById(decoded.userId) if (!user?.isActive) { + Logger.error(`[Auth] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`) return res.status(401).json({ error: 'User not found or inactive' }) } @@ -1128,6 +1132,16 @@ class Auth { done(null, null) return } + + // TODO: Temporary flag to report old tokens to users + // May be a better place for this but here means we dont have to decode the token again + if (!jwt_payload.exp && !user.isOldToken) { + Logger.debug(`[Auth] User ${user.username} is using an access token without an expiration`) + user.isOldToken = true + } else if (jwt_payload.exp && user.isOldToken !== undefined) { + delete user.isOldToken + } + // approve login done(null, user) } diff --git a/server/models/User.js b/server/models/User.js index 588b53bb..154587a7 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -522,6 +522,9 @@ class User extends Model { type: this.type, // TODO: Old non-expiring token token: this.type === 'root' && hideRootToken ? '' : this.token, + // TODO: Temporary flag not saved in db that is set in Auth.js jwtAuthCheck + // Necessary to detect apps using old tokens that no longer match the old token stored on the user + isOldToken: this.isOldToken, mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], seriesHideFromContinueListening: [...seriesHideFromContinueListening], bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [], From e201247d69566350739e0eefa5eca767e814cea1 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Jul 2025 11:07:01 -0500 Subject: [PATCH 079/182] Handle socket re-authentication, fix socket toast to be re-usable, socket cleanup --- client/components/app/LazyBookshelf.vue | 6 ---- client/layouts/default.vue | 43 +++++++++++++++++++++++-- client/plugins/axios.js | 7 +++- server/Auth.js | 2 ++ server/SocketAuthority.js | 24 ++++++++++---- 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 61331fb9..854b61b2 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -778,10 +778,6 @@ export default { windowResize() { this.executeRebuild() }, - socketInit() { - // Server settings are set on socket init - this.executeRebuild() - }, initListeners() { window.addEventListener('resize', this.windowResize) @@ -794,7 +790,6 @@ export default { }) this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities) - this.$eventBus.$on('socket_init', this.socketInit) this.$eventBus.$on('user-settings', this.settingsUpdated) if (this.$root.socket) { @@ -826,7 +821,6 @@ export default { } this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities) - this.$eventBus.$off('socket_init', this.socketInit) this.$eventBus.$off('user-settings', this.settingsUpdated) if (this.$root.socket) { diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 33e7aa15..9f15af67 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -33,6 +33,7 @@ export default { return { socket: null, isSocketConnected: false, + isSocketAuthenticated: false, isFirstSocketConnection: true, socketConnectionToastId: null, currentLang: null, @@ -81,9 +82,28 @@ export default { document.body.classList.add('app-bar') } }, + tokenRefreshed(newAccessToken) { + if (this.isSocketConnected && !this.isSocketAuthenticated) { + console.log('[SOCKET] Re-authenticating socket after token refresh') + this.socket.emit('auth', newAccessToken) + } + }, updateSocketConnectionToast(content, type, timeout) { if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) { - this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false) + const toastUpdateOptions = { + content: content, + options: { + timeout: timeout, + type: type, + closeButton: false, + position: 'bottom-center', + onClose: () => { + this.socketConnectionToastId = null + }, + closeOnClick: timeout !== null + } + } + this.$toast.update(this.socketConnectionToastId, toastUpdateOptions, false) } else { this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null }) } @@ -109,7 +129,7 @@ export default { this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null) }, reconnect() { - console.error('[SOCKET] reconnected') + console.log('[SOCKET] reconnected') }, reconnectAttempt(val) { console.log(`[SOCKET] reconnect attempt ${val}`) @@ -120,6 +140,10 @@ export default { reconnectFailed() { console.error('[SOCKET] reconnect failed') }, + authFailed(payload) { + console.error('[SOCKET] auth failed', payload.message) + this.isSocketAuthenticated = false + }, init(payload) { console.log('Init Payload', payload) @@ -127,7 +151,7 @@ export default { this.$store.commit('users/setUsersOnline', payload.usersOnline) } - this.$eventBus.$emit('socket_init') + this.isSocketAuthenticated = true }, streamOpen(stream) { if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream) @@ -354,6 +378,15 @@ export default { this.$store.commit('scanners/removeCustomMetadataProvider', provider) }, initializeSocket() { + if (this.$root.socket) { + // Can happen in dev due to hot reload + console.warn('Socket already initialized') + this.socket = this.$root.socket + this.isSocketConnected = this.$root.socket?.connected + this.isFirstSocketConnection = false + this.socketConnectionToastId = null + return + } this.socket = this.$nuxtSocket({ name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', persist: 'main', @@ -364,6 +397,7 @@ export default { path: `${this.$config.routerBasePath}/socket.io` }) this.$root.socket = this.socket + this.isSocketAuthenticated = false console.log('Socket initialized') // Pre-defined socket events @@ -377,6 +411,7 @@ export default { // Event received after authorizing socket this.socket.on('init', this.init) + this.socket.on('auth_failed', this.authFailed) // Stream Listeners this.socket.on('stream_open', this.streamOpen) @@ -571,6 +606,7 @@ export default { this.updateBodyClass() this.resize() this.$eventBus.$on('change-lang', this.changeLanguage) + this.$eventBus.$on('token_refreshed', this.tokenRefreshed) window.addEventListener('resize', this.resize) window.addEventListener('keydown', this.keyDown) @@ -594,6 +630,7 @@ export default { }, beforeDestroy() { this.$eventBus.$off('change-lang', this.changeLanguage) + this.$eventBus.$off('token_refreshed', this.tokenRefreshed) window.removeEventListener('resize', this.resize) window.removeEventListener('keydown', this.keyDown) } diff --git a/client/plugins/axios.js b/client/plugins/axios.js index c95067d1..2724acb3 100644 --- a/client/plugins/axios.js +++ b/client/plugins/axios.js @@ -1,4 +1,4 @@ -export default function ({ $axios, store, $config, app }) { +export default function ({ $axios, store, $root, app }) { // Track if we're currently refreshing to prevent multiple refresh attempts let isRefreshing = false let failedQueue = [] @@ -82,6 +82,11 @@ export default function ({ $axios, store, $config, app }) { // Update the token in store and localStorage store.commit('user/setUser', response.user) + // Emit event used to re-authenticate socket in default.vue since $root is not available here + if (app.$eventBus) { + app.$eventBus.$emit('token_refreshed', newAccessToken) + } + // Update the original request with new token if (!originalRequest.headers) { originalRequest.headers = {} diff --git a/server/Auth.js b/server/Auth.js index d2250f17..b2fcebf9 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1054,6 +1054,8 @@ class Auth { /** * Function to validate a jwt token for a given user + * Used to authenticate socket connections + * TODO: Support API keys for web socket connections * * @param {string} token * @returns {Object} tokens data diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 050e7e2f..68b647ff 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -231,6 +231,9 @@ class SocketAuthority { * When setting up a socket connection the user needs to be associated with a socket id * for this the client will send a 'auth' event that includes the users API token * + * Sends event 'init' to the socket. For admins this contains an array of users online. + * For failed authentication it sends event 'auth_failed' with a message + * * @param {SocketIO.Socket} socket * @param {string} token JWT */ @@ -242,7 +245,7 @@ class SocketAuthority { if (!token_data?.userId) { // Token invalid Logger.error('Cannot validate socket - invalid token') - return socket.emit('invalid_token') + return socket.emit('auth_failed', { message: 'Invalid token' }) } // get the user via the id from the decoded jwt. @@ -250,7 +253,11 @@ class SocketAuthority { if (!user) { // user not found Logger.error('Cannot validate socket - invalid token') - return socket.emit('invalid_token') + return socket.emit('auth_failed', { message: 'Invalid token' }) + } + if (!user.isActive) { + Logger.error('Cannot validate socket - user is not active') + return socket.emit('auth_failed', { message: 'Invalid user' }) } const client = this.clients[socket.id] @@ -260,13 +267,18 @@ class SocketAuthority { } if (client.user !== undefined) { - Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username) + if (client.user.id === user.id) { + // Allow re-authentication of a socket to the same user + Logger.info(`[SocketAuthority] Authenticating socket already associated to user "${client.user.username}"`) + } else { + // Allow re-authentication of a socket to a different user but shouldn't happen + Logger.warn(`[SocketAuthority] Authenticating socket to user "${user.username}", but is already associated with a different user "${client.user.username}"`) + } + } else { + Logger.debug(`[SocketAuthority] Authenticating socket to user "${user.username}"`) } client.user = user - - Logger.debug(`[SocketAuthority] User Online ${client.user.username}`) - this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) // Update user lastSeen without firing sequelize bulk update hooks From e24eaab3f187eaa8cac3b9bc73d710d1303154f3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Jul 2025 13:10:14 -0500 Subject: [PATCH 080/182] Log when token expiry is set via env var, api-keys create/update returns with user association --- client/components/tables/ApiKeysTable.vue | 2 +- client/pages/config/api-keys/index.vue | 5 +---- server/Auth.js | 6 ++++++ server/controllers/ApiKeyController.js | 6 ++++++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/client/components/tables/ApiKeysTable.vue b/client/components/tables/ApiKeysTable.vue index 037000b5..feab4e68 100644 --- a/client/components/tables/ApiKeysTable.vue +++ b/client/components/tables/ApiKeysTable.vue @@ -93,7 +93,7 @@ export default { this.$toast.error(data.error) } else { this.removeApiKey(apiKey.id) - this.$emit('deleted', apiKey.id) + this.$emit('numApiKeys', this.apiKeys.length) } }) .catch((error) => { diff --git a/client/pages/config/api-keys/index.vue b/client/pages/config/api-keys/index.vue index edc4d59f..2523feed 100644 --- a/client/pages/config/api-keys/index.vue +++ b/client/pages/config/api-keys/index.vue @@ -19,7 +19,7 @@ - +
@@ -50,9 +50,6 @@ export default { this.$refs.apiKeysTable.addApiKey(apiKey) } }, - apiKeyDeleted() { - this.numApiKeys-- - }, apiKeyUpdated(apiKey) { if (this.$refs.apiKeysTable) { this.$refs.apiKeysTable.updateApiKey(apiKey) diff --git a/server/Auth.js b/server/Auth.js index b2fcebf9..c445b45e 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -25,6 +25,12 @@ class Auth { this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours + if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { + Logger.info(`[Auth] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`) + } + if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) { + Logger.info(`[Auth] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`) + } } /** diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js index 0166479f..f60480df 100644 --- a/server/controllers/ApiKeyController.js +++ b/server/controllers/ApiKeyController.js @@ -87,6 +87,9 @@ class ApiKeyController { isActive: !!req.body.isActive, createdByUserId: req.user.id }) + apiKeyInstance.dataValues.user = await apiKeyInstance.getUser({ + attributes: ['id', 'username', 'type'] + }) Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`) return res.json({ @@ -152,6 +155,9 @@ class ApiKeyController { if (hasUpdates) { await apiKey.save() + apiKey.dataValues.user = await apiKey.getUser({ + attributes: ['id', 'username', 'type'] + }) Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`) } else { Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`) From 97afd22f81d58c3a9c086404bbdd7b8eb3328c1e Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Jul 2025 16:43:03 -0500 Subject: [PATCH 081/182] Refactor Auth to breakout functions in TokenManager, handle token generation for OIDC --- client/pages/login.vue | 1 + server/Auth.js | 593 ++++++--------------------- server/SocketAuthority.js | 5 +- server/auth/TokenManager.js | 379 +++++++++++++++++ server/controllers/UserController.js | 4 +- server/models/User.js | 94 ++++- 6 files changed, 603 insertions(+), 473 deletions(-) create mode 100644 server/auth/TokenManager.js diff --git a/client/pages/login.vue b/client/pages/login.vue index 71fa8c2b..5d447ed9 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -304,6 +304,7 @@ export default { } }, async mounted() { + // Token passed as query parameter after successful oidc login if (this.$route.query?.setToken) { localStorage.setItem('token', this.$route.query.setToken) } diff --git a/server/Auth.js b/server/Auth.js index c445b45e..a4c52781 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,16 +1,17 @@ +const { Request, Response, NextFunction } = require('express') const axios = require('axios') const passport = require('passport') -const { Op } = require('sequelize') -const { Request, Response, NextFunction } = require('express') -const bcrypt = require('./libs/bcryptjs') -const jwt = require('./libs/jsonwebtoken') -const requestIp = require('./libs/requestIp') -const LocalStrategy = require('./libs/passportLocal') +const OpenIDClient = require('openid-client') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt -const OpenIDClient = require('openid-client') + const Database = require('./Database') const Logger = require('./Logger') +const TokenManager = require('./auth/TokenManager') + +const bcrypt = require('./libs/bcryptjs') +const requestIp = require('./libs/requestIp') +const LocalStrategy = require('./libs/passportLocal') const { escapeRegExp } = require('./utils') /** @@ -23,26 +24,23 @@ class Auth { const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)] - this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days - this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours - if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { - Logger.info(`[Auth] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`) - } - if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) { - Logger.info(`[Auth] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`) - } + this.tokenManager = new TokenManager() } /** * Checks if the request should not be authenticated. * @param {Request} req * @returns {boolean} - * @private */ authNotNeeded(req) { return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.path)) } + /** + * Middleware to register passport in express-session + * + * @param {function} middleware + */ ifAuthNeeded(middleware) { return (req, res, next) => { if (this.authNotNeeded(req)) { @@ -52,6 +50,67 @@ class Auth { } } + /** + * middleware to use in express to only allow authenticated users. + * + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + isAuthenticated(req, res, next) { + return passport.authenticate('jwt', { session: false })(req, res, next) + } + + /** + * Generate a token which is used to encrpt/protect the jwts. + */ + async initTokenSecret() { + return this.tokenManager.initTokenSecret() + } + + /** + * Function to generate a jwt token for a given user + * TODO: Old method with no expiration + * @deprecated + * + * @param {{ id:string, username:string }} user + * @returns {string} + */ + generateAccessToken(user) { + return this.tokenManager.generateAccessToken(user) + } + + /** + * Invalidate all JWT sessions for a given user + * If user is current user and refresh token is valid, rotate tokens for the current session + * + * @param {import('./models/User')} user + * @param {Request} req + * @param {Response} res + * @returns {Promise} accessToken only if user is current user and refresh token is valid + */ + async invalidateJwtSessionsForUser(user, req, res) { + return this.tokenManager.invalidateJwtSessionsForUser(user, req, res) + } + + /** + * Return the login info payload for a user + * + * @param {import('./models/User')} user + * @returns {Promise} jsonPayload + */ + async getUserLoginResponsePayload(user) { + const libraryIds = await Database.libraryModel.getAllLibraryIds() + return { + user: user.toOldJSONForBrowser(), + userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), + serverSettings: Database.serverSettings.toJSONForBrowser(), + ereaderDevices: Database.emailSettings.getEReaderDevices(user), + Source: global.Source + } + } + + // #region Passport strategies /** * Inializes all passportjs strategies and other passportjs ralated initialization. */ @@ -75,7 +134,7 @@ class Auth { // Handle expiration manaully in order to disable api keys that are expired ignoreExpiration: true }, - this.jwtAuthCheck.bind(this) + this.tokenManager.jwtAuthCheck.bind(this) ) ) @@ -161,7 +220,7 @@ class Auth { throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) } - let user = await this.findOrCreateUser(userinfo) + let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) if (!user?.isActive) { throw new Error('User not active or not found') @@ -183,94 +242,7 @@ class Auth { ) ) } - - /** - * Finds an existing user by OpenID subject identifier, or by email/username based on server settings, - * or creates a new user if configured to do so. - * - * @returns {Promise} - */ - async findOrCreateUser(userinfo) { - let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) - - // Matched by sub - if (user) { - Logger.debug(`[Auth] openid: User found by sub`) - return user - } - - // Match existing user by email - if (Database.serverSettings.authOpenIDMatchExistingBy === 'email') { - if (userinfo.email) { - // Only disallow when email_verified explicitly set to false (allow both if not set or true) - if (userinfo.email_verified === false) { - Logger.warn(`[Auth] openid: User not found and email "${userinfo.email}" is not verified`) - return null - } else { - Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) - user = await Database.userModel.getUserByEmail(userinfo.email) - - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) - return null // User is linked to a different OpenID subject; do not proceed. - } - } - } else { - Logger.warn(`[Auth] openid: User not found and no email in userinfo`) - // We deny login, because if the admin whishes to match email, it makes sense to require it - return null - } - } - // Match existing user by username - else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username') { - let username - - if (userinfo.preferred_username) { - Logger.info(`[Auth] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`) - username = userinfo.preferred_username - } else if (userinfo.username) { - Logger.info(`[Auth] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`) - username = userinfo.username - } else { - Logger.warn(`[Auth] openid: User not found and neither preferred_username nor username in userinfo`) - return null - } - - user = await Database.userModel.getUserByUsername(username) - - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`) - return null // User is linked to a different OpenID subject; do not proceed. - } - } - - // Found existing user via email or username - if (user) { - if (!user.isActive) { - Logger.warn(`[Auth] openid: User found but is not active`) - return null - } - - // Update user with OpenID sub - if (!user.extraData) user.extraData = {} - user.extraData.authOpenIDSub = userinfo.sub - user.changed('extraData', true) - await user.save() - - Logger.debug(`[Auth] openid: User found by email/username`) - return user - } - - // If no existing user was matched, auto-register if configured - if (Database.serverSettings.authOpenIDAutoRegister) { - Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) - user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) - return user - } - - Logger.warn(`[Auth] openid: User not found and auto-register is disabled`) - return null - } + // #endregion /** * Validates the presence and content of the group claim in userinfo. @@ -418,22 +390,6 @@ class Auth { res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true }) } - /** - * Sets the refresh token cookie - * @param {Request} req - * @param {Response} res - * @param {string} refreshToken - */ - setRefreshTokenCookie(req, res, refreshToken) { - res.cookie('refresh_token', refreshToken, { - httpOnly: true, - secure: req.secure || req.get('x-forwarded-proto') === 'https', - sameSite: 'lax', - maxAge: this.RefreshTokenExpiry * 1000, - path: '/' - }) - } - /** * Informs the client in the right mode about a successfull login and the token * (clients choise is restored from cookies). @@ -442,25 +398,56 @@ class Auth { * @param {Response} res */ async handleLoginSuccessBasedOnCookie(req, res) { - // get userLogin json (information about the user, server and the session) - const data_json = await this.getUserLoginResponsePayload(req.user) + // Handle token generation and get userResponse object + // TODO: where to check if refresh tokens should be returned? + const userResponse = await this.handleLoginSuccess(req, res, false) if (this.isAuthMethodAPIBased(req.cookies.auth_method)) { // REST request - send data - res.json(data_json) + res.json(userResponse) } else { // UI request -> check if we have a callback url // TODO: do we want to somehow limit the values for auth_cb? if (req.cookies.auth_cb) { let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : '' // UI request -> redirect to auth_cb url and send the jwt token as parameter - res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}${stateQuery}`) + res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.accessToken}${stateQuery}`) } else { res.status(400).send('No callback or already expired') } } } + /** + * After login success from local or oidc + * req.user is set by passport.authenticate + * + * attaches the access token to the user in the response + * if returnTokens is true, also attaches the refresh token to the user in the response + * + * if returnTokens is false, sets the refresh token cookie + * + * @param {Request} req + * @param {Response} res + * @param {boolean} returnTokens + */ + async handleLoginSuccess(req, res, returnTokens = false) { + // Create tokens and session + const { accessToken, refreshToken } = await this.tokenManager.createTokensAndSession(req.user, req) + + const userResponse = await this.getUserLoginResponsePayload(req.user) + + userResponse.user.refreshToken = returnTokens ? refreshToken : null + userResponse.user.accessToken = accessToken + + if (!returnTokens) { + this.tokenManager.setRefreshTokenCookie(req, res, refreshToken) + } + + return userResponse + } + + // #region Auth routes /** * Creates all (express) routes required for authentication. * @@ -469,19 +456,10 @@ class Auth { async initAuthRoutes(router) { // Local strategy login route (takes username and password) router.post('/login', passport.authenticate('local'), async (req, res) => { - // return the user login response json if the login was successfull - const userResponse = await this.getUserLoginResponsePayload(req.user) - // Check if mobile app wants refresh token in response const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' - userResponse.user.refreshToken = returnTokens ? req.user.refreshToken : null - userResponse.user.accessToken = req.user.accessToken - - if (!returnTokens) { - this.setRefreshTokenCookie(req, res, req.user.refreshToken) - } - + const userResponse = await this.handleLoginSuccess(req, res, returnTokens) res.json(userResponse) }) @@ -504,67 +482,16 @@ class Auth { Logger.debug(`[Auth] refreshing token. shouldReturnRefreshToken: ${shouldReturnRefreshToken}`) - try { - // Verify the refresh token - const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) - - if (decoded.type !== 'refresh') { - Logger.error(`[Auth] Failed to refresh token. Invalid token type: ${decoded.type}`) - return res.status(401).json({ error: 'Invalid token type' }) - } - - const session = await Database.sessionModel.findOne({ - where: { refreshToken: refreshToken } - }) - - if (!session) { - Logger.error(`[Auth] Failed to refresh token. Session not found for refresh token: ${refreshToken}`) - return res.status(401).json({ error: 'Invalid refresh token' }) - } - - // Check if session is expired in database - if (session.expiresAt < new Date()) { - Logger.info(`[Auth] Session expired in database, cleaning up`) - await session.destroy() - return res.status(401).json({ error: 'Refresh token expired' }) - } - - const user = await Database.userModel.getUserById(decoded.userId) - if (!user?.isActive) { - Logger.error(`[Auth] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`) - return res.status(401).json({ error: 'User not found or inactive' }) - } - - const newTokens = await this.rotateTokensForSession(session, user, req, res) - - const userResponse = await this.getUserLoginResponsePayload(user) - - userResponse.user.accessToken = newTokens.accessToken - userResponse.user.refreshToken = shouldReturnRefreshToken ? newTokens.refreshToken : null - res.json(userResponse) - } catch (error) { - if (error.name === 'TokenExpiredError') { - Logger.info(`[Auth] Refresh token expired, cleaning up session`) - - // Clean up the expired session from database - try { - await Database.sessionModel.destroy({ - where: { refreshToken: refreshToken } - }) - Logger.info(`[Auth] Expired session cleaned up`) - } catch (cleanupError) { - Logger.error(`[Auth] Error cleaning up expired session: ${cleanupError.message}`) - } - - return res.status(401).json({ error: 'Refresh token expired' }) - } else if (error.name === 'JsonWebTokenError') { - Logger.error(`[Auth] Invalid refresh token format: ${error.message}`) - return res.status(401).json({ error: 'Invalid refresh token' }) - } else { - Logger.error(`[Auth] Refresh token error: ${error.message}`) - return res.status(401).json({ error: 'Invalid refresh token' }) - } + const refreshResponse = await this.tokenManager.handleRefreshToken(refreshToken, req, res) + if (refreshResponse.error) { + return res.status(401).json({ error: refreshResponse.error }) } + + const userResponse = await this.getUserLoginResponsePayload(refreshResponse.user) + + userResponse.user.accessToken = refreshResponse.accessToken + userResponse.user.refreshToken = shouldReturnRefreshToken ? refreshResponse.refreshToken : null + res.json(userResponse) }) // openid strategy login route (this redirects to the configured openid login provider) @@ -906,255 +833,9 @@ class Auth { }) }) } + // #endregion - /** - * middleware to use in express to only allow authenticated users. - * - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - isAuthenticated(req, res, next) { - return passport.authenticate('jwt', { session: false })(req, res, next) - } - - /** - * Function to generate a jwt token for a given user - * TODO: Old method with no expiration - * - * @param {{ id:string, username:string }} user - * @returns {string} token - */ - generateAccessToken(user) { - return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) - } - - /** - * Generate access token for a given user - * - * @param {{ id:string, username:string }} user - * @returns {Promise} - */ - generateTempAccessToken(user) { - return new Promise((resolve) => { - jwt.sign({ userId: user.id, username: user.username, type: 'access' }, global.ServerSettings.tokenSecret, { expiresIn: this.AccessTokenExpiry }, (err, token) => { - if (err) { - Logger.error(`[Auth] Error generating access token for user ${user.id}: ${err}`) - resolve(null) - } else { - resolve(token) - } - }) - }) - } - - /** - * Generate refresh token for a given user - * - * @param {{ id:string, username:string }} user - * @returns {Promise} - */ - generateRefreshToken(user) { - return new Promise((resolve) => { - jwt.sign({ userId: user.id, username: user.username, type: 'refresh' }, global.ServerSettings.tokenSecret, { expiresIn: this.RefreshTokenExpiry }, (err, token) => { - if (err) { - Logger.error(`[Auth] Error generating refresh token for user ${user.id}: ${err}`) - resolve(null) - } else { - resolve(token) - } - }) - }) - } - - /** - * Create tokens and session for a given user - * - * @param {{ id:string, username:string }} user - * @param {Request} req - * @returns {Promise<{ accessToken:string, refreshToken:string, session:import('./models/Session') }>} - */ - async createTokensAndSession(user, req) { - const ipAddress = requestIp.getClientIp(req) - const userAgent = req.headers['user-agent'] - const [accessToken, refreshToken] = await Promise.all([this.generateTempAccessToken(user), this.generateRefreshToken(user)]) - - // Calculate expiration time for the refresh token - const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) - - const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt) - user.accessToken = accessToken - // Store refresh token on user object for cookie setting - user.refreshToken = refreshToken - return { accessToken, refreshToken, session } - } - - /** - * Rotate tokens for a given session - * - * @param {import('./models/Session')} session - * @param {import('./models/User')} user - * @param {Request} req - * @param {Response} res - * @returns {Promise<{ accessToken:string, refreshToken:string }>} - */ - async rotateTokensForSession(session, user, req, res) { - // Generate new tokens - const [newAccessToken, newRefreshToken] = await Promise.all([this.generateTempAccessToken(user), this.generateRefreshToken(user)]) - - // Calculate new expiration time - const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) - - // Update the session with the new refresh token and expiration - session.refreshToken = newRefreshToken - session.expiresAt = newExpiresAt - await session.save() - - // Set new refresh token cookie - this.setRefreshTokenCookie(req, res, newRefreshToken) - - return { - accessToken: newAccessToken, - refreshToken: newRefreshToken - } - } - - /** - * Invalidate all JWT sessions for a given user - * If user is current user and refresh token is valid, rotate tokens for the current session - * - * @param {Request} req - * @param {Response} res - * @returns {Promise} accessToken only if user is current user and refresh token is valid - */ - async invalidateJwtSessionsForUser(user, req, res) { - const currentRefreshToken = req.cookies.refresh_token - if (req.user.id === user.id && currentRefreshToken) { - // Current user is the same as the user to invalidate sessions for - // So rotate token for current session - const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) - if (currentSession) { - const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) - - // Invalidate all sessions for the user except the current one - await Database.sessionModel.destroy({ - where: { - id: { - [Op.ne]: currentSession.id - }, - userId: user.id - } - }) - - return newTokens.accessToken - } else { - Logger.error(`[Auth] No session found to rotate tokens for refresh token ${currentRefreshToken}`) - } - } - - // Current user is not the same as the user to invalidate sessions for (or no refresh token) - // So invalidate all sessions for the user - await Database.sessionModel.destroy({ where: { userId: user.id } }) - return null - } - - /** - * Function to validate a jwt token for a given user - * Used to authenticate socket connections - * TODO: Support API keys for web socket connections - * - * @param {string} token - * @returns {Object} tokens data - */ - static validateAccessToken(token) { - try { - return jwt.verify(token, global.ServerSettings.tokenSecret) - } catch (err) { - return null - } - } - - /** - * Generate a token which is used to encrpt/protect the jwts. - */ - async initTokenSecret() { - if (process.env.TOKEN_SECRET) { - // User can supply their own token secret - Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET - } else { - Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') - } - await Database.updateServerSettings() - - // TODO: Old method of non-expiring tokens - // New token secret creation added in v2.1.0 so generate new API tokens for each user - const users = await Database.userModel.findAll({ - attributes: ['id', 'username', 'token'] - }) - if (users.length) { - for (const user of users) { - user.token = await this.generateAccessToken(user) - await user.save({ hooks: false }) - } - } - } - - /** - * Checks if the user or api key in the validated jwt_payload exists and is active. - * @param {Object} jwt_payload - * @param {function} done - */ - async jwtAuthCheck(jwt_payload, done) { - if (jwt_payload.type === 'api') { - const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId) - - if (!apiKey?.isActive) { - done(null, null) - return - } - - // Check if the api key is expired and deactivate it - if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) { - done(null, null) - - apiKey.isActive = false - await apiKey.save() - Logger.info(`[Auth] API key ${apiKey.id} is expired - deactivated`) - return - } - - const user = await Database.userModel.getUserById(apiKey.userId) - done(null, user) - } else { - // Check if the jwt is expired - if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) { - done(null, null) - return - } - - // load user by id from the jwt token - const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) - - if (!user?.isActive) { - // deny login - done(null, null) - return - } - - // TODO: Temporary flag to report old tokens to users - // May be a better place for this but here means we dont have to decode the token again - if (!jwt_payload.exp && !user.isOldToken) { - Logger.debug(`[Auth] User ${user.username} is using an access token without an expiration`) - user.isOldToken = true - } else if (jwt_payload.exp && user.isOldToken !== undefined) { - delete user.isOldToken - } - - // approve login - done(null, user) - } - } - + // #region Local Auth /** * Checks if a username and password tuple is valid and the user active. * @param {Request} req @@ -1187,9 +868,6 @@ class Auth { // approve login Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) - // Create tokens and session, updates user.accessToken and user.refreshToken - await this.createTokensAndSession(user, req) - done(null, user) return } else if (!user.pash) { @@ -1204,9 +882,6 @@ class Auth { // approve login Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) - // Create tokens and session, updates user.accessToken and user.refreshToken - await this.createTokensAndSession(user, req) - done(null, user) return } @@ -1244,23 +919,6 @@ class Auth { }) } - /** - * Return the login info payload for a user - * - * @param {import('./models/User')} user - * @returns {Promise} jsonPayload - */ - async getUserLoginResponsePayload(user) { - const libraryIds = await Database.libraryModel.getAllLibraryIds() - return { - user: user.toOldJSONForBrowser(), - userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), - serverSettings: Database.serverSettings.toJSONForBrowser(), - ereaderDevices: Database.emailSettings.getEReaderDevices(user), - Source: global.Source - } - } - /** * * @param {string} password @@ -1322,6 +980,7 @@ class Auth { }) } } + // #endregion } module.exports = Auth diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 68b647ff..da31ba4a 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -1,7 +1,7 @@ const SocketIO = require('socket.io') const Logger = require('./Logger') const Database = require('./Database') -const Auth = require('./Auth') +const TokenManager = require('./auth/TokenManager') /** * @typedef SocketClient @@ -240,7 +240,8 @@ class SocketAuthority { async authenticateSocket(socket, token) { // we don't use passport to authenticate the jwt we get over the socket connection. // it's easier to directly verify/decode it. - const token_data = Auth.validateAccessToken(token) + // TODO: Support API keys for web socket connections + const token_data = TokenManager.validateAccessToken(token) if (!token_data?.userId) { // Token invalid diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js new file mode 100644 index 00000000..cc4783b5 --- /dev/null +++ b/server/auth/TokenManager.js @@ -0,0 +1,379 @@ +const { Op } = require('sequelize') + +const Database = require('../Database') +const Logger = require('../Logger') + +const requestIp = require('../libs/requestIp') +const jwt = require('../libs/jsonwebtoken') + +class TokenManager { + constructor() { + this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days + this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours + + if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { + Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`) + } + if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) { + Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`) + } + } + + /** + * Generate a token which is used to encrypt/protect the jwts. + */ + async initTokenSecret() { + if (process.env.TOKEN_SECRET) { + // User can supply their own token secret + Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET + } else { + Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') + } + await Database.updateServerSettings() + + // TODO: Old method of non-expiring tokens + // New token secret creation added in v2.1.0 so generate new API tokens for each user + const users = await Database.userModel.findAll({ + attributes: ['id', 'username', 'token'] + }) + if (users.length) { + for (const user of users) { + user.token = this.generateAccessToken(user) + await user.save({ hooks: false }) + } + } + } + + /** + * Sets the refresh token cookie + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {string} refreshToken + */ + setRefreshTokenCookie(req, res, refreshToken) { + res.cookie('refresh_token', refreshToken, { + httpOnly: true, + secure: req.secure || req.get('x-forwarded-proto') === 'https', + sameSite: 'lax', + maxAge: this.RefreshTokenExpiry * 1000, + path: '/' + }) + } + + /** + * Function to validate a jwt token for a given user + * Used to authenticate socket connections + * TODO: Support API keys for web socket connections + * + * @param {string} token + * @returns {Object} tokens data + */ + static validateAccessToken(token) { + try { + return jwt.verify(token, global.ServerSettings.tokenSecret) + } catch (err) { + return null + } + } + + /** + * Function to generate a jwt token for a given user + * TODO: Old method with no expiration + * @deprecated + * + * @param {{ id:string, username:string }} user + * @returns {string} + */ + generateAccessToken(user) { + return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) + } + + /** + * Generate access token for a given user + * + * @param {{ id:string, username:string }} user + * @returns {string} + */ + generateTempAccessToken(user) { + const payload = { + userId: user.id, + username: user.username, + type: 'access' + } + const options = { + expiresIn: this.AccessTokenExpiry + } + try { + return jwt.sign(payload, global.ServerSettings.tokenSecret, options) + } catch (error) { + Logger.error(`[TokenManager] Error generating access token for user ${user.id}: ${error}`) + return null + } + } + + /** + * Generate refresh token for a given user + * + * @param {{ id:string, username:string }} user + * @returns {string} + */ + generateRefreshToken(user) { + const payload = { + userId: user.id, + username: user.username, + type: 'refresh' + } + const options = { + expiresIn: this.RefreshTokenExpiry + } + try { + return jwt.sign(payload, global.ServerSettings.tokenSecret, options) + } catch (error) { + Logger.error(`[TokenManager] Error generating refresh token for user ${user.id}: ${error}`) + return null + } + } + + /** + * Create tokens and session for a given user + * + * @param {{ id:string, username:string }} user + * @param {import('express').Request} req + * @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>} + */ + async createTokensAndSession(user, req) { + const ipAddress = requestIp.getClientIp(req) + const userAgent = req.headers['user-agent'] + const accessToken = this.generateTempAccessToken(user) + const refreshToken = this.generateRefreshToken(user) + + // Calculate expiration time for the refresh token + const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) + + const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt) + + return { + accessToken, + refreshToken, + session + } + } + + /** + * Rotate tokens for a given session + * + * @param {import('../models/Session')} session + * @param {import('../models/User')} user + * @param {import('express').Request} req + * @param {import('express').Response} res + * @returns {Promise<{ accessToken:string, refreshToken:string }>} + */ + async rotateTokensForSession(session, user, req, res) { + // Generate new tokens + const newAccessToken = this.generateTempAccessToken(user) + const newRefreshToken = this.generateRefreshToken(user) + + // Calculate new expiration time + const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) + + // Update the session with the new refresh token and expiration + session.refreshToken = newRefreshToken + session.expiresAt = newExpiresAt + await session.save() + + // Set new refresh token cookie + this.setRefreshTokenCookie(req, res, newRefreshToken) + + return { + accessToken: newAccessToken, + refreshToken: newRefreshToken + } + } + + /** + * Check if the jwt is valid + * + * @param {Object} jwt_payload + * @param {Function} done - passportjs callback + */ + async jwtAuthCheck(jwt_payload, done) { + if (jwt_payload.type === 'api') { + // Api key based authentication + const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId) + + if (!apiKey?.isActive) { + done(null, null) + return + } + + // Check if the api key is expired and deactivate it + if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) { + done(null, null) + + apiKey.isActive = false + await apiKey.save() + Logger.info(`[TokenManager] API key ${apiKey.id} is expired - deactivated`) + return + } + + const user = await Database.userModel.getUserById(apiKey.userId) + done(null, user) + } else { + // JWT based authentication + + // Check if the jwt is expired + if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) { + done(null, null) + return + } + + // load user by id from the jwt token + const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) + + if (!user?.isActive) { + // deny login + done(null, null) + return + } + + // TODO: Temporary flag to report old tokens to users + // May be a better place for this but here means we dont have to decode the token again + if (!jwt_payload.exp && !user.isOldToken) { + Logger.debug(`[TokenManager] User ${user.username} is using an access token without an expiration`) + user.isOldToken = true + } else if (jwt_payload.exp && user.isOldToken !== undefined) { + delete user.isOldToken + } + + // approve login + done(null, user) + } + } + + /** + * Handle refresh token + * + * @param {string} refreshToken + * @param {import('express').Request} req + * @param {import('express').Response} res + * @returns {Promise<{ accessToken?:string, refreshToken?:string, user?:import('../models/User'), error?:string }>} + */ + async handleRefreshToken(refreshToken, req, res) { + try { + // Verify the refresh token + const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) + + if (decoded.type !== 'refresh') { + Logger.error(`[TokenManager] Failed to refresh token. Invalid token type: ${decoded.type}`) + return { + error: 'Invalid token type' + } + } + + const session = await Database.sessionModel.findOne({ + where: { refreshToken: refreshToken } + }) + + if (!session) { + Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`) + return { + error: 'Invalid refresh token' + } + } + + // Check if session is expired in database + if (session.expiresAt < new Date()) { + Logger.info(`[TokenManager] Session expired in database, cleaning up`) + await session.destroy() + return { + error: 'Refresh token expired' + } + } + + const user = await Database.userModel.getUserById(decoded.userId) + if (!user?.isActive) { + Logger.error(`[TokenManager] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`) + return { + error: 'User not found or inactive' + } + } + + const newTokens = await this.rotateTokensForSession(session, user, req, res) + return { + accessToken: newTokens.accessToken, + refreshToken: newTokens.refreshToken, + user + } + } catch (error) { + if (error.name === 'TokenExpiredError') { + Logger.info(`[TokenManager] Refresh token expired, cleaning up session`) + + // Clean up the expired session from database + try { + await Database.sessionModel.destroy({ + where: { refreshToken: refreshToken } + }) + Logger.info(`[TokenManager] Expired session cleaned up`) + } catch (cleanupError) { + Logger.error(`[TokenManager] Error cleaning up expired session: ${cleanupError.message}`) + } + + return { + error: 'Refresh token expired' + } + } else if (error.name === 'JsonWebTokenError') { + Logger.error(`[TokenManager] Invalid refresh token format: ${error.message}`) + return { + error: 'Invalid refresh token' + } + } else { + Logger.error(`[TokenManager] Refresh token error: ${error.message}`) + return { + error: 'Invalid refresh token' + } + } + } + } + + /** + * Invalidate all JWT sessions for a given user + * If user is current user and refresh token is valid, rotate tokens for the current session + * + * @param {import('../models/User')} user + * @param {import('express').Request} req + * @param {import('express').Response} res + * @returns {Promise} accessToken only if user is current user and refresh token is valid + */ + async invalidateJwtSessionsForUser(user, req, res) { + const currentRefreshToken = req.cookies.refresh_token + if (req.user.id === user.id && currentRefreshToken) { + // Current user is the same as the user to invalidate sessions for + // So rotate token for current session + const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) + if (currentSession) { + const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) + + // Invalidate all sessions for the user except the current one + await Database.sessionModel.destroy({ + where: { + id: { + [Op.ne]: currentSession.id + }, + userId: user.id + } + }) + + return newTokens.accessToken + } else { + Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`) + } + } + + // Current user is not the same as the user to invalidate sessions for (or no refresh token) + // So invalidate all sessions for the user + await Database.sessionModel.destroy({ where: { userId: user.id } }) + return null + } +} + +module.exports = TokenManager diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 48c98150..2ed92616 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -128,7 +128,7 @@ class UserController { const userId = uuidv4() const pash = await this.auth.hashPass(req.body.password) - const token = await this.auth.generateAccessToken({ id: userId, username: req.body.username }) + const token = this.auth.generateAccessToken({ id: userId, username: req.body.username }) const userType = req.body.type || 'user' // librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions @@ -327,7 +327,7 @@ class UserController { if (hasUpdates) { if (shouldUpdateToken) { - user.token = await this.auth.generateAccessToken(user) + user.token = this.auth.generateAccessToken(user) Logger.info(`[UserController] User ${user.username} has generated a new api token`) } diff --git a/server/models/User.js b/server/models/User.js index 154587a7..3f06b238 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -190,7 +190,7 @@ class User extends Model { static async createRootUser(username, pash, auth) { const userId = uuidv4() - const token = await auth.generateAccessToken({ id: userId, username }) + const token = auth.generateAccessToken({ id: userId, username }) const newUser = { id: userId, @@ -208,6 +208,96 @@ class User extends Model { return this.create(newUser) } + /** + * Finds an existing user by OpenID subject identifier, or by email/username based on server settings, + * or creates a new user if configured to do so. + * + * @param {Object} userinfo + * @param {import('../Auth')} auth + * @returns {Promise} + */ + static async findOrCreateUserFromOpenIdUserInfo(userinfo, auth) { + let user = await this.getUserByOpenIDSub(userinfo.sub) + + // Matched by sub + if (user) { + Logger.debug(`[User] openid: User found by sub`) + return user + } + + // Match existing user by email + if (global.ServerSettings.authOpenIDMatchExistingBy === 'email') { + if (userinfo.email) { + // Only disallow when email_verified explicitly set to false (allow both if not set or true) + if (userinfo.email_verified === false) { + Logger.warn(`[User] openid: User not found and email "${userinfo.email}" is not verified`) + return null + } else { + Logger.info(`[User] openid: User not found, checking existing with email "${userinfo.email}"`) + user = await this.getUserByEmail(userinfo.email) + + if (user?.authOpenIDSub) { + Logger.warn(`[User] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) + return null // User is linked to a different OpenID subject; do not proceed. + } + } + } else { + Logger.warn(`[User] openid: User not found and no email in userinfo`) + // We deny login, because if the admin whishes to match email, it makes sense to require it + return null + } + } + // Match existing user by username + else if (global.ServerSettings.authOpenIDMatchExistingBy === 'username') { + let username + + if (userinfo.preferred_username) { + Logger.info(`[User] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`) + username = userinfo.preferred_username + } else if (userinfo.username) { + Logger.info(`[User] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`) + username = userinfo.username + } else { + Logger.warn(`[User] openid: User not found and neither preferred_username nor username in userinfo`) + return null + } + + user = await this.getUserByUsername(username) + + if (user?.authOpenIDSub) { + Logger.warn(`[User] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`) + return null // User is linked to a different OpenID subject; do not proceed. + } + } + + // Found existing user via email or username + if (user) { + if (!user.isActive) { + Logger.warn(`[User] openid: User found but is not active`) + return null + } + + // Update user with OpenID sub + if (!user.extraData) user.extraData = {} + user.extraData.authOpenIDSub = userinfo.sub + user.changed('extraData', true) + await user.save() + + Logger.debug(`[User] openid: User found by email/username`) + return user + } + + // If no existing user was matched, auto-register if configured + if (global.ServerSettings.authOpenIDAutoRegister) { + Logger.info(`[User] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) + user = await this.createUserFromOpenIdUserInfo(userinfo, auth) + return user + } + + Logger.warn(`[User] openid: User not found and auto-register is disabled`) + return null + } + /** * Create user from openid userinfo * @param {Object} userinfo @@ -220,7 +310,7 @@ class User extends Model { const username = userinfo.preferred_username || userinfo.name || userinfo.sub const email = userinfo.email && userinfo.email_verified ? userinfo.email : null - const token = await auth.generateAccessToken({ id: userId, username }) + const token = auth.generateAccessToken({ id: userId, username }) const newUser = { id: userId, From ce803dd6de252ca7aaf65055c7f566136093d3c2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Jul 2025 17:39:03 -0500 Subject: [PATCH 082/182] Use getServerSetting to ensure serverSettings is set before accessing --- client/components/cards/LazyBookCard.vue | 2 +- client/components/cards/LazySeriesCard.vue | 2 +- client/components/modals/BookmarksModal.vue | 4 ++-- client/components/modals/ListeningSessionModal.vue | 4 ++-- client/components/modals/ShareModal.vue | 2 +- client/components/modals/changelog/ViewModal.vue | 2 +- client/components/tables/BackupsTable.vue | 4 ++-- client/components/tables/UsersTable.vue | 4 ++-- client/components/tables/podcast/LazyEpisodeRow.vue | 2 +- client/components/tables/podcast/LazyEpisodesTable.vue | 4 ++-- client/components/widgets/CronExpressionBuilder.vue | 2 +- client/pages/config/rss-feeds.vue | 4 ++-- client/pages/config/sessions.vue | 4 ++-- client/pages/config/users/_id/index.vue | 4 ++-- client/pages/config/users/_id/sessions.vue | 4 ++-- client/pages/item/_id/index.vue | 2 +- client/pages/library/_library/podcast/latest.vue | 2 +- 17 files changed, 26 insertions(+), 26 deletions(-) diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 35c959fa..41b73310 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -198,7 +198,7 @@ export default { return this.store.getters['user/getSizeMultiplier'] }, dateFormat() { - return this.store.state.serverSettings.dateFormat + return this.store.getters['getServerSetting']('dateFormat') }, _libraryItem() { return this.libraryItem || {} diff --git a/client/components/cards/LazySeriesCard.vue b/client/components/cards/LazySeriesCard.vue index 3532095b..34cea7e2 100644 --- a/client/components/cards/LazySeriesCard.vue +++ b/client/components/cards/LazySeriesCard.vue @@ -71,7 +71,7 @@ export default { return this.height * this.sizeMultiplier }, dateFormat() { - return this.store.state.serverSettings.dateFormat + return this.store.getters['getServerSetting']('dateFormat') }, labelFontSize() { if (this.width < 160) return 0.75 diff --git a/client/components/modals/BookmarksModal.vue b/client/components/modals/BookmarksModal.vue index de8c72b7..d84a8ed8 100644 --- a/client/components/modals/BookmarksModal.vue +++ b/client/components/modals/BookmarksModal.vue @@ -79,10 +79,10 @@ export default { return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1) }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/components/modals/ListeningSessionModal.vue b/client/components/modals/ListeningSessionModal.vue index a2469836..ecf00f78 100644 --- a/client/components/modals/ListeningSessionModal.vue +++ b/client/components/modals/ListeningSessionModal.vue @@ -159,10 +159,10 @@ export default { return 'Unknown' }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') }, isOpenSession() { return !!this._session.open diff --git a/client/components/modals/ShareModal.vue b/client/components/modals/ShareModal.vue index 24994b22..bd0c9acf 100644 --- a/client/components/modals/ShareModal.vue +++ b/client/components/modals/ShareModal.vue @@ -144,7 +144,7 @@ export default { expirationDateString() { if (!this.expireDurationSeconds) return this.$strings.LabelPermanent const dateMs = Date.now() + this.expireDurationSeconds * 1000 - return this.$formatDatetime(dateMs, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) + return this.$formatDatetime(dateMs, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat')) } }, methods: { diff --git a/client/components/modals/changelog/ViewModal.vue b/client/components/modals/changelog/ViewModal.vue index 1b332a1d..939ee71d 100644 --- a/client/components/modals/changelog/ViewModal.vue +++ b/client/components/modals/changelog/ViewModal.vue @@ -40,7 +40,7 @@ export default { } }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, releasesToShow() { return this.versionData?.releasesToShow || [] diff --git a/client/components/tables/BackupsTable.vue b/client/components/tables/BackupsTable.vue index 769c8d25..f769abdb 100644 --- a/client/components/tables/BackupsTable.vue +++ b/client/components/tables/BackupsTable.vue @@ -78,10 +78,10 @@ export default { return this.$store.getters['user/getToken'] }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue index 20f41228..c7171018 100644 --- a/client/components/tables/UsersTable.vue +++ b/client/components/tables/UsersTable.vue @@ -76,10 +76,10 @@ export default { return usermap }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/components/tables/podcast/LazyEpisodeRow.vue b/client/components/tables/podcast/LazyEpisodeRow.vue index ae99e6d3..ee189961 100644 --- a/client/components/tables/podcast/LazyEpisodeRow.vue +++ b/client/components/tables/podcast/LazyEpisodeRow.vue @@ -112,7 +112,7 @@ export default { return this.episode?.publishedAt }, dateFormat() { - return this.store.state.serverSettings.dateFormat + return this.store.getters['getServerSetting']('dateFormat') }, itemProgress() { return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId) diff --git a/client/components/tables/podcast/LazyEpisodesTable.vue b/client/components/tables/podcast/LazyEpisodesTable.vue index b23cc560..d23ee3d3 100644 --- a/client/components/tables/podcast/LazyEpisodesTable.vue +++ b/client/components/tables/podcast/LazyEpisodesTable.vue @@ -239,10 +239,10 @@ export default { }) }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/components/widgets/CronExpressionBuilder.vue b/client/components/widgets/CronExpressionBuilder.vue index 77b5a54c..600ed81a 100644 --- a/client/components/widgets/CronExpressionBuilder.vue +++ b/client/components/widgets/CronExpressionBuilder.vue @@ -85,7 +85,7 @@ export default { nextRun() { if (!this.cronExpression) return '' const parsed = this.$getNextScheduledDate(this.cronExpression) - return this.$formatJsDatetime(parsed, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) || '' + return this.$formatJsDatetime(parsed, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat')) || '' }, description() { if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return '' diff --git a/client/pages/config/rss-feeds.vue b/client/pages/config/rss-feeds.vue index 56823920..b5ba90a3 100644 --- a/client/pages/config/rss-feeds.vue +++ b/client/pages/config/rss-feeds.vue @@ -78,10 +78,10 @@ export default { }, computed: { dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/pages/config/sessions.vue b/client/pages/config/sessions.vue index 0b1bdaa7..135922d3 100644 --- a/client/pages/config/sessions.vue +++ b/client/pages/config/sessions.vue @@ -250,10 +250,10 @@ export default { return user?.username || null }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') }, numSelected() { return this.listeningSessions.filter((s) => s.selected).length diff --git a/client/pages/config/users/_id/index.vue b/client/pages/config/users/_id/index.vue index e2f8e208..f0e6647f 100644 --- a/client/pages/config/users/_id/index.vue +++ b/client/pages/config/users/_id/index.vue @@ -129,10 +129,10 @@ export default { return this.listeningSessions.sessions[0] }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/pages/config/users/_id/sessions.vue b/client/pages/config/users/_id/sessions.vue index ab33b4b1..764983d7 100644 --- a/client/pages/config/users/_id/sessions.vue +++ b/client/pages/config/users/_id/sessions.vue @@ -98,10 +98,10 @@ export default { return this.$store.getters['users/getIsUserOnline'](this.user.id) }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 6042f92a..1d8f0f20 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -193,7 +193,7 @@ export default { return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}` }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, userIsAdminOrUp() { return this.$store.getters['user/getIsAdminOrUp'] diff --git a/client/pages/library/_library/podcast/latest.vue b/client/pages/library/_library/podcast/latest.vue index 4da25f3e..4f12043e 100644 --- a/client/pages/library/_library/podcast/latest.vue +++ b/client/pages/library/_library/podcast/latest.vue @@ -141,7 +141,7 @@ export default { return episodeIds }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') } }, methods: { From d9cfcc86e77971966c556ac19be90cca5c7b3741 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 09:16:07 -0500 Subject: [PATCH 083/182] Update oidc to return refresh token in response body for mobile --- server/Auth.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index a4c52781..6b2f2bd8 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -399,10 +399,11 @@ class Auth { */ async handleLoginSuccessBasedOnCookie(req, res) { // Handle token generation and get userResponse object - // TODO: where to check if refresh tokens should be returned? - const userResponse = await this.handleLoginSuccess(req, res, false) + // For API based auth (e.g. mobile), we will return the refresh token in the response + const isApiBased = this.isAuthMethodAPIBased(req.cookies.auth_method) + const userResponse = await this.handleLoginSuccess(req, res, isApiBased) - if (this.isAuthMethodAPIBased(req.cookies.auth_method)) { + if (isApiBased) { // REST request - send data res.json(userResponse) } else { From 9c8900560c8261ce885f138e3b2de5c8f77a0687 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 15:04:40 -0500 Subject: [PATCH 084/182] Seperate out auth strategies, update change password to return error status codes --- client/pages/account.vue | 21 +- server/Auth.js | 539 ++--------------------------- server/auth/LocalAuthStrategy.js | 186 ++++++++++ server/auth/OidcAuthStrategy.js | 488 ++++++++++++++++++++++++++ server/controllers/MeController.js | 18 +- 5 files changed, 729 insertions(+), 523 deletions(-) create mode 100644 server/auth/LocalAuthStrategy.js create mode 100644 server/auth/OidcAuthStrategy.js diff --git a/client/pages/account.vue b/client/pages/account.vue index b157f570..e9b5da3c 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -182,18 +182,19 @@ export default { password: this.password, newPassword: this.newPassword }) - .then((res) => { - if (res.success) { - this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) - this.resetForm() - } else { - this.$toast.error(res.error || this.$strings.ToastUnknownError) - } - this.changingPassword = false + .then(() => { + this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) + this.resetForm() }) .catch((error) => { - console.error(error) - this.$toast.error(this.$strings.ToastUnknownError) + console.error('Failed to change password', error) + let errorMessage = this.$strings.ToastUnknownError + if (error.response?.data && typeof error.response.data === 'string') { + errorMessage = error.response.data + } + this.$toast.error(errorMessage) + }) + .finally(() => { this.changingPassword = false }) }, diff --git a/server/Auth.js b/server/Auth.js index 6b2f2bd8..e62df0b8 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,17 +1,14 @@ const { Request, Response, NextFunction } = require('express') -const axios = require('axios') const passport = require('passport') -const OpenIDClient = require('openid-client') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt const Database = require('./Database') const Logger = require('./Logger') const TokenManager = require('./auth/TokenManager') +const LocalAuthStrategy = require('./auth/LocalAuthStrategy') +const OidcAuthStrategy = require('./auth/OidcAuthStrategy') -const bcrypt = require('./libs/bcryptjs') -const requestIp = require('./libs/requestIp') -const LocalStrategy = require('./libs/passportLocal') const { escapeRegExp } = require('./utils') /** @@ -19,12 +16,12 @@ const { escapeRegExp } = require('./utils') */ class Auth { constructor() { - // Map of openId sessions indexed by oauth2 state-variable - this.openIdAuthSession = new Map() const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)] this.tokenManager = new TokenManager() + this.localAuthStrategy = new LocalAuthStrategy() + this.oidcAuthStrategy = new OidcAuthStrategy() } /** @@ -117,12 +114,12 @@ class Auth { async initPassportJs() { // Check if we should load the local strategy (username + password login) if (global.ServerSettings.authActiveAuthMethods.includes('local')) { - this.initAuthStrategyPassword() + this.localAuthStrategy.init() } // Check if we should load the openid strategy if (global.ServerSettings.authActiveAuthMethods.includes('openid')) { - this.initAuthStrategyOpenID() + this.oidcAuthStrategy.init() } // Load the JwtStrategy (always) -> for bearer token auth @@ -165,169 +162,21 @@ class Auth { }.bind(this) ) } - - /** - * Passport use LocalStrategy - */ - initAuthStrategyPassword() { - passport.use(new LocalStrategy({ passReqToCallback: true }, this.localAuthCheckUserPw.bind(this))) - } - - /** - * Passport use OpenIDClient.Strategy - */ - initAuthStrategyOpenID() { - if (!Database.serverSettings.isOpenIDAuthSettingsValid) { - Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`) - return - } - - // Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing - OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 }) - - const openIdIssuerClient = new OpenIDClient.Issuer({ - issuer: global.ServerSettings.authOpenIDIssuerURL, - authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, - token_endpoint: global.ServerSettings.authOpenIDTokenURL, - userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, - jwks_uri: global.ServerSettings.authOpenIDJwksURL, - end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL - }).Client - const openIdClient = new openIdIssuerClient({ - client_id: global.ServerSettings.authOpenIDClientID, - client_secret: global.ServerSettings.authOpenIDClientSecret, - id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm - }) - passport.use( - 'openid-client', - new OpenIDClient.Strategy( - { - client: openIdClient, - params: { - redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, - scope: 'openid profile email' - } - }, - async (tokenset, userinfo, done) => { - try { - Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) - - if (!userinfo.sub) { - throw new Error('Invalid userinfo, no sub') - } - - if (!this.validateGroupClaim(userinfo)) { - throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) - } - - let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) - - if (!user?.isActive) { - throw new Error('User not active or not found') - } - - await this.setUserGroup(user, userinfo) - await this.updateUserPermissions(user, userinfo) - - // We also have to save the id_token for later (used for logout) because we cannot set cookies here - user.openid_id_token = tokenset.id_token - - return done(null, user) - } catch (error) { - Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`) - - return done(null, null, 'Unauthorized') - } - } - ) - ) - } // #endregion - /** - * Validates the presence and content of the group claim in userinfo. - */ - validateGroupClaim(userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim - if (!groupClaimName) - // Allow no group claim when configured like this - return true - - // If configured it must exist in userinfo - if (!userinfo[groupClaimName]) { - return false - } - return true - } - - /** - * Sets the user group based on group claim in userinfo. - * - * @param {import('./models/User')} user - * @param {Object} userinfo - */ - async setUserGroup(user, userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim - if (!groupClaimName) - // No group claim configured, don't set anything - return - - if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`) - - const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()) - const rolesInOrderOfPriority = ['admin', 'user', 'guest'] - - let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) - if (userType) { - if (user.type === 'root') { - // Check OpenID Group - if (userType !== 'admin') { - throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`) - } else { - // If root user is logging in via OpenID, we will not change the type - return - } - } - - if (user.type !== userType) { - Logger.info(`[Auth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`) - user.type = userType - await user.save() - } - } else { - throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) - } - } - - /** - * Updates user permissions based on the advanced permissions claim. - * - * @param {import('./models/User')} user - * @param {Object} userinfo - */ - async updateUserPermissions(user, userinfo) { - const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim - if (!absPermissionsClaim) - // No advanced permissions claim configured, don't set anything - return - - if (user.type === 'admin' || user.type === 'root') return - - const absPermissions = userinfo[absPermissionsClaim] - if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) - - if (await user.updatePermissionsFromExternalJSON(absPermissions)) { - Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) - } - } - /** * Unuse strategy * * @param {string} name */ unuseAuthStrategy(name) { - passport.unuse(name) + if (name === 'openid') { + this.oidcAuthStrategy.unuse() + } else if (name === 'local') { + this.localAuthStrategy.unuse() + } else { + Logger.error('[Auth] Invalid auth strategy ' + name) + } } /** @@ -337,9 +186,9 @@ class Auth { */ useAuthStrategy(name) { if (name === 'openid') { - this.initAuthStrategyOpenID() + this.oidcAuthStrategy.init() } else if (name === 'local') { - this.initAuthStrategyPassword() + this.localAuthStrategy.init() } else { Logger.error('[Auth] Invalid auth strategy ' + name) } @@ -496,153 +345,27 @@ class Auth { }) // openid strategy login route (this redirects to the configured openid login provider) - router.get('/auth/openid', (req, res, next) => { - // Get the OIDC client from the strategy - // We need to call the client manually, because the strategy does not support forwarding the code challenge - // for API or mobile clients - const oidcStrategy = passport._strategy('openid-client') - const client = oidcStrategy._client - const sessionKey = oidcStrategy._key + router.get('/auth/openid', (req, res) => { + const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req) - try { - const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' - const hostUrl = new URL(`${protocol}://${req.get('host')}`) - const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge - - // Only allow code flow (for mobile clients) - if (req.query.response_type && req.query.response_type !== 'code') { - Logger.debug(`[Auth] OIDC Invalid response_type=${req.query.response_type}`) - return res.status(400).send('Invalid response_type, only code supported') - } - - // Generate a state on web flow or if no state supplied - const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state - - // Redirect URL for the SSO provider - let redirectUri - if (isMobileFlow) { - // Mobile required redirect uri - // If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect - // where we will handle the redirect to it - if (!req.query.redirect_uri || !isValidRedirectUri(req.query.redirect_uri)) { - Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri}`) - return res.status(400).send('Invalid redirect_uri') - } - // We cannot save the supplied 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(state, { mobile_redirect_uri: req.query.redirect_uri }) - - redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() - } else { - redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString() - - if (req.query.state) { - Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`) - return res.status(400).send('Invalid state, not allowed on web flow') - } - } - oidcStrategy._params.redirect_uri = redirectUri - Logger.debug(`[Auth] OIDC redirect_uri=${redirectUri}`) - - let { code_challenge, code_challenge_method, code_verifier } = generatePkce(req, isMobileFlow) - - req.session[sessionKey] = { - ...req.session[sessionKey], - state: state, - max_age: oidcStrategy._params.max_age, - response_type: 'code', - code_verifier: code_verifier, // not null if web flow - 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 - } - - var scope = 'openid profile email' - if (global.ServerSettings.authOpenIDGroupClaim) { - scope += ' ' + global.ServerSettings.authOpenIDGroupClaim - } - if (global.ServerSettings.authOpenIDAdvancedPermsClaim) { - scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim - } - - const authorizationUrl = client.authorizationUrl({ - ...oidcStrategy._params, - state: state, - response_type: 'code', - scope: scope, - code_challenge, - code_challenge_method - }) - - this.paramsToCookies(req, res, isMobileFlow ? 'openid-mobile' : 'openid') - - res.redirect(authorizationUrl) - } catch (error) { - Logger.error(`[Auth] Error in /auth/openid route: ${error}\n${error?.stack}`) - res.status(500).send('Internal Server Error') + if (authorizationUrlResponse.error) { + return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error) } - function generatePkce(req, isMobileFlow) { - if (isMobileFlow) { - if (!req.query.code_challenge) { - throw new Error('code_challenge required for mobile flow (PKCE)') - } - if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') { - throw new Error('Only S256 code_challenge_method method supported') - } - return { - code_challenge: req.query.code_challenge, - code_challenge_method: req.query.code_challenge_method || 'S256' - } - } else { - const code_verifier = OpenIDClient.generators.codeVerifier() - const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) - return { code_challenge, code_challenge_method: 'S256', code_verifier } - } - } + this.paramsToCookies(req, res, authorizationUrlResponse.isMobileFlow ? 'openid-mobile' : 'openid') - function isValidRedirectUri(uri) { - // Check if the redirect_uri is in the whitelist - return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*') - } + res.redirect(authorizationUrlResponse.authorizationUrl) }) // 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 mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_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 = `${mobile_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}\n${error?.stack}`) - res.status(500).send('Internal Server Error') - } - }) + router.get('/auth/openid/mobile-redirect', (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) // 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') - const sessionKey = oidcStrategy._key + const sessionKey = this.oidcAuthStrategy.getStrategy()._key if (!req.session[sessionKey]) { return res.status(400).send('No session') @@ -719,43 +442,16 @@ class Auth { return res.sendStatus(403) } - if (!req.query.issuer) { + if (!req.query.issuer || typeof req.query.issuer !== 'string') { 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) - - // 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") + const openIdIssuerConfig = await this.oidcAuthStrategy.getIssuerConfig(req.query.issuer) + if (openIdIssuerConfig.error) { + return res.status(openIdIssuerConfig.status).send(openIdIssuerConfig.error) } - axios - .get(configUrl.toString()) - .then(({ data }) => { - res.json({ - issuer: data.issuer, - authorization_endpoint: data.authorization_endpoint, - token_endpoint: data.token_endpoint, - userinfo_endpoint: data.userinfo_endpoint, - end_session_endpoint: data.end_session_endpoint, - jwks_uri: data.jwks_uri, - id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported - }) - }) - .catch((error) => { - Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error) - res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`) - }) + res.json(openIdIssuerConfig) }) // Logout route @@ -782,7 +478,6 @@ class Auth { Logger.info(`[Auth] logout: No refresh token on request`) } - // TODO: invalidate possible JWTs req.logout((err) => { if (err) { res.sendStatus(500) @@ -794,36 +489,7 @@ class Auth { let logoutUrl = null if (authMethod === 'openid' || authMethod === 'openid-mobile') { - // If we are using openid, we need to redirect to the logout endpoint - // node-openid-client does not support doing it over passport - const oidcStrategy = passport._strategy('openid-client') - const client = oidcStrategy._client - - if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) { - let postLogoutRedirectUri = null - - if (authMethod === 'openid') { - const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' - const host = req.get('host') - // TODO: ABS does currently not support subfolders for installation - // If we want to support it we need to include a config for the serverurl - postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login` - } - // else for openid-mobile we keep postLogoutRedirectUri on null - // nice would be to redirect to the app here, but for example Authentik does not implement - // the post_logout_redirect_uri parameter at all and for other providers - // we would also need again to implement (and even before get to know somehow for 3rd party apps) - // the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect). - // Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like - // &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution - // (The URL needs to be whitelisted in the config of the SSO/ID provider) - - logoutUrl = client.endSessionUrl({ - id_token_hint: req.cookies.openid_id_token, - post_logout_redirect_uri: postLogoutRedirectUri - }) - } - + logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod) res.clearCookie('openid_id_token') } @@ -835,153 +501,6 @@ class Auth { }) } // #endregion - - // #region Local Auth - /** - * Checks if a username and password tuple is valid and the user active. - * @param {Request} req - * @param {string} username - * @param {string} password - * @param {Promise} done - */ - async localAuthCheckUserPw(req, username, password, done) { - // Load the user given it's username - const user = await Database.userModel.getUserByUsername(username.toLowerCase()) - - if (!user?.isActive) { - if (user) { - this.logFailedLocalAuthLoginAttempt(req, user.username, 'User is not active') - } else { - this.logFailedLocalAuthLoginAttempt(req, username, 'User not found') - } - done(null, null) - return - } - - // Check passwordless root user - if (user.type === 'root' && !user.pash) { - if (password) { - // deny login - this.logFailedLocalAuthLoginAttempt(req, user.username, 'Root user has no password set') - done(null, null) - return - } - // approve login - Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) - - done(null, user) - return - } else if (!user.pash) { - this.logFailedLocalAuthLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID') - done(null, null) - return - } - - // Check password match - const compare = await bcrypt.compare(password, user.pash) - if (compare) { - // approve login - Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) - - done(null, user) - return - } - // deny login - this.logFailedLocalAuthLoginAttempt(req, user.username, 'Invalid password') - done(null, null) - return - } - - /** - * - * @param {Request} req - * @param {string} username - * @param {string} message - */ - logFailedLocalAuthLoginAttempt(req, username, message) { - if (!req || !username || !message) return - Logger.error(`[Auth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`) - } - - /** - * Hashes a password with bcrypt. - * @param {string} password - * @returns {Promise} hash - */ - hashPass(password) { - return new Promise((resolve) => { - bcrypt.hash(password, 8, (err, hash) => { - if (err) { - resolve(null) - } else { - resolve(hash) - } - }) - }) - } - - /** - * - * @param {string} password - * @param {import('./models/User')} user - * @returns {Promise} - */ - comparePassword(password, user) { - if (user.type === 'root' && !password && !user.pash) return true - if (!password || !user.pash) return false - return bcrypt.compare(password, user.pash) - } - - /** - * User changes their password from request - * TODO: Update responses to use error status codes - * - * @param {import('./controllers/MeController').RequestWithUser} req - * @param {Response} res - */ - async userChangePassword(req, res) { - let { password, newPassword } = req.body - newPassword = newPassword || '' - const matchingUser = req.user - - // Only root can have an empty password - if (matchingUser.type !== 'root' && !newPassword) { - return res.json({ - error: 'Invalid new password - Only root can have an empty password' - }) - } - - // Check password match - const compare = await this.comparePassword(password, matchingUser) - if (!compare) { - return res.json({ - error: 'Invalid password' - }) - } - - let pw = '' - if (newPassword) { - pw = await this.hashPass(newPassword) - if (!pw) { - return res.json({ - error: 'Hash failed' - }) - } - } - try { - await matchingUser.update({ pash: pw }) - Logger.info(`[Auth] User "${matchingUser.username}" changed password`) - res.json({ - success: true - }) - } catch (error) { - Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error) - res.json({ - error: 'Unknown error' - }) - } - } - // #endregion } module.exports = Auth diff --git a/server/auth/LocalAuthStrategy.js b/server/auth/LocalAuthStrategy.js new file mode 100644 index 00000000..e9499d13 --- /dev/null +++ b/server/auth/LocalAuthStrategy.js @@ -0,0 +1,186 @@ +const passport = require('passport') +const LocalStrategy = require('../libs/passportLocal') +const Database = require('../Database') +const Logger = require('../Logger') + +const bcrypt = require('../libs/bcryptjs') +const requestIp = require('../libs/requestIp') + +/** + * Local authentication strategy using username/password + */ +class LocalAuthStrategy { + constructor() { + this.name = 'local' + this.strategy = null + } + + /** + * Get the passport strategy instance + * @returns {LocalStrategy} + */ + getStrategy() { + if (!this.strategy) { + this.strategy = new LocalStrategy({ passReqToCallback: true }, this.verifyCredentials.bind(this)) + } + return this.strategy + } + + /** + * Initialize the strategy with passport + */ + init() { + passport.use(this.name, this.getStrategy()) + } + + /** + * Remove the strategy from passport + */ + unuse() { + passport.unuse(this.name) + this.strategy = null + } + + /** + * Verify user credentials + * @param {import('express').Request} req + * @param {string} username + * @param {string} password + * @param {Function} done - Passport callback + */ + async verifyCredentials(req, username, password, done) { + // Load the user given it's username + const user = await Database.userModel.getUserByUsername(username.toLowerCase()) + + if (!user?.isActive) { + if (user) { + this.logFailedLoginAttempt(req, user.username, 'User is not active') + } else { + this.logFailedLoginAttempt(req, username, 'User not found') + } + done(null, null) + return + } + + // Check passwordless root user + if (user.type === 'root' && !user.pash) { + if (password) { + // deny login + this.logFailedLoginAttempt(req, user.username, 'Root user has no password set') + done(null, null) + return + } + // approve login + Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) + + done(null, user) + return + } else if (!user.pash) { + this.logFailedLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID') + done(null, null) + return + } + + // Check password match + const compare = await bcrypt.compare(password, user.pash) + if (compare) { + // approve login + Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) + + done(null, user) + return + } + + // deny login + this.logFailedLoginAttempt(req, user.username, 'Invalid password') + done(null, null) + } + + /** + * Log failed login attempts + * @param {import('express').Request} req + * @param {string} username + * @param {string} message + */ + logFailedLoginAttempt(req, username, message) { + if (!req || !username || !message) return + Logger.error(`[LocalAuth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`) + } + + /** + * Hash a password with bcrypt + * @param {string} password + * @returns {Promise} hash + */ + hashPassword(password) { + return new Promise((resolve) => { + bcrypt.hash(password, 8, (err, hash) => { + if (err) { + resolve(null) + } else { + resolve(hash) + } + }) + }) + } + + /** + * Compare password with user's hashed password + * @param {string} password + * @param {import('../models/User')} user + * @returns {Promise} + */ + comparePassword(password, user) { + if (user.type === 'root' && !password && !user.pash) return true + if (!password || !user.pash) return false + return bcrypt.compare(password, user.pash) + } + + /** + * Change user password + * @param {import('../models/User')} user + * @param {string} password + * @param {string} newPassword + */ + async changePassword(user, password, newPassword) { + // Only root can have an empty password + if (user.type !== 'root' && !newPassword) { + return { + error: 'Invalid new password - Only root can have an empty password' + } + } + + // Check password match + const compare = await this.comparePassword(password, user) + if (!compare) { + return { + error: 'Invalid password' + } + } + + let pw = '' + if (newPassword) { + pw = await this.hashPassword(newPassword) + if (!pw) { + return { + error: 'Hash failed' + } + } + } + + try { + await user.update({ pash: pw }) + Logger.info(`[LocalAuth] User "${user.username}" changed password`) + return { + success: true + } + } catch (error) { + Logger.error(`[LocalAuth] User "${user.username}" failed to change password`, error) + return { + error: 'Unknown error' + } + } + } +} + +module.exports = LocalAuthStrategy diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js new file mode 100644 index 00000000..c3f6cfb2 --- /dev/null +++ b/server/auth/OidcAuthStrategy.js @@ -0,0 +1,488 @@ +const { Request, Response } = require('express') +const passport = require('passport') +const OpenIDClient = require('openid-client') +const axios = require('axios') +const Database = require('../Database') +const Logger = require('../Logger') + +/** + * OpenID Connect authentication strategy + */ +class OidcAuthStrategy { + constructor() { + this.name = 'openid-client' + this.strategy = null + this.client = null + // Map of openId sessions indexed by oauth2 state-variable + this.openIdAuthSession = new Map() + } + + /** + * Get the passport strategy instance + * @returns {OpenIDClient.Strategy} + */ + getStrategy() { + if (!this.strategy) { + this.strategy = new OpenIDClient.Strategy( + { + client: this.getClient(), + params: { + redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, + scope: this.getScope() + } + }, + this.verifyCallback.bind(this) + ) + } + return this.strategy + } + + /** + * Get the OpenID Connect client + * @returns {OpenIDClient.Client} + */ + getClient() { + if (!this.client) { + if (!Database.serverSettings.isOpenIDAuthSettingsValid) { + throw new Error('OpenID Connect settings are not valid') + } + + // Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing + OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 }) + + const openIdIssuerClient = new OpenIDClient.Issuer({ + issuer: global.ServerSettings.authOpenIDIssuerURL, + authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, + token_endpoint: global.ServerSettings.authOpenIDTokenURL, + userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, + jwks_uri: global.ServerSettings.authOpenIDJwksURL, + end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL + }).Client + + this.client = new openIdIssuerClient({ + client_id: global.ServerSettings.authOpenIDClientID, + client_secret: global.ServerSettings.authOpenIDClientSecret, + id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm + }) + } + return this.client + } + + /** + * Get the scope string for the OpenID Connect request + * @returns {string} + */ + getScope() { + let scope = 'openid profile email' + if (global.ServerSettings.authOpenIDGroupClaim) { + scope += ' ' + global.ServerSettings.authOpenIDGroupClaim + } + if (global.ServerSettings.authOpenIDAdvancedPermsClaim) { + scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim + } + return scope + } + + /** + * Initialize the strategy with passport + */ + init() { + if (!Database.serverSettings.isOpenIDAuthSettingsValid) { + Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`) + return + } + passport.use(this.name, this.getStrategy()) + } + + /** + * Remove the strategy from passport + */ + unuse() { + passport.unuse(this.name) + this.strategy = null + this.client = null + } + + /** + * Verify callback for OpenID Connect authentication + * @param {Object} tokenset + * @param {Object} userinfo + * @param {Function} done - Passport callback + */ + async verifyCallback(tokenset, userinfo, done) { + try { + Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) + + if (!userinfo.sub) { + throw new Error('Invalid userinfo, no sub') + } + + if (!this.validateGroupClaim(userinfo)) { + throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) + } + + let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) + + if (!user?.isActive) { + throw new Error('User not active or not found') + } + + await this.setUserGroup(user, userinfo) + await this.updateUserPermissions(user, userinfo) + + // We also have to save the id_token for later (used for logout) because we cannot set cookies here + user.openid_id_token = tokenset.id_token + + return done(null, user) + } catch (error) { + Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`) + return done(null, null, 'Unauthorized') + } + } + + /** + * Validates the presence and content of the group claim in userinfo. + * @param {Object} userinfo + * @returns {boolean} + */ + validateGroupClaim(userinfo) { + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim + if (!groupClaimName) + // Allow no group claim when configured like this + return true + + // If configured it must exist in userinfo + if (!userinfo[groupClaimName]) { + return false + } + return true + } + + /** + * Sets the user group based on group claim in userinfo. + * @param {import('../models/User')} user + * @param {Object} userinfo + */ + async setUserGroup(user, userinfo) { + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim + if (!groupClaimName) + // No group claim configured, don't set anything + return + + if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`) + + const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()) + const rolesInOrderOfPriority = ['admin', 'user', 'guest'] + + let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) + if (userType) { + if (user.type === 'root') { + // Check OpenID Group + if (userType !== 'admin') { + throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`) + } else { + // If root user is logging in via OpenID, we will not change the type + return + } + } + + if (user.type !== userType) { + Logger.info(`[OidcAuth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`) + user.type = userType + await user.save() + } + } else { + throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) + } + } + + /** + * Updates user permissions based on the advanced permissions claim. + * @param {import('../models/User')} user + * @param {Object} userinfo + */ + async updateUserPermissions(user, userinfo) { + const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim + if (!absPermissionsClaim) + // No advanced permissions claim configured, don't set anything + return + + if (user.type === 'admin' || user.type === 'root') return + + const absPermissions = userinfo[absPermissionsClaim] + if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) + + if (await user.updatePermissionsFromExternalJSON(absPermissions)) { + Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) + } + } + + /** + * Generate PKCE parameters for the authorization request + * @param {Request} req + * @param {boolean} isMobileFlow + * @returns {Object|{error: string}} + */ + generatePkce(req, isMobileFlow) { + if (isMobileFlow) { + if (!req.query.code_challenge) { + return { + error: 'code_challenge required for mobile flow (PKCE)' + } + } + if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') { + return { + error: 'Only S256 code_challenge_method method supported' + } + } + return { + code_challenge: req.query.code_challenge, + code_challenge_method: req.query.code_challenge_method || 'S256' + } + } else { + const code_verifier = OpenIDClient.generators.codeVerifier() + const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) + return { code_challenge, code_challenge_method: 'S256', code_verifier } + } + } + + /** + * Check if a redirect URI is valid + * @param {string} uri + * @returns {boolean} + */ + isValidRedirectUri(uri) { + // Check if the redirect_uri is in the whitelist + return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*') + } + + /** + * Get the authorization URL for OpenID Connect + * Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow + * @param {Request} req + * @returns {{ authorizationUrl: string }|{status: number, error: string}} + */ + getAuthorizationUrl(req) { + const client = this.getClient() + const strategy = this.getStrategy() + const sessionKey = strategy._key + + try { + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const hostUrl = new URL(`${protocol}://${req.get('host')}`) + const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge + + // Only allow code flow (for mobile clients) + if (req.query.response_type && req.query.response_type !== 'code') { + Logger.debug(`[OidcAuth] OIDC Invalid response_type=${req.query.response_type}`) + return { + status: 400, + error: 'Invalid response_type, only code supported' + } + } + + // Generate a state on web flow or if no state supplied + const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state + + // Redirect URL for the SSO provider + let redirectUri + if (isMobileFlow) { + // Mobile required redirect uri + // If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect + // where we will handle the redirect to it + if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) { + Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`) + return { + status: 400, + error: 'Invalid redirect_uri' + } + } + // We cannot save the supplied 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(state, { mobile_redirect_uri: req.query.redirect_uri }) + + redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() + } else { + redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString() + + if (req.query.state) { + Logger.debug(`[OidcAuth] Invalid state - not allowed on web openid flow`) + return { + status: 400, + error: 'Invalid state, not allowed on web flow' + } + } + } + + // Update the strategy's redirect_uri for this request + strategy._params.redirect_uri = redirectUri + Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`) + + const pkceData = this.generatePkce(req, isMobileFlow) + if (pkceData.error) { + return { + status: 400, + error: pkceData.error + } + } + + req.session[sessionKey] = { + ...req.session[sessionKey], + state: state, + max_age: strategy._params.max_age, + response_type: 'code', + code_verifier: pkceData.code_verifier, // not null if web flow + mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out + sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback + } + + const authorizationUrl = client.authorizationUrl({ + ...strategy._params, + redirect_uri: redirectUri, + state: state, + response_type: 'code', + scope: this.getScope(), + code_challenge: pkceData.code_challenge, + code_challenge_method: pkceData.code_challenge_method + }) + + return { + authorizationUrl, + isMobileFlow + } + } catch (error) { + Logger.error(`[OidcAuth] Error generating authorization URL: ${error}\n${error?.stack}`) + return { + status: 500, + error: error.message || 'Unknown error' + } + } + } + + /** + * Get the end session URL for logout + * @param {Request} req + * @param {string} idToken + * @param {string} authMethod + * @returns {string|null} + */ + getEndSessionUrl(req, idToken, authMethod) { + const client = this.getClient() + + if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) { + let postLogoutRedirectUri = null + + if (authMethod === 'openid') { + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const host = req.get('host') + // TODO: ABS does currently not support subfolders for installation + // If we want to support it we need to include a config for the serverurl + postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login` + } + // else for openid-mobile we keep postLogoutRedirectUri on null + // nice would be to redirect to the app here, but for example Authentik does not implement + // the post_logout_redirect_uri parameter at all and for other providers + // we would also need again to implement (and even before get to know somehow for 3rd party apps) + // the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect). + // Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like + // &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution + // (The URL needs to be whitelisted in the config of the SSO/ID provider) + + return client.endSessionUrl({ + id_token_hint: idToken, + post_logout_redirect_uri: postLogoutRedirectUri + }) + } + + return null + } + + /** + * @typedef {Object} OpenIdIssuerConfig + * @property {string} issuer + * @property {string} authorization_endpoint + * @property {string} token_endpoint + * @property {string} userinfo_endpoint + * @property {string} end_session_endpoint + * @property {string} jwks_uri + * @property {string} id_token_signing_alg_values_supported + * + * Get OpenID Connect configuration from an issuer URL + * @param {string} issuerUrl + * @returns {Promise} + */ + async getIssuerConfig(issuerUrl) { + // Strip trailing slash + if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) + + // 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(`[OidcAuth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) + return { + status: 400, + error: "Invalid request. Query param 'issuer' is invalid" + } + } + + try { + const { data } = await axios.get(configUrl.toString()) + return { + issuer: data.issuer, + authorization_endpoint: data.authorization_endpoint, + token_endpoint: data.token_endpoint, + userinfo_endpoint: data.userinfo_endpoint, + end_session_endpoint: data.end_session_endpoint, + jwks_uri: data.jwks_uri, + id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported + } + } catch (error) { + Logger.error(`[OidcAuth] Failed to get openid configuration at "${configUrl}"`, error) + return { + status: 400, + error: 'Failed to get openid configuration' + } + } + } + + /** + * Handle mobile redirect for OAuth2 callback + * @param {Request} req + * @param {Response} res + */ + handleMobileRedirect(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('[OidcAuth] /auth/openid/mobile-redirect route: State parameter mismatch') + return res.status(400).send('State parameter mismatch') + } + + let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri + + if (!mobile_redirect_uri) { + Logger.error('[OidcAuth] No redirect URI') + return res.status(400).send('No redirect URI') + } + + this.openIdAuthSession.delete(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) { + Logger.error(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`) + res.status(500).send('Internal Server Error') + } + } +} + +module.exports = OidcAuthStrategy diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 87acd221..9451a765 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -273,12 +273,24 @@ class MeController { * @param {RequestWithUser} req * @param {Response} res */ - updatePassword(req, res) { + async updatePassword(req, res) { if (req.user.isGuest) { Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`) - return res.sendStatus(500) + return res.sendStatus(403) } - this.auth.userChangePassword(req, res) + + const { password, newPassword } = req.body + if (!password || !newPassword || typeof password !== 'string' || typeof newPassword !== 'string') { + return res.status(400).send('Missing or invalid password or new password') + } + + const result = await this.auth.localAuthStrategy.changePassword(req.user, password, newPassword) + + if (result.error) { + return res.status(400).send(result.error) + } + + res.sendStatus(200) } /** From ac381854e56b736be5b629e46bafb26fd62131cf Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 16:23:15 -0500 Subject: [PATCH 085/182] Add rate limiter for auth endpoints --- package-lock.json | 16 ++++++++ package.json | 1 + server/Auth.js | 16 +++++--- server/routers/ApiRouter.js | 2 +- server/utils/rateLimiterFactory.js | 61 ++++++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 server/utils/rateLimiterFactory.js diff --git a/package-lock.json b/package-lock.json index d44ea79b..1be14fa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", + "express-rate-limit": "^7.5.1", "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", @@ -1893,6 +1894,21 @@ "node": ">= 0.10.0" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-session": { "version": "1.17.3", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", diff --git a/package.json b/package.json index 2fd1a87e..3fdbf768 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", + "express-rate-limit": "^7.5.1", "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", diff --git a/server/Auth.js b/server/Auth.js index e62df0b8..601fe8f2 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,4 +1,5 @@ const { Request, Response, NextFunction } = require('express') +const { rateLimit } = require('express-rate-limit') const passport = require('passport') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt @@ -9,6 +10,7 @@ const TokenManager = require('./auth/TokenManager') const LocalAuthStrategy = require('./auth/LocalAuthStrategy') const OidcAuthStrategy = require('./auth/OidcAuthStrategy') +const RateLimiterFactory = require('./utils/rateLimiterFactory') const { escapeRegExp } = require('./utils') /** @@ -19,6 +21,9 @@ class Auth { const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)] + /** @type {import('express-rate-limit').RateLimitRequestHandler} */ + this.authRateLimiter = RateLimiterFactory.getAuthRateLimiter() + this.tokenManager = new TokenManager() this.localAuthStrategy = new LocalAuthStrategy() this.oidcAuthStrategy = new OidcAuthStrategy() @@ -305,7 +310,7 @@ class Auth { */ async initAuthRoutes(router) { // Local strategy login route (takes username and password) - router.post('/login', passport.authenticate('local'), async (req, res) => { + router.post('/login', this.authRateLimiter, passport.authenticate('local'), async (req, res) => { // Check if mobile app wants refresh token in response const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' @@ -314,7 +319,7 @@ class Auth { }) // Refresh token route - router.post('/auth/refresh', async (req, res) => { + router.post('/auth/refresh', this.authRateLimiter, async (req, res) => { let refreshToken = req.cookies.refresh_token // If x-refresh-token header is present, use it instead of the cookie @@ -345,7 +350,7 @@ class Auth { }) // openid strategy login route (this redirects to the configured openid login provider) - router.get('/auth/openid', (req, res) => { + router.get('/auth/openid', this.authRateLimiter, (req, res) => { const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req) if (authorizationUrlResponse.error) { @@ -359,11 +364,12 @@ 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) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) + router.get('/auth/openid/mobile-redirect', this.authRateLimiter, (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) // openid strategy callback route (this receives the token from the configured openid login provider) router.get( '/auth/openid/callback', + this.authRateLimiter, (req, res, next) => { const sessionKey = this.oidcAuthStrategy.getStrategy()._key @@ -436,7 +442,7 @@ class Auth { * * @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/ */ - router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => { + router.get('/auth/openid/config', this.authRateLimiter, 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) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 8966ff66..6446ecc8 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -182,7 +182,7 @@ class ApiRouter { this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this)) this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this)) this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this)) - this.router.patch('/me/password', MeController.updatePassword.bind(this)) + this.router.patch('/me/password', this.auth.authRateLimiter, MeController.updatePassword.bind(this)) this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this)) this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js new file mode 100644 index 00000000..6f04d5ac --- /dev/null +++ b/server/utils/rateLimiterFactory.js @@ -0,0 +1,61 @@ +const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit') +const Logger = require('../Logger') + +/** + * Factory for creating authentication rate limiters + */ +class RateLimiterFactory { + constructor() { + this.authRateLimiter = null + } + + /** + * Get the authentication rate limiter + * @returns {RateLimitRequestHandler} + */ + getAuthRateLimiter() { + if (this.authRateLimiter) { + return this.authRateLimiter + } + + let windowMs = 10 * 60 * 1000 // 10 minutes default + if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) { + windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) + } + + let max = 20 // 20 attempts default + if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) { + max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) + } + + let message = 'Too many requests, please try again later.' + if (process.env.RATE_LIMIT_AUTH_MESSAGE) { + message = process.env.RATE_LIMIT_AUTH_MESSAGE + } + + this.authRateLimiter = rateLimit({ + windowMs, + max, + message, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + const userAgent = req.get('User-Agent') || 'Unknown' + const endpoint = req.path + const method = req.method + + Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${req.ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`) + + res.status(429).json({ + error: 'Too many authentication attempts, please try again later.' + }) + } + }) + + Logger.debug(`[RateLimiterFactory] Created auth rate limiter: ${max} attempts per ${windowMs / 1000 / 60} minutes`) + + return this.authRateLimiter + } +} + +module.exports = new RateLimiterFactory() From 691f291843e8b09fbf607d66915990be6c4f0470 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 16:26:17 -0500 Subject: [PATCH 086/182] Update LibraryItemController unit test --- test/server/controllers/LibraryItemController.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 9972bd90..5a042239 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -6,6 +6,7 @@ const Database = require('../../../server/Database') const ApiRouter = require('../../../server/routers/ApiRouter') const LibraryItemController = require('../../../server/controllers/LibraryItemController') const ApiCacheManager = require('../../../server/managers/ApiCacheManager') +const Auth = require('../../../server/Auth') const Logger = require('../../../server/Logger') describe('LibraryItemController', () => { @@ -19,6 +20,7 @@ describe('LibraryItemController', () => { await Database.buildModels() apiRouter = new ApiRouter({ + auth: new Auth(), apiCacheManager: new ApiCacheManager() }) From 4102ed8be47742c435cc7464ab1edd6cf91cf8f2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 16:49:20 -0500 Subject: [PATCH 087/182] Fix LazySeriesCard component test --- client/cypress/tests/components/cards/LazySeriesCard.cy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/cypress/tests/components/cards/LazySeriesCard.cy.js b/client/cypress/tests/components/cards/LazySeriesCard.cy.js index 346259d2..12eec692 100644 --- a/client/cypress/tests/components/cards/LazySeriesCard.cy.js +++ b/client/cypress/tests/components/cards/LazySeriesCard.cy.js @@ -40,6 +40,7 @@ describe('LazySeriesCard', () => { }, $store: { getters: { + getServerSetting: () => 'MM/dd/yyyy', 'user/getUserCanUpdate': true, 'user/getUserMediaProgress': (id) => null, 'user/getSizeMultiplier': 1, From 6cc7a44a22c917184177b886a8f4918e3ac344aa Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 17:21:25 -0500 Subject: [PATCH 088/182] Update oidc redirect to pass both new and old token in url --- client/pages/login.vue | 4 ++-- server/Auth.js | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 5d447ed9..a9d44561 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -305,8 +305,8 @@ export default { }, async mounted() { // Token passed as query parameter after successful oidc login - if (this.$route.query?.setToken) { - localStorage.setItem('token', this.$route.query.setToken) + if (this.$route.query?.accessToken) { + localStorage.setItem('token', this.$route.query.accessToken) } if (localStorage.getItem('token')) { if (await this.checkAuth()) return // if valid user no need to check status diff --git a/server/Auth.js b/server/Auth.js index 601fe8f2..1d229ceb 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -266,7 +266,8 @@ class Auth { if (req.cookies.auth_cb) { let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : '' // UI request -> redirect to auth_cb url and send the jwt token as parameter - res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.accessToken}${stateQuery}`) + // TODO: Temporarily continue sending the old token as setToken + res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.token}&accessToken=${userResponse.user.accessToken}${stateQuery}`) } else { res.status(400).send('No callback or already expired') } From 4ff735526229f9d4172071395edb82b3651bd01c Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 8 Jul 2025 09:14:07 -0500 Subject: [PATCH 089/182] Fix hashPassword --- server/Server.js | 2 +- server/controllers/UserController.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/Server.js b/server/Server.js index 639ae210..cf0ad5c7 100644 --- a/server/Server.js +++ b/server/Server.js @@ -431,7 +431,7 @@ class Server { Logger.info(`[Server] Initializing new server`) const newRoot = req.body.newRoot const rootUsername = newRoot.username || 'root' - const rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : '' + const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : '' if (!rootPash) Logger.warn(`[Server] Creating root user with no password`) await Database.createRootUser(rootUsername, rootPash, this.auth) diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 2ed92616..e72293cb 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -127,7 +127,7 @@ class UserController { } const userId = uuidv4() - const pash = await this.auth.hashPass(req.body.password) + const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password) const token = this.auth.generateAccessToken({ id: userId, username: req.body.username }) const userType = req.body.type || 'user' @@ -252,7 +252,7 @@ class UserController { // Updating password if (updatePayload.password) { - user.pash = await this.auth.hashPass(updatePayload.password) + user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password) hasUpdates = true } From d0d152c20d3db90ccefbc4a79d21c1b889e09af9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 8 Jul 2025 09:45:24 -0500 Subject: [PATCH 090/182] Seperate setUserToken from setUser in store --- client/pages/login.vue | 1 + client/plugins/axios.js | 3 +++ client/store/user.js | 23 +++++++++-------------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index a9d44561..51f60600 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -189,6 +189,7 @@ export default { this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) this.$store.commit('user/setUser', user) + this.$store.commit('user/setUserToken', user.accessToken) this.$store.dispatch('user/loadUserSettings') }, diff --git a/client/plugins/axios.js b/client/plugins/axios.js index 2724acb3..87eedca2 100644 --- a/client/plugins/axios.js +++ b/client/plugins/axios.js @@ -45,6 +45,7 @@ export default function ({ $axios, store, $root, app }) { if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/login') { // Refresh failed or login failed, redirect to login store.commit('user/setUser', null) + store.commit('user/setUserToken', null) app.router.push('/login') return Promise.reject(error) } @@ -81,6 +82,7 @@ export default function ({ $axios, store, $root, app }) { // Update the token in store and localStorage store.commit('user/setUser', response.user) + store.commit('user/setUserToken', newAccessToken) // Emit event used to re-authenticate socket in default.vue since $root is not available here if (app.$eventBus) { @@ -106,6 +108,7 @@ export default function ({ $axios, store, $root, app }) { // Clear user data and redirect to login store.commit('user/setUser', null) + store.commit('user/setUserToken', null) app.router.push('/login') return Promise.reject(refreshError) diff --git a/client/store/user.js b/client/store/user.js index e37568f1..04dc8447 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -151,22 +151,17 @@ export const actions = { export const mutations = { setUser(state, user) { state.user = user - if (user) { - // Use accessToken from user if included in response (for login) - if (user.accessToken) localStorage.setItem('token', user.accessToken) - else if (localStorage.getItem('token')) { - user.accessToken = localStorage.getItem('token') - } else { - console.error('No access token found for user', user) - } - } else { - localStorage.removeItem('token') - } }, setUserToken(state, token) { - if (!state.user) return - state.user.accessToken = token - localStorage.setItem('token', token) + if (!token) { + localStorage.removeItem('token') + if (state.user) { + state.user.accessToken = null + } + } else if (state.user) { + state.user.accessToken = token + localStorage.setItem('token', token) + } }, updateMediaProgress(state, { id, data }) { if (!state.user) return From 8775e55762de9494bd66fe413b7072572bd00d8d Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 8 Jul 2025 16:39:50 -0500 Subject: [PATCH 091/182] Update jwt secret handling --- server/Auth.js | 9 +--- server/Server.js | 9 ++-- server/auth/TokenManager.js | 51 +++++++++++++---------- server/controllers/ApiKeyController.js | 4 +- server/models/ApiKey.js | 5 ++- server/objects/settings/ServerSettings.js | 1 + 6 files changed, 39 insertions(+), 40 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 1d229ceb..55eb334a 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -63,13 +63,6 @@ class Auth { return passport.authenticate('jwt', { session: false })(req, res, next) } - /** - * Generate a token which is used to encrpt/protect the jwts. - */ - async initTokenSecret() { - return this.tokenManager.initTokenSecret() - } - /** * Function to generate a jwt token for a given user * TODO: Old method with no expiration @@ -132,7 +125,7 @@ class Auth { new JwtStrategy( { jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), - secretOrKey: Database.serverSettings.tokenSecret, + secretOrKey: TokenManager.TokenSecret, // Handle expiration manaully in order to disable api keys that are expired ignoreExpiration: true }, diff --git a/server/Server.js b/server/Server.js index cf0ad5c7..99e72d0c 100644 --- a/server/Server.js +++ b/server/Server.js @@ -156,14 +156,11 @@ class Server { } await Database.init(false) + // Create or set JWT secret in token manager + await this.auth.tokenManager.initTokenSecret() await Logger.logManager.init() - // Create token secret if does not exist (Added v2.1.0) - if (!Database.serverSettings.tokenSecret) { - await this.auth.initTokenSecret() - } - await this.cleanUserData() // Remove invalid user item progress await CacheManager.ensureCachePaths() @@ -264,7 +261,7 @@ class Server { // enable express-session app.use( expressSession({ - secret: global.ServerSettings.tokenSecret, + secret: this.auth.tokenManager.TokenSecret, resave: false, saveUninitialized: false, cookie: { diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index cc4783b5..3f5cc836 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -7,8 +7,13 @@ const requestIp = require('../libs/requestIp') const jwt = require('../libs/jsonwebtoken') class TokenManager { + /** @type {string} JWT secret key */ + static TokenSecret = null + constructor() { + /** @type {number} Refresh token expiry in seconds */ this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days + /** @type {number} Access token expiry in seconds */ this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { @@ -19,28 +24,28 @@ class TokenManager { } } + get TokenSecret() { + return TokenManager.TokenSecret + } + /** - * Generate a token which is used to encrypt/protect the jwts. + * Token secret is used to sign and verify JWTs + * Set by ENV variable "JWT_SECRET_KEY" or generated and stored on server settings if not set */ async initTokenSecret() { - if (process.env.TOKEN_SECRET) { - // User can supply their own token secret - Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET + if (process.env.JWT_SECRET_KEY) { + // Use user supplied token secret + Logger.info('[TokenManager] JWT secret key set from ENV variable') + TokenManager.TokenSecret = process.env.JWT_SECRET_KEY + } else if (!Database.serverSettings.tokenSecret) { + // Generate new token secret and store it on server settings + Logger.info('[TokenManager] JWT secret key not found, generating one') + TokenManager.TokenSecret = require('crypto').randomBytes(256).toString('base64') + Database.serverSettings.tokenSecret = TokenManager.TokenSecret + await Database.updateServerSettings() } else { - Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') - } - await Database.updateServerSettings() - - // TODO: Old method of non-expiring tokens - // New token secret creation added in v2.1.0 so generate new API tokens for each user - const users = await Database.userModel.findAll({ - attributes: ['id', 'username', 'token'] - }) - if (users.length) { - for (const user of users) { - user.token = this.generateAccessToken(user) - await user.save({ hooks: false }) - } + // Use existing token secret from server settings + TokenManager.TokenSecret = Database.serverSettings.tokenSecret } } @@ -70,7 +75,7 @@ class TokenManager { */ static validateAccessToken(token) { try { - return jwt.verify(token, global.ServerSettings.tokenSecret) + return jwt.verify(token, TokenManager.TokenSecret) } catch (err) { return null } @@ -85,7 +90,7 @@ class TokenManager { * @returns {string} */ generateAccessToken(user) { - return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) + return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret) } /** @@ -104,7 +109,7 @@ class TokenManager { expiresIn: this.AccessTokenExpiry } try { - return jwt.sign(payload, global.ServerSettings.tokenSecret, options) + return jwt.sign(payload, TokenManager.TokenSecret, options) } catch (error) { Logger.error(`[TokenManager] Error generating access token for user ${user.id}: ${error}`) return null @@ -127,7 +132,7 @@ class TokenManager { expiresIn: this.RefreshTokenExpiry } try { - return jwt.sign(payload, global.ServerSettings.tokenSecret, options) + return jwt.sign(payload, TokenManager.TokenSecret, options) } catch (error) { Logger.error(`[TokenManager] Error generating refresh token for user ${user.id}: ${error}`) return null @@ -261,7 +266,7 @@ class TokenManager { async handleRefreshToken(refreshToken, req, res) { try { // Verify the refresh token - const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) + const decoded = jwt.verify(refreshToken, TokenManager.TokenSecret) if (decoded.type !== 'refresh') { Logger.error(`[TokenManager] Failed to refresh token. Invalid token type: ${decoded.type}`) diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js index f60480df..c09d0736 100644 --- a/server/controllers/ApiKeyController.js +++ b/server/controllers/ApiKeyController.js @@ -42,6 +42,8 @@ class ApiKeyController { /** * POST: /api/api-keys * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -69,7 +71,7 @@ class ApiKeyController { } const keyId = uuidv4() // Generate key id ahead of time to use in JWT - const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn) + const apiKey = await Database.apiKeyModel.generateApiKey(this.auth.tokenManager.TokenSecret, keyId, req.body.name, req.body.expiresIn) if (!apiKey) { Logger.error(`[ApiKeyController] create: Error generating API key`) diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js index 7b61f731..7c61611d 100644 --- a/server/models/ApiKey.js +++ b/server/models/ApiKey.js @@ -157,12 +157,13 @@ class ApiKey extends Model { /** * Generate a new api key + * @param {string} tokenSecret * @param {string} keyId * @param {string} name * @param {number} [expiresIn] - Seconds until the api key expires or undefined for no expiration * @returns {Promise} */ - static async generateApiKey(keyId, name, expiresIn) { + static async generateApiKey(tokenSecret, keyId, name, expiresIn) { const options = {} if (expiresIn && !isNaN(expiresIn) && expiresIn > 0) { options.expiresIn = expiresIn @@ -175,7 +176,7 @@ class ApiKey extends Model { name, type: 'api' }, - global.ServerSettings.tokenSecret, + tokenSecret, options, (err, token) => { if (err) { diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 29913e44..4f0aa97b 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -7,6 +7,7 @@ const User = require('../../models/User') class ServerSettings { constructor(settings) { this.id = 'server-settings' + /** @type {string} JWT secret key ONLY used when JWT_SECRET_KEY is not set in ENV */ this.tokenSecret = null // Scanner From df1391d93f6cf2a33b2420b0dc38f6ed40b6bcca Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 9 Jul 2025 13:42:53 -0500 Subject: [PATCH 092/182] Fix scanner after deleting single file books #4459 --- server/scanner/LibraryItemScanner.js | 5 +++++ server/scanner/LibraryScanner.js | 5 +++++ server/utils/fileUtils.js | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/server/scanner/LibraryItemScanner.js b/server/scanner/LibraryItemScanner.js index 133a1e30..501df427 100644 --- a/server/scanner/LibraryItemScanner.js +++ b/server/scanner/LibraryItemScanner.js @@ -206,6 +206,11 @@ class LibraryItemScanner { async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) { const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem) + if (!libraryItemScanData.libraryFiles.length) { + Logger.info(`[LibraryItemScanner] Library item at path "${libraryItemPath}" has no files - ignoring`) + return null + } + const scanLogger = new ScanLogger() scanLogger.verbose = true scanLogger.setData('libraryItem', libraryItemScanData.relPath) diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index bc174d7a..640c82d7 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -606,6 +606,11 @@ class LibraryScanner { } else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) { Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`) continue + } else if (!(await fs.pathExists(fullPath))) { + Logger.info(`[LibraryScanner] File update group "${itemDir}" does not exist - ignoring`) + + itemGroupingResults[itemDir] = ScanResult.NOTHING + continue } // Check if a library item is a subdirectory of this dir diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index f80c4acd..2da6b4c9 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -109,7 +109,7 @@ function getIno(path) { .stat(path, { bigint: true }) .then((data) => String(data.ino)) .catch((err) => { - Logger.error('[Utils] Failed to get ino for path', path, err) + Logger.warn(`[Utils] Failed to get ino for path "${path}"`, err) return null }) } From d9a37506677ac3983a1a488653ac91ac9ad5fc4f Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 28 Jun 2025 20:21:58 +0200 Subject: [PATCH 093/182] Translated using Weblate (German) Currently translated at 99.9% (1106 of 1107 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 04de453e..6ce78103 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -168,7 +168,7 @@ "HeaderNotifications": "Benachrichtigungen", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentifizierung", "HeaderOpenListeningSessions": "Aktive Hörbuch-Sitzungen", - "HeaderOpenRSSFeed": "RSS-Feed öffnen", + "HeaderOpenRSSFeed": "RSS-Feed offen", "HeaderOtherFiles": "Sonstige Dateien", "HeaderPasswordAuthentication": "Passwortauthentifizierung", "HeaderPermissions": "Berechtigungen", From 06535723966a4c04b825a3933df56bdc9c4b163c Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 28 Jun 2025 20:22:48 +0200 Subject: [PATCH 094/182] Translated using Weblate (German) Currently translated at 99.9% (1106 of 1107 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 6ce78103..dbcb42c2 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -168,7 +168,7 @@ "HeaderNotifications": "Benachrichtigungen", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentifizierung", "HeaderOpenListeningSessions": "Aktive Hörbuch-Sitzungen", - "HeaderOpenRSSFeed": "RSS-Feed offen", + "HeaderOpenRSSFeed": "RSS-Feed öffnen", "HeaderOtherFiles": "Sonstige Dateien", "HeaderPasswordAuthentication": "Passwortauthentifizierung", "HeaderPermissions": "Berechtigungen", @@ -514,7 +514,7 @@ "LabelPublishers": "Herausgeber", "LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail", "LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers", - "LabelRSSFeedOpen": "RSS Feed öffnen", + "LabelRSSFeedOpen": "RSS Feed offen", "LabelRSSFeedPreventIndexing": "Indizierung verhindern", "LabelRSSFeedSlug": "RSS-Feed-Schlagwort", "LabelRSSFeedURL": "RSS-Feed-URL", From 7d0def0edbea7dddd5e7e628ae17b9b6e00c62d7 Mon Sep 17 00:00:00 2001 From: Daniel Schosser Date: Tue, 1 Jul 2025 13:34:46 +0200 Subject: [PATCH 095/182] Translated using Weblate (German) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/de.json b/client/strings/de.json index dbcb42c2..c7821bf6 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?", "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?", + "MessageConfirmRemoveEpisodeNote": "Hinweis: Die Audiodatei wird nicht gelöscht, es sei denn \"Datei dauerhaft löschen\" ist aktiviert.", "MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?", "MessageConfirmRemoveMetadataFiles": "Bist du sicher, dass du alle metadata.{0} Dateien in deinen Bibliotheksordnern löschen willst?", From f2e0b9762c8c97ac663b30edf325226cbeaa8551 Mon Sep 17 00:00:00 2001 From: Daniel Schosser Date: Tue, 1 Jul 2025 13:35:38 +0200 Subject: [PATCH 096/182] Translated using Weblate (German) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index c7821bf6..0e62a2a0 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -32,7 +32,7 @@ "ButtonEditChapters": "Kapitel bearbeiten", "ButtonEditPodcast": "Podcast bearbeiten", "ButtonEnable": "Aktivieren", - "ButtonFireAndFail": "Abfeuern und versagen", + "ButtonFireAndFail": "Abschicken und fehlschlagen", "ButtonFireOnTest": "Test-Event abfeuern", "ButtonForceReScan": "Komplett-Scan (alle Medien)", "ButtonFullPath": "Vollständiger Pfad", @@ -757,7 +757,7 @@ "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?", "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?", - "MessageConfirmRemoveEpisodeNote": "Hinweis: Die Audiodatei wird nicht gelöscht, es sei denn \"Datei dauerhaft löschen\" ist aktiviert.", + "MessageConfirmRemoveEpisodeNote": "Hinweis: Die Audiodatei wird nicht gelöscht, es sei denn \"Datei dauerhaft löschen\" ist aktiviert", "MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?", "MessageConfirmRemoveMetadataFiles": "Bist du sicher, dass du alle metadata.{0} Dateien in deinen Bibliotheksordnern löschen willst?", From 794adf0292651dc761b217e4bee1e0ee9853fd09 Mon Sep 17 00:00:00 2001 From: DavevanIersel Date: Tue, 1 Jul 2025 21:10:49 +0200 Subject: [PATCH 097/182] Translated using Weblate (Dutch) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/client/strings/nl.json b/client/strings/nl.json index c20efa12..cefa5c4c 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -24,7 +24,7 @@ "ButtonCloseSession": "Sluit Sessie", "ButtonCollections": "Collecties", "ButtonConfigureScanner": "Configureer scanner", - "ButtonCreate": "Creëer", + "ButtonCreate": "Aanmaken", "ButtonCreateBackup": "Maak back-up", "ButtonDelete": "Verwijder", "ButtonDownloadQueue": "Wachtrij", @@ -43,9 +43,9 @@ "ButtonJumpForward": "Spring vooruit", "ButtonLatest": "Meest recent", "ButtonLibrary": "Bibliotheek", - "ButtonLogout": "Log uit", + "ButtonLogout": "Uitloggen", "ButtonLookup": "Zoeken", - "ButtonManageTracks": "Beheer tracks", + "ButtonManageTracks": "Tracks beheren", "ButtonMapChapterTitles": "Hoofdstuktitels mappen", "ButtonMatchAllAuthors": "Alle auteurs matchen", "ButtonMatchBooks": "Alle boeken matchen", @@ -72,7 +72,7 @@ "ButtonQuickEmbedMetadata": "Snel Metadata Insluiten", "ButtonQuickMatch": "Snelle match", "ButtonReScan": "Nieuwe scan", - "ButtonRead": "Lees", + "ButtonRead": "Lezen", "ButtonReadLess": "Lees minder", "ButtonReadMore": "Lees meer", "ButtonRefresh": "Verversen", @@ -107,7 +107,7 @@ "ButtonUnlinkOpenId": "OpenID Ontkoppelen", "ButtonUpload": "Upload", "ButtonUploadBackup": "Upload back-up", - "ButtonUploadCover": "Upload cover", + "ButtonUploadCover": "Omslag uploaden", "ButtonUploadOPMLFile": "Upload OPML-bestand", "ButtonUserDelete": "Verwijder gebruiker {0}", "ButtonUserEdit": "Wijzig gebruiker {0}", @@ -178,7 +178,7 @@ "HeaderPlaylistItems": "Onderdelen in afspeellijst", "HeaderPodcastsToAdd": "Toe te voegen podcasts", "HeaderPresets": "Voorinstellingen", - "HeaderPreviewCover": "Preview cover", + "HeaderPreviewCover": "Voorbeeld omslag", "HeaderRSSFeedGeneral": "RSS-details", "HeaderRSSFeedIsOpen": "RSS-feed is open", "HeaderRSSFeeds": "RSS-feeds", @@ -285,7 +285,7 @@ "LabelContinueReading": "Verder lezen", "LabelContinueSeries": "Doorgaan met Serie", "LabelCover": "Omslag", - "LabelCoverImageURL": "Coverafbeelding URL", + "LabelCoverImageURL": "Omslagafbeelding-URL", "LabelCoverProvider": "Omslag bron", "LabelCreatedAt": "Gecreëerd op", "LabelCronExpression": "Cron-uitdrukking", @@ -322,7 +322,7 @@ "LabelEmailSettingsSecure": "Veilig", "LabelEmailSettingsSecureHelp": "Als 'waar', dan gebruikt de verbinding TLS om met de server te verbinden. Als 'onwaar', dan wordt TLS gebruikt als de server de STARTTLS-extensie ondersteunt. In de meeste gevallen kies je voor 'waar' verbindt met poort 465. Voo poort 587 of 25, laat op 'onwaar'. (van nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test-adres", - "LabelEmbeddedCover": "Ingesloten cover", + "LabelEmbeddedCover": "Ingesloten omslag", "LabelEnable": "Inschakelen", "LabelEncodingBackupLocation": "Er wordt een back-up van uw originele audiobestanden opgeslagen in:", "LabelEncodingChaptersNotEmbedded": "Hoofdstukken zijn niet ingesloten in audioboeken met meerdere sporen.", @@ -331,7 +331,7 @@ "LabelEncodingInfoEmbedded": "Metagegevens worden ingesloten in de audiotracks in uw audioboekmap.", "LabelEncodingStartedNavigation": "Eenmaal de taak is gestart kan u weg navigeren van deze pagina.", "LabelEncodingTimeWarning": "Encoding kan tot 30 minuten duren.", - "LabelEncodingWarningAdvancedSettings": "Waarschuwing: update deze instellingen niet tenzij u bekend bent met de coderingsopties van ffmpeg.", + "LabelEncodingWarningAdvancedSettings": "Waarschuwing: pas deze instellingen niet aan tenzij u bekend bent met de coderingsopties van ffmpeg.", "LabelEncodingWatcherDisabled": "Als u de watcher hebt uitgeschakeld, moet u het audioboek daarna opnieuw scannen.", "LabelEnd": "Einde", "LabelEndOfChapter": "Einde van het Hoofdstuk", @@ -373,7 +373,7 @@ "LabelFull": "Vol", "LabelGenre": "Genre", "LabelGenres": "Genres", - "LabelHardDeleteFile": "Hard-delete bestand", + "LabelHardDeleteFile": "Bestand permanent verwijderen", "LabelHasEbook": "Heeft Ebook", "LabelHasSupplementaryEbook": "Heeft aanvullend Ebook", "LabelHideSubtitles": "Ondertitels Verstoppen", @@ -407,7 +407,7 @@ "LabelLastBookUpdated": "Laatst bijgewerkte boek", "LabelLastSeen": "Laatst gezien", "LabelLastTime": "Laatste keer", - "LabelLastUpdate": "Laatste update", + "LabelLastUpdate": "Laatste wijziging", "LabelLayout": "Layout", "LabelLayoutSinglePage": "Enkele pagina", "LabelLayoutSplitPage": "Gesplitste pagina", @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?", "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?", "MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?", + "MessageConfirmRemoveEpisodeNote": "Let op: Het audiobestand wordt niet verwijderd, tenzij je ‘Bestand permanent verwijderen’ inschakelt", "MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?", "MessageConfirmRemoveListeningSessions": "Weet je zeker dat je {0} luistersessies wilt verwijderen?", "MessageConfirmRemoveMetadataFiles": "Bent u zeker dat u alle metadata wil verwijderen. {0} bestanden in uw bibliotheel item folders?", From 8255e4308cf3c64fc3fbf1ae7392993e33bd8599 Mon Sep 17 00:00:00 2001 From: DavevanIersel Date: Tue, 1 Jul 2025 21:36:06 +0200 Subject: [PATCH 098/182] Translated using Weblate (Dutch) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/client/strings/nl.json b/client/strings/nl.json index cefa5c4c..adac094c 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -322,7 +322,7 @@ "LabelEmailSettingsSecure": "Veilig", "LabelEmailSettingsSecureHelp": "Als 'waar', dan gebruikt de verbinding TLS om met de server te verbinden. Als 'onwaar', dan wordt TLS gebruikt als de server de STARTTLS-extensie ondersteunt. In de meeste gevallen kies je voor 'waar' verbindt met poort 465. Voo poort 587 of 25, laat op 'onwaar'. (van nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test-adres", - "LabelEmbeddedCover": "Ingesloten omslag", + "LabelEmbeddedCover": "Omslag in bestand", "LabelEnable": "Inschakelen", "LabelEncodingBackupLocation": "Er wordt een back-up van uw originele audiobestanden opgeslagen in:", "LabelEncodingChaptersNotEmbedded": "Hoofdstukken zijn niet ingesloten in audioboeken met meerdere sporen.", @@ -532,7 +532,7 @@ "LabelRemoveAllMetadataAbs": "Verwijder alle metadata.abs bestanden", "LabelRemoveAllMetadataJson": "Verwijder alle metadata.json bestanden", "LabelRemoveAudibleBranding": "Verwijder Audible intro en outro uit hoofdstukken", - "LabelRemoveCover": "Verwijder cover", + "LabelRemoveCover": "Omslag verwijderen", "LabelRemoveMetadataFile": "Verwijder metadata bestanden in bibliotheek item folders", "LabelRemoveMetadataFileHelp": "Verwijder alle metadata.json en metadata.abs bestanden in uw {0} folders.", "LabelRowsPerPage": "Rijen per pagina", @@ -568,8 +568,8 @@ "LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.", "LabelSettingsExperimentalFeatures": "Experimentele functies", "LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.", - "LabelSettingsFindCovers": "Zoek covers", - "LabelSettingsFindCoversHelp": "Als je audioboek geen ingesloten cover of cover in de map heeft, zal de scanner proberen een cover te vinden.
Opmerking: Dit zal de scan-duur verlengen", + "LabelSettingsFindCovers": "Omslagen zoeken", + "LabelSettingsFindCoversHelp": "Als je audioboek geen omslag in het bestand of in de map heeft, zal de scanner automatisch proberen een omslag te vinden.
Opmerking: Dit kan de scantijd verlengen", "LabelSettingsHideSingleBookSeries": "Verberg series met een enkel boek", "LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.", "LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina", @@ -581,16 +581,16 @@ "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.", "LabelSettingsParseSubtitles": "Parseer subtitel", "LabelSettingsParseSubtitlesHelp": "Haal subtitels uit mapnaam van audioboek.
Subtitel moet gescheiden zijn met \" - \"
b.v. \"Boektitel - Een Subtitel Hier\" heeft als subtitel \"Een Subtitel Hier\"", - "LabelSettingsPreferMatchedMetadata": "Prefereer gematchte metadata", + "LabelSettingsPreferMatchedMetadata": "Geef voorkeur aan gematchte metadata", "LabelSettingsPreferMatchedMetadataHelp": "Gematchte data zal onderdeeldetails overschrijven bij gebruik van Quick Match. Standaard vult Quick Match uitsluitend ontbrekende details aan.", "LabelSettingsSkipMatchingBooksWithASIN": "Sla matchen van boeken over die al over een ASIN beschikken", "LabelSettingsSkipMatchingBooksWithISBN": "Sla matchen van boeken over die al over een ISBN beschikken", "LabelSettingsSortingIgnorePrefixes": "Negeer voorvoegsels bij sorteren", "LabelSettingsSortingIgnorePrefixesHelp": "b.v. voor voorvoegsel \"The\" wordt titel \"The Title\" dan gesorteerd als \"Title, The\"", - "LabelSettingsSquareBookCovers": "Gebruik vierkante boekcovers", - "LabelSettingsSquareBookCoversHelp": "Prefereer gebruik van vierkante covers boven standaard 1.6:1 boekcovers", - "LabelSettingsStoreCoversWithItem": "Bewaar covers bij onderdeel", - "LabelSettingsStoreCoversWithItemHelp": "Standaard worden covers bewaard in /metadata/items, door deze instelling in te schakelen zullen covers in de map van je bibliotheekonderdeel bewaard worden. Slechts een bestand genaamd \"cover\" zal worden bewaard", + "LabelSettingsSquareBookCovers": "Gebruik vierkante boekomslagen", + "LabelSettingsSquareBookCoversHelp": "Gebruik vierkante boekomslagen in plaats van standaard 1,6:1", + "LabelSettingsStoreCoversWithItem": "Bewaar omslagen bij onderdeel", + "LabelSettingsStoreCoversWithItemHelp": "Omslagen worden standaard in /metadata/items opgeslagen. Bij inschakelen worden ze in de map van het bibliotheekitem zelf opgeslagen. Slechts een bestand genaamd \"cover\" zal worden bewaard", "LabelSettingsStoreMetadataWithItem": "Bewaar metadata bij onderdeel", "LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden", "LabelSettingsTimeFormat": "Tijdformat", @@ -652,12 +652,12 @@ "LabelTimeToShift": "Tijd op te schuiven in seconden", "LabelTitle": "Titel", "LabelToolsEmbedMetadata": "Metadata insluiten", - "LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief coverafbeelding en hoofdstukken.", + "LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief omslagafbeelding en hoofdstukken.", "LabelToolsM4bEncoder": "M4B Encoder", "LabelToolsMakeM4b": "Maak M4B-audioboekbestand", - "LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, coverafbeelding en hoofdstukken.", + "LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, omslagafbeelding en hoofdstukken.", "LabelToolsSplitM4b": "Splitst M4B in MP3's", - "LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplitst per hoofdstuk met ingesloten metadata, coverafbeelding en hoofdstukken.", + "LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplitst per hoofdstuk met ingesloten metadata, omslagafbeelding en hoofdstukken.", "LabelTotalDuration": "Totale duur", "LabelTotalTimeListened": "Totale tijd geluisterd", "LabelTrackFromFilename": "Track vanuit bestandsnaam", @@ -672,8 +672,8 @@ "LabelUndo": "Ongedaan maken", "LabelUnknown": "Onbekend", "LabelUnknownPublishDate": "Onbekende uitgeefdatum", - "LabelUpdateCover": "Cover bijwerken", - "LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden", + "LabelUpdateCover": "Omslag bijwerken", + "LabelUpdateCoverHelp": "Sta overschrijven van bestaande omslagen toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdateDetails": "Details bijwerken", "LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdatedAt": "Bijgewerkt op", @@ -715,7 +715,7 @@ "MessageBackupsLocationPathEmpty": "Backup locatie pad kan niet leeg zijn", "MessageBatchEditPopulateMapDetailsAllHelp": "Vul actieve velden in met data van alle items. Velden met meerdere waarden zullen worden samengevoegd", "MessageBatchEditPopulateMapDetailsItemHelp": "Vul actieve folder detail velden met de data van dit item", - "MessageBatchQuickMatchDescription": "Quick Match zal proberen ontbrekende covers en metadata voor de geselecteerde onderdelen te matchten. Schakel de opties hieronder in om Quick Match toe te staan bestaande covers en/of metadata te overschrijven.", + "MessageBatchQuickMatchDescription": "Quick Match probeert ontbrekende omslagen en metadata toe te voegen aan de geselecteerde items. Schakel de opties hieronder in om Quick Match bestaande omslagen en/of metadata te laten overschrijven.", "MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt", "MessageBookshelfNoCollectionsHelp": "Collecties zijn publiekelijk. Alle gebruikers met toegang tot de bibliotheek kunnen ze zien.", "MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend", @@ -799,14 +799,14 @@ "MessageMarkAllEpisodesNotFinished": "Markeer alle afleveringen als niet voltooid", "MessageMarkAsFinished": "Markeer als Voltooid", "MessageMarkAsNotFinished": "Markeer als Niet Voltooid", - "MessageMatchBooksDescription": "zal proberen boeken in de bibliotheek te matchen met een boek uit de geselecteerde bron en lege details en coverafbeelding te vullen. Overschrijft details niet.", + "MessageMatchBooksDescription": "zal proberen boeken in de bibliotheek te koppelen aan een boek uit de geselecteerde bron en ontbrekende gegevens en een omslag toe te voegen. Overschrijft geen bestaande gegevens.", "MessageNoAudioTracks": "Geen audiotracks", "MessageNoAuthors": "Geen auteurs", "MessageNoBackups": "Geen back-ups", "MessageNoBookmarks": "Geen boekwijzers", "MessageNoChapters": "Geen hoofdstukken", "MessageNoCollections": "Geen collecties", - "MessageNoCoversFound": "Geen covers gevonden", + "MessageNoCoversFound": "Geen omslagen gevonden", "MessageNoDescription": "Geen beschrijving", "MessageNoDevices": "Geen Apparaten", "MessageNoDownloadsInProgress": "Geen downloads bezig op dit moment", @@ -844,7 +844,7 @@ "MessageQuickEmbedInProgress": "Snelle inbedding in uitvoering", "MessageQuickEmbedQueue": "In de wachtrij voor snelle insluiting ({0} in wachtrij)", "MessageQuickMatchAllEpisodes": "Alle Afleveringen Snel Matchen", - "MessageQuickMatchDescription": "Vul lege onderdeeldetails & cover met eerste matchresultaat van '{0}'. Overschrijft geen details tenzij 'Prefereer gematchte metadata' serverinstelling is ingeschakeld.", + "MessageQuickMatchDescription": "Vult ontbrekende gegevens & omslag met eerste matchresultaat van '{0}'. Overschrijft gegevens alleen als de serverinstelling ‘Geef voorkeur aan gematchte metadata’ is ingeschakeld.", "MessageRemoveChapter": "Verwijder hoofdstuk", "MessageRemoveEpisodes": "Verwijder {0} aflevering(en)", "MessageRemoveFromPlayerQueue": "Verwijder uit afspeelwachtrij", @@ -852,7 +852,7 @@ "MessageReportBugsAndContribute": "Rapporteer bugs, vraag functionaliteiten aan en draag bij op", "MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?", "MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op", - "MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.

Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.

Alle clients die van je server gebruik maken zullen automatisch worden ververst.", + "MessageRestoreBackupWarning": "Een back-up herstellen zal de volledige database in /config en de omslagen in /metadata/items & /metadata/authors overschrijven.

Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om omslagen en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.

Alle apparaten die je server gebruiken, worden automatisch ververst.", "MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het raadzaam om deze functie uitgeschakeld te laten en de folder watcher-instelling ingeschakeld te houden. De folder watcher detecteert automatisch wijzigingen in uw bibliotheekmappen. De folder watcher werkt niet voor elk bestandssysteem (zoals NFS), dus geplande bibliotheekscans kunnen in plaats daarvan worden gebruikt.", "MessageScheduleRunEveryWeekdayAtTime": "Elke {0} uitvoeren op {1}", "MessageSearchResultsFor": "Zoekresultaten voor", @@ -986,7 +986,7 @@ "ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt", "ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt", - "ToastCoverUpdateFailed": "Cover update mislukt", + "ToastCoverUpdateFailed": "Omslag bijwerken mislukt", "ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig", "ToastDeleteFileFailed": "Bestand verwijderen mislukt", "ToastDeleteFileSuccess": "Bestand verwijderd", @@ -1008,7 +1008,7 @@ "ToastInvalidImageUrl": "Ongeldige afbeeldings-URL", "ToastInvalidMaxEpisodesToDownload": "Ongeldig maximum aantal afleveringen om te downloaden", "ToastInvalidUrl": "Ongeldige URL", - "ToastItemCoverUpdateSuccess": "Cover onderdeel bijgewerkt", + "ToastItemCoverUpdateSuccess": "Omslag bijgewerkt", "ToastItemDeletedFailed": "Item verwijderen mislukt", "ToastItemDeletedSuccess": "Verwijderd item", "ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt", From 6d2b1df5602ed055ac381e1e142d961b1def4af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Tue, 1 Jul 2025 18:21:01 +0200 Subject: [PATCH 099/182] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index 70cb4bb3..18d694ec 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -44,7 +44,7 @@ "ButtonLatest": "Останні", "ButtonLibrary": "Бібліотека", "ButtonLogout": "Вийти", - "ButtonLookup": "Пошук", + "ButtonLookup": "Пошуки", "ButtonManageTracks": "Керувати доріжками", "ButtonMapChapterTitles": "Призначити назви глав", "ButtonMatchAllAuthors": "Віднайти усіх авторів", @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Ви дійсно бажаєте видалити автора \"{0}\"?", "MessageConfirmRemoveCollection": "Ви дійсно бажаєте видалити добірку \"{0}\"?", "MessageConfirmRemoveEpisode": "Ви дійсно бажаєте видалити епізод \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "Примітка: Це не видаляє аудіофайл, якщо не перемикає \"файл жорсткого видалення\"", "MessageConfirmRemoveEpisodes": "Ви дійсно бажаєте видалити епізодів: {0}?", "MessageConfirmRemoveListeningSessions": "Ви дійсно бажаєте видалити сеанси прослуховування: {0}?", "MessageConfirmRemoveMetadataFiles": "Ви впевнені, що хочете видалити всі файли metadata.{0} у папках елементів вашої бібліотеки?", @@ -818,7 +819,7 @@ "MessageNoItems": "Елементи відсутні", "MessageNoItemsFound": "Елементів не знайдено", "MessageNoListeningSessions": "Сеанси прослуховування відсутні", - "MessageNoLogs": "Немає журналів", + "MessageNoLogs": "Немає журнали", "MessageNoMediaProgress": "Прогрес відсутній", "MessageNoNotifications": "Сповіщення відсутні", "MessageNoPodcastFeed": "Некоректний подкаст: немає каналу", From 576d7dc0249ad34ed8a0a883d1e2c20abbe598de Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Tue, 1 Jul 2025 07:45:41 +0200 Subject: [PATCH 100/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.9% (1107 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 0b6c3422..84803c4b 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -613,12 +613,12 @@ "LabelStartedAt": "从这开始", "LabelStatsAudioTracks": "音轨", "LabelStatsAuthors": "作者", - "LabelStatsBestDay": "最好的一天", + "LabelStatsBestDay": "单日听书纪录", "LabelStatsDailyAverage": "每日平均值", - "LabelStatsDays": "天", + "LabelStatsDays": "连续收听", "LabelStatsDaysListened": "收听天数", "LabelStatsHours": "小时", - "LabelStatsInARow": "在一行", + "LabelStatsInARow": "天", "LabelStatsItemsFinished": "已完成的项目", "LabelStatsItemsInLibrary": "媒体库中的项目", "LabelStatsMinutes": "分钟", From d41276ba8caa7eced1b65f3c263825990520674d Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Tue, 1 Jul 2025 07:53:54 +0200 Subject: [PATCH 101/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.9% (1107 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 84803c4b..4e9aa8b6 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -613,7 +613,7 @@ "LabelStartedAt": "从这开始", "LabelStatsAudioTracks": "音轨", "LabelStatsAuthors": "作者", - "LabelStatsBestDay": "单日听书纪录", + "LabelStatsBestDay": "单日最高", "LabelStatsDailyAverage": "每日平均值", "LabelStatsDays": "连续收听", "LabelStatsDaysListened": "收听天数", From 84b77f4c7f067c0ce2bdf9337194466f3f968ed0 Mon Sep 17 00:00:00 2001 From: DavevanIersel Date: Thu, 3 Jul 2025 21:43:39 +0200 Subject: [PATCH 102/182] Translated using Weblate (Dutch) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/nl.json b/client/strings/nl.json index adac094c..c5c14827 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -560,7 +560,7 @@ "LabelSettingsAudiobooksOnlyHelp": "Deze instelling inschakelen zorgt ervoor dat ebook-bestanden genegeerd worden tenzij ze in een audiobook-map staan, in welk geval ze worden ingesteld als supplementaire ebooks", "LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken", "LabelSettingsChromecastSupport": "Chromecast ondersteuning", - "LabelSettingsDateFormat": "Datum format", + "LabelSettingsDateFormat": "Datumnotatie", "LabelSettingsEnableWatcher": "Bibliotheken automatisch scannen op wijzigingen", "LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch scannen op wijzigingen", "LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server", @@ -579,7 +579,7 @@ "LabelSettingsLibraryMarkAsFinishedWhen": "Markeer media item wanneer voltooid", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sla eedere boeken in Serie Verderzetten over", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.", - "LabelSettingsParseSubtitles": "Parseer subtitel", + "LabelSettingsParseSubtitles": "Subtitel afleiden uit foldernaam", "LabelSettingsParseSubtitlesHelp": "Haal subtitels uit mapnaam van audioboek.
Subtitel moet gescheiden zijn met \" - \"
b.v. \"Boektitel - Een Subtitel Hier\" heeft als subtitel \"Een Subtitel Hier\"", "LabelSettingsPreferMatchedMetadata": "Geef voorkeur aan gematchte metadata", "LabelSettingsPreferMatchedMetadataHelp": "Gematchte data zal onderdeeldetails overschrijven bij gebruik van Quick Match. Standaard vult Quick Match uitsluitend ontbrekende details aan.", From f02453ac92da61e9cf98567f6d16d51141a7b177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Fri, 4 Jul 2025 18:20:20 +0200 Subject: [PATCH 103/182] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index 18d694ec..4655681d 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -346,7 +346,7 @@ "LabelExample": "Приклад", "LabelExpandSeries": "Розгорнути серії", "LabelExpandSubSeries": "Розгорнути підсерії", - "LabelExplicit": "Відверта", + "LabelExplicit": "Відвертий", "LabelExplicitChecked": "Відверта (з прапорцем)", "LabelExplicitUnchecked": "Не відверта (без прапорця)", "LabelExportOPML": "Експорт OPML", From 018ca8e7ee4016e70686fca3bdc89b5e4a4247e4 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 4 Jul 2025 11:12:31 +0200 Subject: [PATCH 104/182] Translated using Weblate (Slovak) Currently translated at 99.9% (1107 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sk/ --- client/strings/sk.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/strings/sk.json b/client/strings/sk.json index 39b3c0ae..80170dfc 100644 --- a/client/strings/sk.json +++ b/client/strings/sk.json @@ -346,7 +346,7 @@ "LabelExample": "Príklad", "LabelExpandSeries": "Rozbaliť série", "LabelExpandSubSeries": "Rozbaliť podsérie", - "LabelExplicit": "Explicitné", + "LabelExplicit": "Explicitný obsah", "LabelExplicitChecked": "Explicitné (zaškrtnuté)", "LabelExplicitUnchecked": "Ne-explicitné (nezaškrtnuté)", "LabelExportOPML": "Exportovať OPML", @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Ste si istý, že chcete odstrániť autora \"{0}\"?", "MessageConfirmRemoveCollection": "Ste si istý, že chcete odstrániť zbierku \"{0}\"?", "MessageConfirmRemoveEpisode": "Ste si istý, že chcete odstrániť epizódu \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "Poznámka: Tento krok neodstráni zvukový súbor, pokiaľ nezaškrtnete voľbu \"Nezvratné zmazanie súborov\"", "MessageConfirmRemoveEpisodes": "Ste si istý, že chcete odstrániť {0} epizód?", "MessageConfirmRemoveListeningSessions": "Ste si istý, že chcete odstrániť týchto {0} relácií?", "MessageConfirmRemoveMetadataFiles": "Ste si istý, že chcete odstrániť všetky súbory metadata.{0} z priečinkov položiek vašej knižnice?", @@ -918,6 +919,8 @@ "NotificationOnBackupCompletedDescription": "Spustené po dokončení zálohovania", "NotificationOnBackupFailedDescription": "Spustené pri zlyhaní zálohovania", "NotificationOnEpisodeDownloadedDescription": "Spustené po automatickom stiahnutí epizódy podcastu", + "NotificationOnRSSFeedDisabledDescription": "Spustí sa, keď je automatické sťahovanie epizód pozastavené z dôvodu veľkého počtu zlyhaní", + "NotificationOnRSSFeedFailedDescription": "Spustí sa v prípade, keď zlyhá požiadavka RSS zdroja na automatické stiahnutie epizódy", "NotificationOnTestDescription": "Udalosť určená na testovanie systému notifikácií", "PlaceholderNewCollection": "Názov novej zbierky", "PlaceholderNewFolderPath": "Umiestnenie nového priečinka", From 85446be0e551f9b86f7f4add252479f9284fa17d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Po=C5=BEgay?= Date: Sun, 6 Jul 2025 02:54:16 +0200 Subject: [PATCH 105/182] Translated using Weblate (Czech) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/strings/cs.json b/client/strings/cs.json index 3ee9b087..d61f91a7 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -346,11 +346,11 @@ "LabelExample": "Příklad", "LabelExpandSeries": "Rozbalit série", "LabelExpandSubSeries": "Rozbalit podsérie", - "LabelExplicit": "Explicitní", + "LabelExplicit": "Explicitně", "LabelExplicitChecked": "Explicitní (zaškrtnuto)", "LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)", "LabelExportOPML": "Export OPML", - "LabelFeedURL": "URL zdroje", + "LabelFeedURL": "URL kanálu", "LabelFetchingMetadata": "Získávání metadat", "LabelFile": "Soubor", "LabelFileBirthtime": "Čas vzniku souboru", @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?", "MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?", "MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "Poznámka: Tím se zvukový soubor neodstraní, pokud nepřepnete volbu “Tvrdé odstranění souboru„“.", "MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?", "MessageConfirmRemoveListeningSessions": "Opravdu chcete odebrat {0} poslechových relací?", "MessageConfirmRemoveMetadataFiles": "Jste si jisti, že chcete odstranit všechny metadata.{0} soubory ve složkách s položkami ve vaší knihovně?", From 62afa3c3ee1bd5a2e6f0e09c056f1b91aec38eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Po=C5=BEgay?= Date: Sun, 6 Jul 2025 02:54:53 +0200 Subject: [PATCH 106/182] Translated using Weblate (Czech) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/cs.json b/client/strings/cs.json index d61f91a7..7da79857 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -757,7 +757,7 @@ "MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?", "MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?", "MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?", - "MessageConfirmRemoveEpisodeNote": "Poznámka: Tím se zvukový soubor neodstraní, pokud nepřepnete volbu “Tvrdé odstranění souboru„“.", + "MessageConfirmRemoveEpisodeNote": "Poznámka: Tím se zvukový soubor neodstraní, pokud nepřepnete volbu “Tvrdé odstranění souboru“", "MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?", "MessageConfirmRemoveListeningSessions": "Opravdu chcete odebrat {0} poslechových relací?", "MessageConfirmRemoveMetadataFiles": "Jste si jisti, že chcete odstranit všechny metadata.{0} soubory ve složkách s položkami ve vaší knihovně?", From d09a21d92266d66c2d6de7903ebff269b76a3d8f Mon Sep 17 00:00:00 2001 From: Raj Date: Mon, 7 Jul 2025 04:07:50 +0200 Subject: [PATCH 107/182] Translated using Weblate (Gujarati) Currently translated at 16.6% (184 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/gu/ --- client/strings/gu.json | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/client/strings/gu.json b/client/strings/gu.json index f0eee434..301d673b 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -9,6 +9,9 @@ "ButtonApply": "લાગુ કરો", "ButtonApplyChapters": "પ્રકરણો લાગુ કરો", "ButtonAuthors": "લેખકો", + "ButtonBack": "પાછા", + "ButtonBatchEditPopulateFromExisting": "હાલની માહિતીમાંથી ભરો", + "ButtonBatchEditPopulateMapDetails": "નકશાની વિગત ભરો", "ButtonBrowseForFolder": "ફોલ્ડર માટે જુઓ", "ButtonCancel": "રદ કરો", "ButtonCancelEncode": "એન્કોડ રદ કરો", @@ -27,11 +30,14 @@ "ButtonEdit": "સંપાદિત કરો", "ButtonEditChapters": "પ્રકરણો સંપાદિત કરો", "ButtonEditPodcast": "પોડકાસ્ટ સંપાદિત કરો", + "ButtonEnable": "સક્રિય કરો", "ButtonForceReScan": "બળપૂર્વક ફરીથી સ્કેન કરો", "ButtonFullPath": "સંપૂર્ણ પથ", "ButtonHide": "છુપાવો", "ButtonHome": "ઘર", "ButtonIssues": "સમસ્યાઓ", + "ButtonJumpBackward": "પાછળ જાવો", + "ButtonJumpForward": "આગળ જાવો", "ButtonLatest": "નવીનતમ", "ButtonLibrary": "પુસ્તકાલય", "ButtonLogout": "લૉગ આઉટ", @@ -41,19 +47,32 @@ "ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો", "ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો", "ButtonNevermind": "કંઈ વાંધો નહીં", + "ButtonNext": "આગળ જાઓ", + "ButtonNextChapter": "આગળનું અધ્યાય", + "ButtonNextItemInQueue": "કતારમાં આવતું આગળનું અધ્યાય", "ButtonOk": "ઓકે", "ButtonOpenFeed": "ફીડ ખોલો", "ButtonOpenManager": "મેનેજર ખોલો", + "ButtonPause": "વિરામ", "ButtonPlay": "ચલાવો", + "ButtonPlayAll": "બધું ચલાવો", "ButtonPlaying": "ચલાવી રહ્યું છે", "ButtonPlaylists": "પ્લેલિસ્ટ", + "ButtonPrevious": "પાછળનું", + "ButtonPreviousChapter": "પાછળનું અધ્યાય", + "ButtonProbeAudioFile": "ઑડિયો ફાઇલ તપાસો", "ButtonPurgeAllCache": "બધો Cache કાઢી નાખો", "ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો", "ButtonQueueAddItem": "કતારમાં ઉમેરો", "ButtonQueueRemoveItem": "કતારથી કાઢી નાખો", + "ButtonQuickEmbed": "ઝડપથી સમાવેશ કરો", + "ButtonQuickEmbedMetadata": "ઝડપથી મેટાડેટા સમાવવો", "ButtonQuickMatch": "ઝડપી મેળ ખવડાવો", "ButtonReScan": "ફરીથી સ્કેન કરો", "ButtonRead": "વાંચો", + "ButtonReadLess": "ઓછું વાંચો", + "ButtonReadMore": "વધારે વાંચો", + "ButtonRefresh": "તાજું કરો", "ButtonRemove": "કાઢી નાખો", "ButtonRemoveAll": "બધું કાઢી નાખો", "ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો", @@ -68,16 +87,21 @@ "ButtonSaveTracklist": "ટ્રેક યાદી સાચવો", "ButtonScan": "સ્કેન કરો", "ButtonScanLibrary": "પુસ્તકાલય સ્કેન કરો", + "ButtonScrollLeft": "ડાબે", + "ButtonScrollRight": "જમણે", "ButtonSearch": "શોધો", "ButtonSelectFolderPath": "ફોલ્ડર પથ પસંદ કરો", "ButtonSeries": "સિરીઝ", "ButtonSetChaptersFromTracks": "ટ્રેક્સથી પ્રકરણો સેટ કરો", + "ButtonShare": "શેર કરો", "ButtonShiftTimes": "સમય શિફ્ટ કરો", "ButtonShow": "બતાવો", "ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો", "ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો", + "ButtonStats": "આંકડા", "ButtonSubmit": "સબમિટ કરો", "ButtonTest": "પરખ કરો", + "ButtonUnlinkOpenId": "OpenID દૂર કરો", "ButtonUpload": "અપલોડ કરો", "ButtonUploadBackup": "બેકઅપ અપલોડ કરો", "ButtonUploadCover": "કવર અપલોડ કરો", @@ -86,11 +110,16 @@ "ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો", "ButtonViewAll": "બધું જુઓ", "ButtonYes": "હા", + "ErrorUploadFetchMetadataAPI": "મેટાડેટા મેળવવામાં તકલીફ આવી", + "ErrorUploadFetchMetadataNoResults": "મેટાડેટા મેળવી શક્યા નહીં – કૃપા કરીને શીર્ષક અને/અથવા લેખકનું નામ અપડેટ કરવાનો પ્રયત્ન કરો", + "ErrorUploadLacksTitle": "શીર્ષક હોવું આવશ્યક છે", "HeaderAccount": "એકાઉન્ટ", + "HeaderAddCustomMetadataProvider": "કસ્ટમ મેટાડેટા પ્રોવાઇડર ઉમેરો", "HeaderAdvanced": "અડ્વાન્સડ", "HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ", "HeaderAudioTracks": "ઓડિયો ટ્રેક્સ", "HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ", + "HeaderAuthentication": "પ્રમાણીકરણ", "HeaderBackups": "બેકઅપ્સ", "HeaderChangePassword": "પાસવર્ડ બદલો", "HeaderChapters": "પ્રકરણો", @@ -99,6 +128,7 @@ "HeaderCollectionItems": "સંગ્રહ વસ્તુઓ", "HeaderCover": "આવરણ", "HeaderCurrentDownloads": "વર્તમાન ડાઉનલોડ્સ", + "HeaderCustomMetadataProviders": "કસ્ટમ મેટાડેટા પ્રોવાઇડર્સ", "HeaderDetails": "વિગતો", "HeaderDownloadQueue": "ડાઉનલોડ કતાર", "HeaderEbookFiles": "ઇબુક ફાઇલો", @@ -129,6 +159,7 @@ "HeaderMetadataToEmbed": "એમ્બેડ કરવા માટે મેટાડેટા", "HeaderNewAccount": "નવું એકાઉન્ટ", "HeaderNewLibrary": "નવી પુસ્તકાલય", + "HeaderNotificationCreate": "સૂચના બનાવો", "HeaderNotifications": "સૂચનાઓ", "HeaderOpenRSSFeed": "RSS ફીડ ખોલો", "HeaderOtherFiles": "અન્ય ફાઇલો", From f1153f9da535eb0a56167819abf72c0bd20a37de Mon Sep 17 00:00:00 2001 From: SunSpring Date: Mon, 7 Jul 2025 06:15:34 +0200 Subject: [PATCH 108/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 4e9aa8b6..1b8a5cd3 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -346,7 +346,7 @@ "LabelExample": "示例", "LabelExpandSeries": "展开系列", "LabelExpandSubSeries": "展开子系列", - "LabelExplicit": "信息准确", + "LabelExplicit": "信息明确", "LabelExplicitChecked": "明确(已选中)", "LabelExplicitUnchecked": "不明确 (未选中)", "LabelExportOPML": "导出 OPML", @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?", "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", "MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "注意: 除非切换 \"硬删除文件\", 否则不会删除音频文件", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveListeningSessions": "你确定要移除 {0} 收听会话吗?", "MessageConfirmRemoveMetadataFiles": "你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?", From 448cbf8530efb9363878146cc653c4f6e69b9e3d Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Mon, 7 Jul 2025 05:11:07 +0200 Subject: [PATCH 109/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 1b8a5cd3..b4acc925 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -354,7 +354,7 @@ "LabelFetchingMetadata": "正在获取元数据", "LabelFile": "文件", "LabelFileBirthtime": "文件创建时间", - "LabelFileBornDate": "生于 {0}", + "LabelFileBornDate": "添加于 {0}", "LabelFileModified": "文件修改时间", "LabelFileModifiedDate": "已修改 {0}", "LabelFilename": "文件名", From 27e40d16fd43299142b3ec37d857ec4cd4843788 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Mon, 7 Jul 2025 06:16:51 +0200 Subject: [PATCH 110/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index b4acc925..cb10cc43 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -346,7 +346,7 @@ "LabelExample": "示例", "LabelExpandSeries": "展开系列", "LabelExpandSubSeries": "展开子系列", - "LabelExplicit": "信息明确", + "LabelExplicit": "信息准确", "LabelExplicitChecked": "明确(已选中)", "LabelExplicitUnchecked": "不明确 (未选中)", "LabelExportOPML": "导出 OPML", From fba70c98314bc7c317c742520392075c6bca458c Mon Sep 17 00:00:00 2001 From: thehijacker Date: Wed, 9 Jul 2025 11:22:27 +0200 Subject: [PATCH 111/182] Translated using Weblate (Slovenian) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index 81e80003..0dd5a6ce 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Ali ste prepričani, da želite odstraniti avtorja \"{0}\"?", "MessageConfirmRemoveCollection": "Ali ste prepričani, da želite odstraniti zbirko \"{0}\"?", "MessageConfirmRemoveEpisode": "Ali ste prepričani, da želite odstraniti epizodo \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "Opomba: S tem se zvočna datoteka ne izbriše, razen če vklopite možnost \"Trdo brisanje datoteke\".", "MessageConfirmRemoveEpisodes": "Ali ste prepričani, da želite odstraniti {0} epizod?", "MessageConfirmRemoveListeningSessions": "Ali ste prepričani, da želite odstraniti {0} sej poslušanja?", "MessageConfirmRemoveMetadataFiles": "Ali ste prepričani, da želite odstraniti vse metapodatke.{0} v mapah elementov knjižnice?", From 3c8876a37d9ccc17706538caf9907d43183052bf Mon Sep 17 00:00:00 2001 From: thehijacker Date: Wed, 9 Jul 2025 11:22:35 +0200 Subject: [PATCH 112/182] Translated using Weblate (Slovenian) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index 0dd5a6ce..5c3c7d4f 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -757,7 +757,7 @@ "MessageConfirmRemoveAuthor": "Ali ste prepričani, da želite odstraniti avtorja \"{0}\"?", "MessageConfirmRemoveCollection": "Ali ste prepričani, da želite odstraniti zbirko \"{0}\"?", "MessageConfirmRemoveEpisode": "Ali ste prepričani, da želite odstraniti epizodo \"{0}\"?", - "MessageConfirmRemoveEpisodeNote": "Opomba: S tem se zvočna datoteka ne izbriše, razen če vklopite možnost \"Trdo brisanje datoteke\".", + "MessageConfirmRemoveEpisodeNote": "Opomba: S tem se zvočna datoteka ne izbriše, razen če vklopite možnost \"Trdo brisanje datoteke\"", "MessageConfirmRemoveEpisodes": "Ali ste prepričani, da želite odstraniti {0} epizod?", "MessageConfirmRemoveListeningSessions": "Ali ste prepričani, da želite odstraniti {0} sej poslušanja?", "MessageConfirmRemoveMetadataFiles": "Ali ste prepričani, da želite odstraniti vse metapodatke.{0} v mapah elementov knjižnice?", From 25fe4dee3a0aa2ab7cb46f018505bbddd3695b56 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 9 Jul 2025 17:03:10 -0500 Subject: [PATCH 113/182] Update epub reader to use axios for handling refresh tokens --- client/components/readers/EpubReader.vue | 66 ++++++++++++++---------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 350d8596..795fcd2b 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -97,9 +97,9 @@ export default { }, ebookUrl() { if (this.fileId) { - return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}` + return `/api/items/${this.libraryItemId}/ebook/${this.fileId}` } - return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook` + return `/api/items/${this.libraryItemId}/ebook` }, themeRules() { const isDark = this.ereaderSettings.theme === 'dark' @@ -309,14 +309,24 @@ export default { /** @type {EpubReader} */ const reader = this + // Use axios to make request because we have token refresh logic in interceptor + const customRequest = async (url) => { + try { + return this.$axios.$get(url, { + responseType: 'arraybuffer' + }) + } catch (error) { + console.error('EpubReader.initEpub customRequest failed:', error) + throw error + } + } + /** @type {ePub.Book} */ reader.book = new ePub(reader.ebookUrl, { width: this.readerWidth, height: this.readerHeight - 50, openAs: 'epub', - requestHeaders: { - Authorization: `Bearer ${this.userToken}` - } + requestMethod: customRequest }) /** @type {ePub.Rendition} */ @@ -337,29 +347,33 @@ export default { this.applyTheme() }) - reader.book.ready.then(() => { - // set up event listeners - reader.rendition.on('relocated', reader.relocated) - reader.rendition.on('keydown', reader.keyUp) + reader.book.ready + .then(() => { + // set up event listeners + reader.rendition.on('relocated', reader.relocated) + reader.rendition.on('keydown', reader.keyUp) - reader.rendition.on('touchstart', (event) => { - this.$emit('touchstart', event) - }) - reader.rendition.on('touchend', (event) => { - this.$emit('touchend', event) - }) - - // load ebook cfi locations - const savedLocations = this.loadLocations() - if (savedLocations) { - reader.book.locations.load(savedLocations) - } else { - reader.book.locations.generate().then(() => { - this.checkSaveLocations(reader.book.locations.save()) + reader.rendition.on('touchstart', (event) => { + this.$emit('touchstart', event) }) - } - this.getChapters() - }) + reader.rendition.on('touchend', (event) => { + this.$emit('touchend', event) + }) + + // load ebook cfi locations + const savedLocations = this.loadLocations() + if (savedLocations) { + reader.book.locations.load(savedLocations) + } else { + reader.book.locations.generate().then(() => { + this.checkSaveLocations(reader.book.locations.save()) + }) + } + this.getChapters() + }) + .catch((error) => { + console.error('EpubReader.initEpub failed:', error) + }) }, getChapters() { // Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759 From d3402e30c252a9acacee63fc3530805d127e354c Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 10 Jul 2025 16:54:28 -0500 Subject: [PATCH 114/182] Update ereaders to handle refreshing, epubjs to use custom request method, separate accessToken in store --- client/components/modals/AccountModal.vue | 2 +- client/components/readers/ComicReader.vue | 8 +----- client/components/readers/EpubReader.vue | 3 -- client/components/readers/MobiReader.vue | 10 ++----- client/components/readers/PdfReader.vue | 32 +++++++++++++++++++-- client/pages/login.vue | 2 +- client/plugins/axios.js | 20 +++---------- client/store/user.js | 34 ++++++++++++++++++----- server/utils/rateLimiterFactory.js | 2 +- 9 files changed, 67 insertions(+), 46 deletions(-) diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index 9293a6d1..6f4b7b67 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -311,7 +311,7 @@ export default { if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) { console.log('Current user access token was updated') - this.$store.commit('user/setUserToken', data.user.accessToken) + this.$store.commit('user/setAccessToken', data.user.accessToken) } this.$toast.success(this.$strings.ToastAccountUpdateSuccess) diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue index 28d79bf2..fce26939 100644 --- a/client/components/readers/ComicReader.vue +++ b/client/components/readers/ComicReader.vue @@ -104,9 +104,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, libraryItemId() { return this.libraryItem?.id }, @@ -234,10 +231,7 @@ export default { async extract() { this.loading = true var buff = await this.$axios.$get(this.ebookUrl, { - responseType: 'blob', - headers: { - Authorization: `Bearer ${this.userToken}` - } + responseType: 'blob' }) const archive = await Archive.open(buff) const originalFilesObject = await archive.getFilesObject() diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 795fcd2b..ac8e3397 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -57,9 +57,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, /** @returns {string} */ libraryItemId() { return this.libraryItem?.id diff --git a/client/components/readers/MobiReader.vue b/client/components/readers/MobiReader.vue index 3e784f77..459ae55b 100644 --- a/client/components/readers/MobiReader.vue +++ b/client/components/readers/MobiReader.vue @@ -26,9 +26,6 @@ export default { return {} }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, libraryItemId() { return this.libraryItem?.id }, @@ -96,11 +93,8 @@ export default { }, async initMobi() { // Fetch mobi file as blob - var buff = await this.$axios.$get(this.ebookUrl, { - responseType: 'blob', - headers: { - Authorization: `Bearer ${this.userToken}` - } + const buff = await this.$axios.$get(this.ebookUrl, { + responseType: 'blob' }) var reader = new FileReader() reader.onload = async (event) => { diff --git a/client/components/readers/PdfReader.vue b/client/components/readers/PdfReader.vue index c05f459c..d9459d76 100644 --- a/client/components/readers/PdfReader.vue +++ b/client/components/readers/PdfReader.vue @@ -55,7 +55,8 @@ export default { loadedRatio: 0, page: 1, numPages: 0, - pdfDocInitParams: null + pdfDocInitParams: null, + isRefreshing: false } }, computed: { @@ -152,7 +153,34 @@ export default { this.page++ this.updateProgress() }, - error(err) { + async refreshToken() { + if (this.isRefreshing) return + this.isRefreshing = true + const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => { + console.error('Failed to refresh token', error) + return null + }) + if (!newAccessToken) { + // Redirect to login on failed refresh + this.$router.push('/login') + return + } + + // Force Vue to re-render the PDF component by creating a new object + this.pdfDocInitParams = { + url: this.ebookUrl, + httpHeaders: { + Authorization: `Bearer ${newAccessToken}` + } + } + this.isRefreshing = false + }, + async error(err) { + if (err && err.status === 401) { + console.log('Received 401 error, refreshing token') + await this.refreshToken() + return + } console.error(err) }, resize() { diff --git a/client/pages/login.vue b/client/pages/login.vue index 51f60600..242eb93a 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -189,7 +189,7 @@ export default { this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) this.$store.commit('user/setUser', user) - this.$store.commit('user/setUserToken', user.accessToken) + this.$store.commit('user/setAccessToken', user.accessToken) this.$store.dispatch('user/loadUserSettings') }, diff --git a/client/plugins/axios.js b/client/plugins/axios.js index 87eedca2..66a9fa85 100644 --- a/client/plugins/axios.js +++ b/client/plugins/axios.js @@ -45,7 +45,7 @@ export default function ({ $axios, store, $root, app }) { if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/login') { // Refresh failed or login failed, redirect to login store.commit('user/setUser', null) - store.commit('user/setUserToken', null) + store.commit('user/setAccessToken', null) app.router.push('/login') return Promise.reject(error) } @@ -72,23 +72,13 @@ export default function ({ $axios, store, $root, app }) { try { // Attempt to refresh the token - const response = await $axios.$post('/auth/refresh') - const newAccessToken = response.user.accessToken - + // Updates store if successful, otherwise clears store and throw error + const newAccessToken = await store.dispatch('user/refreshToken') if (!newAccessToken) { console.error('No new access token received') return Promise.reject(error) } - // Update the token in store and localStorage - store.commit('user/setUser', response.user) - store.commit('user/setUserToken', newAccessToken) - - // Emit event used to re-authenticate socket in default.vue since $root is not available here - if (app.$eventBus) { - app.$eventBus.$emit('token_refreshed', newAccessToken) - } - // Update the original request with new token if (!originalRequest.headers) { originalRequest.headers = {} @@ -106,9 +96,7 @@ export default function ({ $axios, store, $root, app }) { // Process queued requests with error processQueue(refreshError, null) - // Clear user data and redirect to login - store.commit('user/setUser', null) - store.commit('user/setUserToken', null) + // Redirect to login app.router.push('/login') return Promise.reject(refreshError) diff --git a/client/store/user.js b/client/store/user.js index 04dc8447..a67eae34 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -1,5 +1,6 @@ export const state = () => ({ user: null, + accessToken: null, settings: { orderBy: 'media.metadata.title', orderDesc: false, @@ -25,7 +26,7 @@ export const getters = { getIsRoot: (state) => state.user && state.user.type === 'root', getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'), getToken: (state) => { - return state.user?.accessToken || null + return state.accessToken || null }, getUserMediaProgress: (state) => @@ -145,6 +146,27 @@ export const actions = { } catch (error) { console.error('Failed to load userSettings from local storage', error) } + }, + refreshToken({ state, commit }) { + return this.$axios + .$post('/auth/refresh') + .then(async (response) => { + const newAccessToken = response.user.accessToken + commit('setUser', response.user) + commit('setAccessToken', newAccessToken) + // Emit event used to re-authenticate socket in default.vue since $root is not available here + if (this.$eventBus) { + this.$eventBus.$emit('token_refreshed', newAccessToken) + } + return newAccessToken + }) + .catch((error) => { + console.error('Failed to refresh token', error) + commit('setUser', null) + commit('setAccessToken', null) + // Calling function handles redirect to login + throw error + }) } } @@ -152,14 +174,12 @@ export const mutations = { setUser(state, user) { state.user = user }, - setUserToken(state, token) { + setAccessToken(state, token) { if (!token) { localStorage.removeItem('token') - if (state.user) { - state.user.accessToken = null - } - } else if (state.user) { - state.user.accessToken = token + state.accessToken = null + } else { + state.accessToken = token localStorage.setItem('token', token) } }, diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js index 6f04d5ac..b5199662 100644 --- a/server/utils/rateLimiterFactory.js +++ b/server/utils/rateLimiterFactory.js @@ -23,7 +23,7 @@ class RateLimiterFactory { windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) } - let max = 20 // 20 attempts default + let max = 40 // 40 attempts default if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) { max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) } From ad07ed7e25a455b27b2e18e524a27a6f1e8e344e Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Fri, 11 Jul 2025 03:00:09 +0200 Subject: [PATCH 115/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index cb10cc43..76f4e64a 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -212,7 +212,7 @@ "HeaderUsers": "用户", "HeaderYearReview": "{0} 年回顾", "HeaderYourStats": "你的统计数据", - "LabelAbridged": "概要", + "LabelAbridged": "删节版", "LabelAbridgedChecked": "删节版 (已勾选)", "LabelAbridgedUnchecked": "未删节版 (未勾选)", "LabelAccessibleBy": "可访问", @@ -320,7 +320,7 @@ "LabelEmailSettingsRejectUnauthorized": "拒绝未经授权的证书", "LabelEmailSettingsRejectUnauthorizedHelp": "禁用SSL证书验证可能会使你的连接面临安全风险, 例如中间人攻击. 只有当你了解其中的含义并信任所连接的邮件服务器时, 才能禁用此选项.", "LabelEmailSettingsSecure": "安全", - "LabelEmailSettingsSecureHelp": "如果选是, 则连接将在连接到服务器时使用TLS. 如果选否, 则若服务器支持STARTTLS扩展, 则使用TLS. 在大多数情况下, 如果连接到端口465, 请将该值设置为是. 对于端口587或25, 请保持为否. (来自nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsSecureHelp": "开启此选项时,将始终通过TLS连接服务器。关闭此选项时,仅在服务器支持STARTTLS扩展时使用TLS。在大多数情况下,如果连接到端口465,请将此项设为开启。如果连接到端口587或25,请将此设置保持为关闭。(来自nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "测试地址", "LabelEmbeddedCover": "嵌入封面", "LabelEnable": "启用", @@ -346,9 +346,9 @@ "LabelExample": "示例", "LabelExpandSeries": "展开系列", "LabelExpandSubSeries": "展开子系列", - "LabelExplicit": "信息准确", - "LabelExplicitChecked": "明确(已选中)", - "LabelExplicitUnchecked": "不明确 (未选中)", + "LabelExplicit": "含成人内容", + "LabelExplicitChecked": "成人内容(已核实)", + "LabelExplicitUnchecked": "无成人内容 (未核实)", "LabelExportOPML": "导出 OPML", "LabelFeedURL": "源 URL", "LabelFetchingMetadata": "正在获取元数据", @@ -482,7 +482,7 @@ "LabelPermanent": "永久的", "LabelPermissionsAccessAllLibraries": "可以访问所有媒体库", "LabelPermissionsAccessAllTags": "可以访问所有标签", - "LabelPermissionsAccessExplicitContent": "可以访问显式内容", + "LabelPermissionsAccessExplicitContent": "可以访问成人内容", "LabelPermissionsCreateEreader": "可以创建电子阅读器", "LabelPermissionsDelete": "可以删除", "LabelPermissionsDownload": "可以下载", From 7d6d3e668788a2e37a5d6a27a939516d3e24d165 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 11 Jul 2025 14:43:07 -0500 Subject: [PATCH 116/182] Move invalidate refresh token to TokenManager --- server/Auth.js | 10 +--------- server/auth/TokenManager.js | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 55eb334a..571472a7 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,5 +1,4 @@ const { Request, Response, NextFunction } = require('express') -const { rateLimit } = require('express-rate-limit') const passport = require('passport') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt @@ -466,14 +465,7 @@ class Auth { // Invalidate the session in database using refresh token if (refreshToken) { - try { - Logger.info(`[Auth] logout: Invalidating session for refresh token: ${refreshToken}`) - await Database.sessionModel.destroy({ - where: { refreshToken } - }) - } catch (error) { - Logger.error(`[Auth] Error destroying session: ${error.message}`) - } + await this.tokenManager.invalidateRefreshToken(refreshToken) } else { Logger.info(`[Auth] logout: No refresh token on request`) } diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 3f5cc836..65ae32b1 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -379,6 +379,28 @@ class TokenManager { await Database.sessionModel.destroy({ where: { userId: user.id } }) return null } + + /** + * Invalidate a refresh token - used for logout + * + * @param {string} refreshToken + * @returns {Promise} + */ + async invalidateRefreshToken(refreshToken) { + if (!refreshToken) { + Logger.error(`[TokenManager] No refresh token provided to invalidate`) + return false + } + + try { + const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } }) + Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`) + return true + } catch (error) { + Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`) + return false + } + } } module.exports = TokenManager From 806c0a2991d236a219d8df7aa75d3d751b0efc51 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 11 Jul 2025 16:01:45 -0500 Subject: [PATCH 117/182] Remove return_tokens query param for login --- server/Auth.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Auth.js b/server/Auth.js index 571472a7..9c0e2fdb 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -305,7 +305,7 @@ class Auth { // Local strategy login route (takes username and password) router.post('/login', this.authRateLimiter, passport.authenticate('local'), async (req, res) => { // Check if mobile app wants refresh token in response - const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' + const returnTokens = req.headers['x-return-tokens'] === 'true' const userResponse = await this.handleLoginSuccess(req, res, returnTokens) res.json(userResponse) From f081a7fdc1dfd47795b5d838b2d4ec64f7bc4bd9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 10:32:35 -0500 Subject: [PATCH 118/182] Update rate limiter to use requestIp as key, pass in configurable error message --- server/utils/rateLimiterFactory.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js index b5199662..0ad77406 100644 --- a/server/utils/rateLimiterFactory.js +++ b/server/utils/rateLimiterFactory.js @@ -1,5 +1,6 @@ const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit') const Logger = require('../Logger') +const requestIp = require('../libs/requestIp') /** * Factory for creating authentication rate limiters @@ -28,7 +29,7 @@ class RateLimiterFactory { max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) } - let message = 'Too many requests, please try again later.' + let message = 'Too many authentication requests' if (process.env.RATE_LIMIT_AUTH_MESSAGE) { message = process.env.RATE_LIMIT_AUTH_MESSAGE } @@ -36,18 +37,22 @@ class RateLimiterFactory { this.authRateLimiter = rateLimit({ windowMs, max, - message, standardHeaders: true, legacyHeaders: false, + keyGenerator: (req) => { + // Override keyGenerator to handle proxy IPs + return requestIp.getClientIp(req) || req.ip + }, handler: (req, res) => { const userAgent = req.get('User-Agent') || 'Unknown' const endpoint = req.path const method = req.method + const ip = requestIp.getClientIp(req) || req.ip - Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${req.ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`) + Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`) res.status(429).json({ - error: 'Too many authentication attempts, please try again later.' + error: message }) } }) From 030e43f38218f5fe9f013940fba9a63d27ed57ce Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 10:51:07 -0500 Subject: [PATCH 119/182] Support disabled rate limiter by setting max to 0, add logs when rate limit is changed from default --- server/utils/rateLimiterFactory.js | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js index 0ad77406..e639c51c 100644 --- a/server/utils/rateLimiterFactory.js +++ b/server/utils/rateLimiterFactory.js @@ -6,6 +6,9 @@ const requestIp = require('../libs/requestIp') * Factory for creating authentication rate limiters */ class RateLimiterFactory { + static DEFAULT_WINDOW_MS = 10 * 60 * 1000 // 10 minutes + static DEFAULT_MAX = 40 // 40 attempts + constructor() { this.authRateLimiter = null } @@ -19,14 +22,27 @@ class RateLimiterFactory { return this.authRateLimiter } - let windowMs = 10 * 60 * 1000 // 10 minutes default - if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) { - windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) + // Disable by setting max to 0 + if (process.env.RATE_LIMIT_AUTH_MAX === '0') { + this.authRateLimiter = (req, res, next) => next() + Logger.info(`[RateLimiterFactory] Authentication rate limiting disabled by ENV variable`) + return this.authRateLimiter } - let max = 40 // 40 attempts default + let windowMs = RateLimiterFactory.DEFAULT_WINDOW_MS + if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) { + windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) + if (windowMs !== RateLimiterFactory.DEFAULT_WINDOW_MS) { + Logger.info(`[RateLimiterFactory] Authentication rate limiting window set to ${windowMs}ms by ENV variable`) + } + } + + let max = RateLimiterFactory.DEFAULT_MAX if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) { max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) + if (max !== RateLimiterFactory.DEFAULT_MAX) { + Logger.info(`[RateLimiterFactory] Authentication rate limiting max set to ${max} by ENV variable`) + } } let message = 'Too many authentication requests' From d09db19cd5fd6133a9f49c3d8a8f709fdff26a49 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 11:21:52 -0500 Subject: [PATCH 120/182] Update re-login message to show for users without github discussion link, add message to i18n strings --- client/pages/login.vue | 21 ++++++++++----------- client/strings/en-us.json | 1 + 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 242eb93a..01adadcd 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -40,11 +40,11 @@

{{ error }}

-
+
-

Authentication has been improved for security. All users will be required to re-login.

- More info +

{{ $strings.MessageAuthenticationSecurityMessage }}

+ {{ $strings.LabelMoreInfo }}
@@ -95,6 +95,8 @@ export default { login_local: true, login_openid: false, authFormData: null, + // New JWT auth system re-login flags + showNewAuthSystemMessage: false, showNewAuthSystemAdminMessage: false } }, @@ -195,6 +197,7 @@ export default { }, async submitForm() { this.error = null + this.showNewAuthSystemMessage = false this.showNewAuthSystemAdminMessage = false this.processing = true @@ -231,14 +234,10 @@ export default { .then((res) => { // Force re-login if user is using an old token with no expiration if (res.user.isOldToken) { - if (res.user.type === 'admin' || res.user.type === 'root') { - this.username = res.user.username - // Show message to admin users about new auth system - this.showNewAuthSystemAdminMessage = true - } else { - // Regular users just shown login - this.username = res.user.username - } + this.username = res.user.username + this.showNewAuthSystemMessage = true + // Admin user sees link to github discussion + this.showNewAuthSystemAdminMessage = res.user.type === 'admin' || res.user.type === 'root' return false } this.setUser(res) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index a25f02cf..84127708 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -724,6 +724,7 @@ "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.", "MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.", + "MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users will be required to re-login.", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.", "MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups", "MessageBackupsLocationNoEditNote": "Note: The backup location is set through an environment variable and cannot be changed here.", From 4f7831611f9a4a99158ca638a1ce3f1361200ca3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 11:23:08 -0500 Subject: [PATCH 121/182] Update auth re-login i18n string --- client/strings/en-us.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 84127708..56c29ec1 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -724,7 +724,7 @@ "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.", "MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.", - "MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users will be required to re-login.", + "MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users are required to re-login.", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.", "MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups", "MessageBackupsLocationNoEditNote": "Note: The backup location is set through an environment variable and cannot be changed here.", From 2444504c6a27e2f2d041aaa1054a30ec53bed3d7 Mon Sep 17 00:00:00 2001 From: Kabika82 Date: Fri, 11 Jul 2025 11:16:51 +0200 Subject: [PATCH 122/182] Translated using Weblate (Hungarian) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/ --- client/strings/hu.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/hu.json b/client/strings/hu.json index cb7bf070..f89bbf95 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?", "MessageConfirmRemoveCollection": "Biztosan eltávolítja a(z) \"{0}\" gyűjteményt?", "MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?", + "MessageConfirmRemoveEpisodeNote": "Megjegyzés: Ez nem törli a hangfájlt, kivéve, ha a \"Hangfájl végleges törlése\" be van kapcsolva.", "MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?", "MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?", "MessageConfirmRemoveMetadataFiles": "Biztos, hogy az összes metaadatot el akarja távolítani {0} fájl van könyvtár mappáiban?", From c6b3521cb6ea9a99bc8adc2455eae3d3d93e1253 Mon Sep 17 00:00:00 2001 From: Kabika82 Date: Fri, 11 Jul 2025 11:20:23 +0200 Subject: [PATCH 123/182] Translated using Weblate (Hungarian) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/ --- client/strings/hu.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/hu.json b/client/strings/hu.json index f89bbf95..325ead1a 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -278,7 +278,7 @@ "LabelCollapseSeries": "Sorozat összecsukása", "LabelCollapseSubSeries": "Alszéria összecsukása", "LabelCollection": "Gyűjtemény", - "LabelCollections": "Gyűjtemény", + "LabelCollections": "Gyűjtemények", "LabelComplete": "Kész", "LabelConfirmPassword": "Jelszó megerősítése", "LabelContinueListening": "Hallgatás folytatása", @@ -757,7 +757,7 @@ "MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?", "MessageConfirmRemoveCollection": "Biztosan eltávolítja a(z) \"{0}\" gyűjteményt?", "MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?", - "MessageConfirmRemoveEpisodeNote": "Megjegyzés: Ez nem törli a hangfájlt, kivéve, ha a \"Hangfájl végleges törlése\" be van kapcsolva.", + "MessageConfirmRemoveEpisodeNote": "Megjegyzés: Ez nem törli a hangfájlt, kivéve, ha a \"Hangfájl végleges törlése\" be van kapcsolva", "MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?", "MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?", "MessageConfirmRemoveMetadataFiles": "Biztos, hogy az összes metaadatot el akarja távolítani {0} fájl van könyvtár mappáiban?", From a05d32b1d74da222afaf97d06368392aac4d232e Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Fri, 11 Jul 2025 03:29:53 +0200 Subject: [PATCH 124/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 76f4e64a..1ec4f20b 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -714,7 +714,7 @@ "MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.", "MessageBackupsLocationPathEmpty": "备份位置路径不能为空", "MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并", - "MessageBatchEditPopulateMapDetailsItemHelp": "使用此项目的数据填充已启用的地图详细信息字段", + "MessageBatchEditPopulateMapDetailsItemHelp": "将此条目的数据填入已启用的字段", "MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.", "MessageBookshelfNoCollections": "你尚未进行任何收藏", "MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.", From 20bb6e13b583d6efe0a8996ca5595aa701baf58d Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Fri, 11 Jul 2025 03:32:27 +0200 Subject: [PATCH 125/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 1ec4f20b..3597a76b 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -714,7 +714,7 @@ "MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.", "MessageBackupsLocationPathEmpty": "备份位置路径不能为空", "MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并", - "MessageBatchEditPopulateMapDetailsItemHelp": "将此条目的数据填入已启用的字段", + "MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息,填入上方所有勾选的编辑框中。", "MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.", "MessageBookshelfNoCollections": "你尚未进行任何收藏", "MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.", From 3473ff594af1f27d381bd1044340f411076d991f Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Fri, 11 Jul 2025 11:04:24 +0200 Subject: [PATCH 126/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 3597a76b..36ec1270 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -11,7 +11,7 @@ "ButtonAuthors": "作者", "ButtonBack": "返回", "ButtonBatchEditPopulateFromExisting": "用现有内容填充", - "ButtonBatchEditPopulateMapDetails": "填充地图详细信息", + "ButtonBatchEditPopulateMapDetails": "填入此项详情", "ButtonBrowseForFolder": "浏览文件夹", "ButtonCancel": "取消", "ButtonCancelEncode": "取消编码", @@ -73,7 +73,7 @@ "ButtonQuickMatch": "快速匹配", "ButtonReScan": "重新扫描", "ButtonRead": "读取", - "ButtonReadLess": "阅读较少", + "ButtonReadLess": "收起", "ButtonReadMore": "阅读更多", "ButtonRefresh": "刷新", "ButtonRemove": "移除", @@ -714,7 +714,7 @@ "MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.", "MessageBackupsLocationPathEmpty": "备份位置路径不能为空", "MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并", - "MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息,填入上方所有勾选的编辑框中。", + "MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息,填入上方所有勾选的编辑框中", "MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.", "MessageBookshelfNoCollections": "你尚未进行任何收藏", "MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.", @@ -757,7 +757,7 @@ "MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?", "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", "MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?", - "MessageConfirmRemoveEpisodeNote": "注意: 除非切换 \"硬删除文件\", 否则不会删除音频文件", + "MessageConfirmRemoveEpisodeNote": "注意:此操作不会删除音频文件,除非勾选“完全删除文件”选项", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveListeningSessions": "你确定要移除 {0} 收听会话吗?", "MessageConfirmRemoveMetadataFiles": "你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?", @@ -918,9 +918,9 @@ "NoteUploaderUnsupportedFiles": "不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.", "NotificationOnBackupCompletedDescription": "备份完成时触发", "NotificationOnBackupFailedDescription": "备份失败时触发", - "NotificationOnEpisodeDownloadedDescription": "当播客节目自动下载时触发", + "NotificationOnEpisodeDownloadedDescription": "当播客节目自动下载完成时触发", "NotificationOnRSSFeedDisabledDescription": "由于尝试失败次数过多而导致剧集自动下载被禁用时触发", - "NotificationOnRSSFeedFailedDescription": "当 RSS 源请求自动下载剧集失败时触发", + "NotificationOnRSSFeedFailedDescription": "当用于自动下载剧集的 RSS 源请求失败时触发", "NotificationOnTestDescription": "测试通知系统的事件", "PlaceholderNewCollection": "输入收藏夹名称", "PlaceholderNewFolderPath": "输入文件夹路径", From 264ae928a9c1af620487488110eec816b14e23ec Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 11:43:14 -0500 Subject: [PATCH 127/182] Version bump v2.26.0 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index db21b43f..406ef9db 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.25.1", + "version": "2.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.25.1", + "version": "2.26.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index e8823f1b..5ebaab54 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.25.1", + "version": "2.26.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 1be14fa8..5787f713 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.25.1", + "version": "2.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.25.1", + "version": "2.26.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 3fdbf768..586e8d44 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.25.1", + "version": "2.26.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From f7b94a4b6d9fb37ef9e8e8a807ee1e75452db44c Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 13 Jul 2025 17:04:02 -0500 Subject: [PATCH 128/182] Fix OIDC auto register user #4485 --- server/auth/OidcAuthStrategy.js | 2 +- server/auth/TokenManager.js | 14 +++++++++++++- server/models/User.js | 14 +++++++------- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js index c3f6cfb2..9a515c1a 100644 --- a/server/auth/OidcAuthStrategy.js +++ b/server/auth/OidcAuthStrategy.js @@ -121,7 +121,7 @@ class OidcAuthStrategy { throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) } - let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) + let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo) if (!user?.isActive) { throw new Error('User not active or not found') diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 65ae32b1..d972b534 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -81,6 +81,18 @@ class TokenManager { } } + /** + * Generate a JWT token for a given user + * TODO: Old method with no expiration + * @deprecated + * + * @param {{ id:string, username:string }} user + * @returns {string} + */ + static generateAccessToken(user) { + return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret) + } + /** * Function to generate a jwt token for a given user * TODO: Old method with no expiration @@ -90,7 +102,7 @@ class TokenManager { * @returns {string} */ generateAccessToken(user) { - return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret) + return TokenManager.generateAccessToken(user) } /** diff --git a/server/models/User.js b/server/models/User.js index 3f06b238..bc8a9f6a 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -1,9 +1,11 @@ const uuidv4 = require('uuid').v4 const sequelize = require('sequelize') +const { LRUCache } = require('lru-cache') + const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const { isNullOrNaN } = require('../utils') -const { LRUCache } = require('lru-cache') +const TokenManager = require('../auth/TokenManager') class UserCache { constructor() { @@ -213,10 +215,9 @@ class User extends Model { * or creates a new user if configured to do so. * * @param {Object} userinfo - * @param {import('../Auth')} auth * @returns {Promise} */ - static async findOrCreateUserFromOpenIdUserInfo(userinfo, auth) { + static async findOrCreateUserFromOpenIdUserInfo(userinfo) { let user = await this.getUserByOpenIDSub(userinfo.sub) // Matched by sub @@ -290,7 +291,7 @@ class User extends Model { // If no existing user was matched, auto-register if configured if (global.ServerSettings.authOpenIDAutoRegister) { Logger.info(`[User] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) - user = await this.createUserFromOpenIdUserInfo(userinfo, auth) + user = await this.createUserFromOpenIdUserInfo(userinfo) return user } @@ -301,16 +302,15 @@ class User extends Model { /** * Create user from openid userinfo * @param {Object} userinfo - * @param {import('../Auth')} auth * @returns {Promise} */ - static async createUserFromOpenIdUserInfo(userinfo, auth) { + static async createUserFromOpenIdUserInfo(userinfo) { const userId = uuidv4() // TODO: Ensure username is unique? const username = userinfo.preferred_username || userinfo.name || userinfo.sub const email = userinfo.email && userinfo.email_verified ? userinfo.email : null - const token = auth.generateAccessToken({ id: userId, username }) + const token = TokenManager.generateAccessToken({ id: userId, username }) const newUser = { id: userId, From 99110f587a680f4a754bc70cecf3cdbfa41da5db Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 14 Jul 2025 17:17:39 -0500 Subject: [PATCH 129/182] Localize elapsed duration on sessions tables --- client/pages/config/sessions.vue | 2 +- client/pages/config/users/_id/sessions.vue | 2 +- client/plugins/utils.js | 42 ++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/client/pages/config/sessions.vue b/client/pages/config/sessions.vue index 135922d3..c8a881c1 100644 --- a/client/pages/config/sessions.vue +++ b/client/pages/config/sessions.vue @@ -68,7 +68,7 @@

-

{{ $elapsedPretty(session.timeListening) }}

+

{{ $elapsedPrettyLocalized(session.timeListening) }}

{{ $secondsToTimestamp(session.currentTime) }}

diff --git a/client/pages/config/users/_id/sessions.vue b/client/pages/config/users/_id/sessions.vue index 764983d7..9b2d3418 100644 --- a/client/pages/config/users/_id/sessions.vue +++ b/client/pages/config/users/_id/sessions.vue @@ -40,7 +40,7 @@

-

{{ $elapsedPretty(session.timeListening) }}

+

{{ $elapsedPrettyLocalized(session.timeListening) }}

{{ $secondsToTimestamp(session.currentTime) }}

diff --git a/client/plugins/utils.js b/client/plugins/utils.js index 8341a9e2..a03deb41 100644 --- a/client/plugins/utils.js +++ b/client/plugins/utils.js @@ -37,6 +37,48 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false, useMilliseconds = return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}` } +Vue.prototype.$elapsedPrettyLocalized = (seconds, useFullNames = false, useMilliseconds = false) => { + if (isNaN(seconds) || seconds === null) return '' + + try { + const df = new Intl.DurationFormat(Vue.prototype.$languageCodes.current, { + style: useFullNames ? 'long' : 'short' + }) + + const duration = {} + + if (seconds < 60) { + if (useMilliseconds && seconds < 1) { + duration.milliseconds = Math.floor(seconds * 1000) + } else { + duration.seconds = Math.floor(seconds) + } + } else if (seconds < 3600) { + // 1 hour + duration.minutes = Math.floor(seconds / 60) + } else if (seconds < 86400) { + // 1 day + duration.hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (minutes > 0) { + duration.minutes = minutes + } + } else { + duration.days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + if (hours > 0) { + duration.hours = hours + } + } + + return df.format(duration) + } catch (error) { + // Handle not supported + console.warn('Intl.DurationFormat not supported, not localizing duration') + return Vue.prototype.$elapsedPretty(seconds, useFullNames, useMilliseconds) + } +} + Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHours = false) => { if (!seconds) { return alwaysIncludeHours ? '00:00:00' : '0:00' From 6c63e2131cf296b33c83730a10458042e4e5b6ce Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 15 Jul 2025 16:28:41 -0500 Subject: [PATCH 130/182] Update AllowCors to apply to every request #4497 --- server/Server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Server.js b/server/Server.js index 99e72d0c..1a8db406 100644 --- a/server/Server.js +++ b/server/Server.js @@ -240,7 +240,7 @@ class Server { * Running in development allows cors to allow testing the mobile apps in the browser * or env variable ALLOW_CORS = '1' */ - if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) { + if (global.AllowCors || Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) { const allowedOrigins = ['capacitor://localhost', 'http://localhost'] if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) { res.header('Access-Control-Allow-Origin', req.get('origin')) From 3845940245c3fd1311419d896a560d0482a77072 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 16 Jul 2025 16:43:53 -0500 Subject: [PATCH 131/182] Add warning under legacy token input on users page to use api keys instead --- client/pages/config/users/_id/index.vue | 4 +++- client/strings/en-us.json | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/pages/config/users/_id/index.vue b/client/pages/config/users/_id/index.vue index b48147d3..34a0fc86 100644 --- a/client/pages/config/users/_id/index.vue +++ b/client/pages/config/users/_id/index.vue @@ -13,8 +13,10 @@

{{ username }}

-
+
+ +

diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 56c29ec1..d927e3fd 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -723,6 +723,7 @@ "MessageAddToPlayerQueue": "Add to player queue", "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.", + "MessageAuthenticationLegacyTokenWarning": "Legacy API tokens will be removed in the future. Use API Keys instead.", "MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.", "MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users are required to re-login.", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.", From e678fe6e2f91d757f094373d46b26695cfd08e9a Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 16 Jul 2025 16:56:07 -0500 Subject: [PATCH 132/182] Update sessions modal to show username & update sessions endpoints to always return username --- .../modals/ListeningSessionModal.vue | 5 ++++- server/controllers/SessionController.js | 20 +++++++++---------- server/controllers/UserController.js | 11 +++++++++- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/client/components/modals/ListeningSessionModal.vue b/client/components/modals/ListeningSessionModal.vue index ecf00f78..0a6b556e 100644 --- a/client/components/modals/ListeningSessionModal.vue +++ b/client/components/modals/ListeningSessionModal.vue @@ -81,7 +81,7 @@

{{ $strings.LabelUser }}

-

{{ _session.userId }}

+

{{ username }}

{{ $strings.LabelMediaPlayer }}

{{ playMethodName }}

@@ -132,6 +132,9 @@ export default { _session() { return this.session || {} }, + username() { + return this._session.user?.username || this._session.userId || '' + }, deviceInfo() { return this._session.deviceInfo || {} }, diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js index 8cebdd35..7160eace 100644 --- a/server/controllers/SessionController.js +++ b/server/controllers/SessionController.js @@ -57,26 +57,24 @@ class SessionController { } let where = null - const include = [ - { - model: Database.models.device - } - ] if (userId) { where = { userId } - } else { - include.push({ - model: Database.userModel, - attributes: ['id', 'username'] - }) } const { rows, count } = await Database.playbackSessionModel.findAndCountAll({ where, - include, + include: [ + { + model: Database.deviceModel + }, + { + model: Database.userModel, + attributes: ['id', 'username'] + } + ], order: [[orderKey, orderDesc]], limit: itemsPerPage, offset: itemsPerPage * page diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index e72293cb..3ec10539 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -439,7 +439,16 @@ class UserController { const page = toNumber(req.query.page, 0) const start = page * itemsPerPage - const sessions = listeningSessions.slice(start, start + itemsPerPage) + // Map user to sessions to match the format of the sessions endpoint + const sessions = listeningSessions.slice(start, start + itemsPerPage).map((session) => { + return { + ...session, + user: { + id: req.reqUser.id, + username: req.reqUser.username + } + } + }) const payload = { total: listeningSessions.length, From 8a3b8d2249b2554246e9c8000923893ae6be4992 Mon Sep 17 00:00:00 2001 From: biuklija Date: Sun, 13 Jul 2025 08:55:37 +0200 Subject: [PATCH 133/182] Translated using Weblate (Croatian) Currently translated at 100.0% (1128 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index 267d8a67..40d9cf5b 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -1,5 +1,6 @@ { "ButtonAdd": "Dodaj", + "ButtonAddApiKey": "Dodaj API ključ", "ButtonAddChapters": "Dodaj poglavlja", "ButtonAddDevice": "Dodaj uređaj", "ButtonAddLibrary": "Dodaj knjižnicu", @@ -20,6 +21,7 @@ "ButtonChooseAFolder": "Odaberi mapu", "ButtonChooseFiles": "Odaberi datoteke", "ButtonClearFilter": "Poništi filter", + "ButtonClose": "Zatvori", "ButtonCloseFeed": "Zatvori izvor", "ButtonCloseSession": "Zatvori otvorenu sesiju", "ButtonCollections": "Zbirke", @@ -119,6 +121,7 @@ "HeaderAccount": "Korisnički račun", "HeaderAddCustomMetadataProvider": "Dodaj prilagođenog pružatelja meta-podataka", "HeaderAdvanced": "Napredno", + "HeaderApiKeys": "API ključevi", "HeaderAppriseNotificationSettings": "Postavke obavijesti Apprise", "HeaderAudioTracks": "Zvučni zapisi", "HeaderAudiobookTools": "Alati za upravljanje datotekama zvučnih knjiga", @@ -162,6 +165,7 @@ "HeaderMetadataOrderOfPrecedence": "Redoslijed prihvaćanja meta-podataka", "HeaderMetadataToEmbed": "Meta-podatci za ugradnju", "HeaderNewAccount": "Novi korisnički račun", + "HeaderNewApiKey": "Novi API ključ", "HeaderNewLibrary": "Nova knjižnica", "HeaderNotificationCreate": "Izradi obavijest", "HeaderNotificationUpdate": "Ažuriraj obavijest", @@ -206,6 +210,7 @@ "HeaderTableOfContents": "Sadržaj", "HeaderTools": "Alati", "HeaderUpdateAccount": "Ažuriraj korisnički račun", + "HeaderUpdateApiKey": "Ažuriraj API ključ", "HeaderUpdateAuthor": "Ažuriraj autora", "HeaderUpdateDetails": "Ažuriraj pojedinosti", "HeaderUpdateLibrary": "Ažuriraj knjižnicu", @@ -235,6 +240,10 @@ "LabelAllUsersExcludingGuests": "Svi korisnici osim gostiju", "LabelAllUsersIncludingGuests": "Svi korisnici uključujući i goste", "LabelAlreadyInYourLibrary": "Već u vašoj knjižnici", + "LabelApiKeyCreated": "API ključ \"{0}\" uspješno izrađen.", + "LabelApiKeyCreatedDescription": "Ne zaboravite odmah kopirati API ključ jer ga više nećete moći vidjeti.", + "LabelApiKeyUser": "Izvršavaj u ime korisnika", + "LabelApiKeyUserDescription": "Ovaj API ključ imat će iste dozvole kao i korisnik u čije ime djeluje. U zapisnicima će biti zabilježeno da je korisnik slao zahtjeve.", "LabelApiToken": "API Token", "LabelAppend": "Pridodaj", "LabelAudioBitrate": "Kvaliteta zvučnog zapisa (npr. 128k)", @@ -346,7 +355,11 @@ "LabelExample": "Primjer", "LabelExpandSeries": "Serijal prikaži prošireno", "LabelExpandSubSeries": "Podserijal prikaži prošireno", - "LabelExplicit": "Eksplicitni sadržaj", + "LabelExpired": "Istekao", + "LabelExpiresAt": "Istječe", + "LabelExpiresInSeconds": "Istječe za (sekundi)", + "LabelExpiresNever": "Nikada", + "LabelExplicit": "Eksplicitno", "LabelExplicitChecked": "Eksplicitni sadržaj (označeno)", "LabelExplicitUnchecked": "Nije eksplicitni sadržaj (odznačeno)", "LabelExportOPML": "Izvoz OPML-a", @@ -455,6 +468,7 @@ "LabelNewestEpisodes": "Najnoviji nastavci", "LabelNextBackupDate": "Sljedeća izrada sigurnosne kopije", "LabelNextScheduledRun": "Sljedeće zakazano izvođenje", + "LabelNoApiKeys": "Nema API ključeva", "LabelNoCustomMetadataProviders": "Nema prilagođenih pružatelja meta-podataka", "LabelNoEpisodesSelected": "Nema odabranih nastavaka", "LabelNotFinished": "Nije dovršeno", @@ -544,6 +558,7 @@ "LabelSelectAll": "Označi sve", "LabelSelectAllEpisodes": "Označi sve nastavke", "LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka", + "LabelSelectUser": "Odaberite korisnika", "LabelSelectUsers": "Označi korisnike", "LabelSendEbookToDevice": "Pošalji e-knjigu …", "LabelSequence": "Slijed", @@ -709,6 +724,7 @@ "MessageAppriseDescription": "Da biste se koristili ovom značajkom, treba vam instanca Apprise API-ja ili API koji može rukovati istom vrstom zahtjeva.
The Adresa Apprise API-ja treba biti puna URL putanja za slanje obavijesti, npr. ako vam se API instanca poslužuje na adresi http://192.168.1.1:8337 trebate upisati http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Upišite ASIN iz odgovarajuće Audibleove regije, ne s Amazonov.", "MessageAuthenticationOIDCChangesRestart": "Ponovno pokrenite poslužitelj da biste primijenili OIDC promjene.", + "MessageAuthenticationSecurityMessage": "Provjera autentičnosti poboljšana je radi sigurnosti. Svi se korisnici moraju ponovno prijaviti.", "MessageBackupsDescription": "Sigurnosne kopije sadrže korisnike, korisnikov napredak medija, pojedinosti knjižničke građe, postavke poslužitelja i slike koje se spremaju u /metadata/items & /metadata/authors. Sigurnosne kopije ne sadrže niti jednu datoteku iz mapa knjižnice.", "MessageBackupsLocationEditNote": "Napomena: Uređivanje lokacije za sigurnosne kopije ne premješta ili mijenja postojeće sigurnosne kopije", "MessageBackupsLocationNoEditNote": "Napomena: Lokacija za sigurnosne kopije zadana je kroz varijablu okoline i ovdje se ne može izmijeniti.", @@ -730,6 +746,7 @@ "MessageChaptersNotFound": "Poglavlja nisu pronađena", "MessageCheckingCron": "Provjeravam cron...", "MessageConfirmCloseFeed": "Sigurno želite zatvoriti ovaj izvor?", + "MessageConfirmDeleteApiKey": "Sigurno želite izbrisati API ključ \"{0}\"?", "MessageConfirmDeleteBackup": "Sigurno želite izbrisati sigurnosnu kopiju za {0}?", "MessageConfirmDeleteDevice": "Sigurno želite izbrisati e-čitač \"{0}\"?", "MessageConfirmDeleteFile": "Ovo će izbrisati datoteke s datotečnog sustava. Jeste li sigurni?", @@ -757,6 +774,7 @@ "MessageConfirmRemoveAuthor": "Sigurno želite ukloniti autora \"{0}\"?", "MessageConfirmRemoveCollection": "Sigurno želite obrisati kolekciju \"{0}\"?", "MessageConfirmRemoveEpisode": "Sigurno želite ukloniti nastavak \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "Napomena: Ova funkcija neće izbrisati zvučnu datoteku ukoliko ne uključite opciju \"Izbriši datoteku zauvijek\"", "MessageConfirmRemoveEpisodes": "Sigurno želite ukloniti {0} nastavaka?", "MessageConfirmRemoveListeningSessions": "Sigurno želite ukloniti {0} sesija slušanja?", "MessageConfirmRemoveMetadataFiles": "Sigurno želite ukloniti sve datoteke metadata.{0} u mapama vaših knjižničkih stavki?", @@ -1000,6 +1018,8 @@ "ToastEpisodeDownloadQueueClearSuccess": "Redoslijed preuzimanja nastavaka očišćen", "ToastEpisodeUpdateSuccess": "{0} nastavak/a ažurirano", "ToastErrorCannotShare": "Dijeljenje na ovaj uređaj nije moguće", + "ToastFailedToCreate": "Izrada nije uspjela", + "ToastFailedToDelete": "Brisanje nije uspjelo", "ToastFailedToLoadData": "Učitavanje podataka nije uspjelo", "ToastFailedToMatch": "Nije prepoznato", "ToastFailedToShare": "Dijeljenje nije uspjelo", @@ -1031,6 +1051,7 @@ "ToastMustHaveAtLeastOnePath": "Mora postojati barem jedna putanja", "ToastNameEmailRequired": "Ime i adresa e-pošte su obavezni", "ToastNameRequired": "Ime je obavezno", + "ToastNewApiKeyUserError": "Morate odabrati korisnika", "ToastNewEpisodesFound": "pronađeno {0} novih nastavaka", "ToastNewUserCreatedFailed": "Račun \"{0}\" nije uspješno izrađen", "ToastNewUserCreatedSuccess": "Novi račun izrađen", From a21cec806e01a576a16ea00214644cbc77c610a8 Mon Sep 17 00:00:00 2001 From: Simple16 Date: Sun, 13 Jul 2025 06:52:10 +0200 Subject: [PATCH 134/182] Translated using Weblate (Russian) Currently translated at 100.0% (1128 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/client/strings/ru.json b/client/strings/ru.json index 54ed4abe..6da681eb 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -1,5 +1,6 @@ { "ButtonAdd": "Добавить", + "ButtonAddApiKey": "Добавить API ключ", "ButtonAddChapters": "Добавить главы", "ButtonAddDevice": "Добавить устройство", "ButtonAddLibrary": "Добавить библиотеку", @@ -20,6 +21,7 @@ "ButtonChooseAFolder": "Выбор папки", "ButtonChooseFiles": "Выбор файлов", "ButtonClearFilter": "Очистить фильтр", + "ButtonClose": "Закрыть", "ButtonCloseFeed": "Закрыть канал", "ButtonCloseSession": "Закрыть открытый сеанс", "ButtonCollections": "Коллекции", @@ -119,6 +121,7 @@ "HeaderAccount": "Учетная запись", "HeaderAddCustomMetadataProvider": "Добавление пользовательского поставщика метаданных", "HeaderAdvanced": "Дополнительно", + "HeaderApiKeys": "API ключи", "HeaderAppriseNotificationSettings": "Настройки оповещений", "HeaderAudioTracks": "Аудио треки", "HeaderAudiobookTools": "Инструменты файлов аудиокниг", @@ -162,6 +165,7 @@ "HeaderMetadataOrderOfPrecedence": "Порядок приоритета метаданных", "HeaderMetadataToEmbed": "Метаинформация для встраивания", "HeaderNewAccount": "Новая учетная запись", + "HeaderNewApiKey": "Новый API ключ", "HeaderNewLibrary": "Новая библиотека", "HeaderNotificationCreate": "Создать уведомление", "HeaderNotificationUpdate": "Уведомление об обновлении", @@ -206,6 +210,7 @@ "HeaderTableOfContents": "Содержание", "HeaderTools": "Инструменты", "HeaderUpdateAccount": "Обновить учетную запись", + "HeaderUpdateApiKey": "Обновить API ключ", "HeaderUpdateAuthor": "Обновить автора", "HeaderUpdateDetails": "Обновить детали", "HeaderUpdateLibrary": "Обновить библиотеку", @@ -235,6 +240,10 @@ "LabelAllUsersExcludingGuests": "Все пользователи, кроме гостей", "LabelAllUsersIncludingGuests": "Все пользователи, включая гостей", "LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке", + "LabelApiKeyCreated": "API ключ \"{0}\" успешно создан.", + "LabelApiKeyCreatedDescription": "Обязательно скопируйте API-ключ сейчас, так как вы больше не сможете его увидеть.", + "LabelApiKeyUser": "Управление от пользователя", + "LabelApiKeyUserDescription": "Этот API-ключ будет иметь те же права доступа, что и пользователь, от имени которого он действует. В логах это будет отображаться так же, как если бы пользователь отправлял запрос.", "LabelApiToken": "Токен API", "LabelAppend": "Добавить", "LabelAudioBitrate": "Битрейт (напр. 128k)", @@ -346,6 +355,10 @@ "LabelExample": "Пример", "LabelExpandSeries": "Развернуть серию", "LabelExpandSubSeries": "Развернуть подсерию", + "LabelExpired": "Истекший", + "LabelExpiresAt": "Истекает в", + "LabelExpiresInSeconds": "Истекает через (seconds)", + "LabelExpiresNever": "Никогда", "LabelExplicit": "18+", "LabelExplicitChecked": "18+ (отмечено)", "LabelExplicitUnchecked": "+18 (не отмечено)", @@ -455,6 +468,7 @@ "LabelNewestEpisodes": "Новые эпизоды", "LabelNextBackupDate": "Следующая дата бэкапирования", "LabelNextScheduledRun": "Следущий запланированный запуск", + "LabelNoApiKeys": "API ключи отсутствуют", "LabelNoCustomMetadataProviders": "Нет пользовательских поставщиков метаданных", "LabelNoEpisodesSelected": "Эпизоды не выбраны", "LabelNotFinished": "Не завершено", @@ -544,6 +558,7 @@ "LabelSelectAll": "Выбрать все", "LabelSelectAllEpisodes": "Выбрать все эпизоды", "LabelSelectEpisodesShowing": "Выберите {0} эпизодов для показа", + "LabelSelectUser": "Выбрать пользователя", "LabelSelectUsers": "Выбор пользователей", "LabelSendEbookToDevice": "Отправить e-книгу в...", "LabelSequence": "Последовательность", @@ -709,6 +724,7 @@ "MessageAppriseDescription": "Для использования этой функции необходимо иметь запущенный экземпляр Apprise API или api которое обрабатывает те же самые запросы.
URL-адрес API Apprise должен быть полным URL-адресом для отправки уведомления, т.е., если API запущено по адресу http://192.168.1.1:8337 тогда нужно указать http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Убедитесь, что вы используете ASIN из правильной региональной зоны Audible, а не из Amazon.", "MessageAuthenticationOIDCChangesRestart": "Перезапустите ваш сервер после сохранения для применения изменений в OIDC.", + "MessageAuthenticationSecurityMessage": "В целях безопасности была улучшена аутентификация. Всем пользователям необходимо повторно войти в систему.", "MessageBackupsDescription": "Бэкап включает пользователей, прогресс пользователей, данные элементов библиотеки, настройки сервера и изображения хранящиеся в /metadata/items и /metadata/authors. Бэкапы НЕ сохраняют файлы из папок библиотек.", "MessageBackupsLocationEditNote": "Примечание: Обновление местоположения резервной копии не приведет к перемещению или изменению существующих резервных копий", "MessageBackupsLocationNoEditNote": "Примечание: Местоположение резервного копирования задается с помощью переменной среды и не может быть изменено здесь.", @@ -730,6 +746,7 @@ "MessageChaptersNotFound": "Главы не найденны", "MessageCheckingCron": "Проверка cron...", "MessageConfirmCloseFeed": "Вы уверены, что хотите закрыть этот канал?", + "MessageConfirmDeleteApiKey": "Вы уверены, что хотите удалить API ключ \"{0}\"?", "MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?", "MessageConfirmDeleteDevice": "Вы уверены, что хотите удалить устройство для чтения электронных книг \"{0}\"?", "MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?", @@ -757,6 +774,7 @@ "MessageConfirmRemoveAuthor": "Вы уверены, что хотите удалить автора \"{0}\"?", "MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?", "MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "Примечание: Это не приведет к удалению аудиофайла, если не включить опцию \"Жесткое удаление файла\".", "MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?", "MessageConfirmRemoveListeningSessions": "Вы уверены, что хотите удалить {0} сеансов прослушивания?", "MessageConfirmRemoveMetadataFiles": "Вы уверены, что хотите удалить все файлы metadata. {0} файлов из папок элементов вашей библиотеки?", From 4e96649fe3ec88649ea1b6004142d694da988bca Mon Sep 17 00:00:00 2001 From: Simple16 Date: Sun, 13 Jul 2025 06:52:38 +0200 Subject: [PATCH 135/182] Translated using Weblate (Russian) Currently translated at 100.0% (1128 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index 6da681eb..63967877 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -774,7 +774,7 @@ "MessageConfirmRemoveAuthor": "Вы уверены, что хотите удалить автора \"{0}\"?", "MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?", "MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?", - "MessageConfirmRemoveEpisodeNote": "Примечание: Это не приведет к удалению аудиофайла, если не включить опцию \"Жесткое удаление файла\".", + "MessageConfirmRemoveEpisodeNote": "Примечание: Это не приведет к удалению аудиофайла, если не включить опцию \"Жесткое удаление файла\"", "MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?", "MessageConfirmRemoveListeningSessions": "Вы уверены, что хотите удалить {0} сеансов прослушивания?", "MessageConfirmRemoveMetadataFiles": "Вы уверены, что хотите удалить все файлы metadata. {0} файлов из папок элементов вашей библиотеки?", @@ -1018,6 +1018,7 @@ "ToastEpisodeDownloadQueueClearSuccess": "Очередь загрузки эпизода очищена", "ToastEpisodeUpdateSuccess": "{0 эпизодов обновлено", "ToastErrorCannotShare": "Невозможно предоставить общий доступ на этом устройстве", + "ToastFailedToCreate": "Ошибка создания", "ToastFailedToLoadData": "Не удалось загрузить данные", "ToastFailedToMatch": "Не удалось найти совпадения", "ToastFailedToShare": "Не удалось поделиться", From 1bebb227053419e2948fd06a97775d84ec587365 Mon Sep 17 00:00:00 2001 From: Simple16 Date: Sun, 13 Jul 2025 06:53:54 +0200 Subject: [PATCH 136/182] Translated using Weblate (Russian) Currently translated at 100.0% (1128 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index 63967877..3971365c 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -1018,7 +1018,8 @@ "ToastEpisodeDownloadQueueClearSuccess": "Очередь загрузки эпизода очищена", "ToastEpisodeUpdateSuccess": "{0 эпизодов обновлено", "ToastErrorCannotShare": "Невозможно предоставить общий доступ на этом устройстве", - "ToastFailedToCreate": "Ошибка создания", + "ToastFailedToCreate": "Не удалось создать", + "ToastFailedToDelete": "Не удалось удалить", "ToastFailedToLoadData": "Не удалось загрузить данные", "ToastFailedToMatch": "Не удалось найти совпадения", "ToastFailedToShare": "Не удалось поделиться", @@ -1050,6 +1051,7 @@ "ToastMustHaveAtLeastOnePath": "Должен быть хотя бы один путь", "ToastNameEmailRequired": "Имя и адрес электронной почты обязательны", "ToastNameRequired": "Имя обязательно для заполнения", + "ToastNewApiKeyUserError": "Необходимо выбрать пользователя", "ToastNewEpisodesFound": "{0} новых эпизодов найдено", "ToastNewUserCreatedFailed": "Не удалось создать учетную запись: \"{0}\"", "ToastNewUserCreatedSuccess": "Новая учетная запись создана", From c66380eaeb03ddda4243f9c543e9dae97fdb8fa2 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Sun, 13 Jul 2025 08:30:37 +0200 Subject: [PATCH 137/182] Translated using Weblate (Swedish) Currently translated at 94.5% (1066 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/strings/sv.json b/client/strings/sv.json index 4fdc400f..776e0a37 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -1,5 +1,6 @@ { "ButtonAdd": "Lägg till", + "ButtonAddApiKey": "Addera API-nyckel", "ButtonAddChapters": "Lägg till kapitel", "ButtonAddDevice": "Lägg till enhet", "ButtonAddLibrary": "Lägg till bibliotek", @@ -20,6 +21,7 @@ "ButtonChooseAFolder": "Välj en mapp", "ButtonChooseFiles": "Välj filer", "ButtonClearFilter": "Rensa filter", + "ButtonClose": "Stäng", "ButtonCloseFeed": "Stäng flöde", "ButtonCloseSession": "Stäng öppen session", "ButtonCollections": "Samlingar", @@ -119,6 +121,7 @@ "HeaderAccount": "Konto", "HeaderAddCustomMetadataProvider": "Addera egen källa för metadata", "HeaderAdvanced": "Avancerad", + "HeaderApiKeys": "API-nyckel", "HeaderAppriseNotificationSettings": "Inställningar av meddelanden med Apprise", "HeaderAudioTracks": "Ljudfiler", "HeaderAudiobookTools": "Hantering av ljudboksfiler", @@ -162,6 +165,7 @@ "HeaderMetadataOrderOfPrecedence": "Prioriteringsordning vid inläsning av metadata", "HeaderMetadataToEmbed": "Metadata som kommer att adderas", "HeaderNewAccount": "Nytt konto", + "HeaderNewApiKey": "Ny API-nyckel", "HeaderNewLibrary": "Nytt bibliotek", "HeaderNotificationCreate": "Addera ett meddelande", "HeaderNotificationUpdate": "Uppdateringsnotis", @@ -205,6 +209,7 @@ "HeaderTableOfContents": "Innehållsförteckning", "HeaderTools": "Verktyg", "HeaderUpdateAccount": "Uppdatera konto", + "HeaderUpdateApiKey": "Uppdatera API-nyckel", "HeaderUpdateAuthor": "Uppdatera författare", "HeaderUpdateDetails": "Uppdatera detaljer om boken", "HeaderUpdateLibrary": "Uppdatera bibliotek", @@ -234,6 +239,7 @@ "LabelAllUsersExcludingGuests": "Alla användare utom gäster", "LabelAllUsersIncludingGuests": "Alla användare inklusive gäster", "LabelAlreadyInYourLibrary": "Finns redan i samlingen", + "LabelApiKeyCreated": "API-nyckel \"{0}\" har adderats", "LabelApiToken": "API-token", "LabelAppend": "Lägg till", "LabelAudioBitrate": "Bitrate (t.ex. 128k)", From c7eb9d7799957671d4dc418a57bd9955a88e57ae Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Sun, 13 Jul 2025 08:33:31 +0200 Subject: [PATCH 138/182] Translated using Weblate (Swedish) Currently translated at 94.5% (1066 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 776e0a37..a38cf4c4 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -239,7 +239,8 @@ "LabelAllUsersExcludingGuests": "Alla användare utom gäster", "LabelAllUsersIncludingGuests": "Alla användare inklusive gäster", "LabelAlreadyInYourLibrary": "Finns redan i samlingen", - "LabelApiKeyCreated": "API-nyckel \"{0}\" har adderats", + "LabelApiKeyCreated": "API-nyckel \"{0}\" har adderats.", + "LabelApiKeyCreatedDescription": "Se till att kopiera API-nyckeln omedelbart eftersom du inte kommer att kunna se den igen.", "LabelApiToken": "API-token", "LabelAppend": "Lägg till", "LabelAudioBitrate": "Bitrate (t.ex. 128k)", @@ -351,6 +352,10 @@ "LabelExample": "Exempel", "LabelExpandSeries": "Expandera serier", "LabelExpandSubSeries": "Expandera Underserier", + "LabelExpired": "Upphört", + "LabelExpiresAt": "Gäller till och med", + "LabelExpiresInSeconds": "Upphör om (sekunder)", + "LabelExpiresNever": "Aldrig", "LabelExplicit": "Explicit version", "LabelExplicitChecked": "Explicit version (markerad)", "LabelExplicitUnchecked": "Ej Explicit version (ej markerad)", @@ -460,6 +465,7 @@ "LabelNewestEpisodes": "Senaste avsnitten", "LabelNextBackupDate": "Nästa tillfälle för säkerhetskopiering", "LabelNextScheduledRun": "Nästa schemalagda körning", + "LabelNoApiKeys": "Ingen API-nyckel", "LabelNoCustomMetadataProviders": "Ingen egen källa för metadata", "LabelNoEpisodesSelected": "Inga avsnitt har valts", "LabelNotFinished": "Ej avslutad", @@ -481,6 +487,7 @@ "LabelPaginationPageXOfY": "Sida {0} av {1}", "LabelPassword": "Lösenord", "LabelPath": "Sökväg", + "LabelPermanent": "Permanent", "LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek", "LabelPermissionsAccessAllTags": "Kan komma åt alla taggar", "LabelPermissionsAccessExplicitContent": "Kan komma åt explicit version", From f472116dc3b3e7760f548815852fcea0c361e861 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Sun, 13 Jul 2025 08:37:10 +0200 Subject: [PATCH 139/182] Translated using Weblate (Swedish) Currently translated at 94.5% (1066 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index a38cf4c4..808f2270 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -487,7 +487,7 @@ "LabelPaginationPageXOfY": "Sida {0} av {1}", "LabelPassword": "Lösenord", "LabelPath": "Sökväg", - "LabelPermanent": "Permanent", + "LabelPermanent": "", "LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek", "LabelPermissionsAccessAllTags": "Kan komma åt alla taggar", "LabelPermissionsAccessExplicitContent": "Kan komma åt explicit version", @@ -549,6 +549,7 @@ "LabelSelectAll": "Välj alla", "LabelSelectAllEpisodes": "Välj alla avsnitt", "LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas", + "LabelSelectUser": "Välj användare", "LabelSelectUsers": "Välj användare", "LabelSendEbookToDevice": "Skicka e-bok till...", "LabelSequence": "Ordningsnummer", @@ -707,6 +708,7 @@ "LabelYourProgress": "Framsteg", "MessageAddToPlayerQueue": "Lägg till i spellistan", "MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av Apprise API igång eller en API som hanterar dessa begäranden.
Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på http://192.168.1.1:8337, bör du ange http://192.168.1.1:8337/notify.", + "MessageAuthenticationSecurityMessage": "Identifieringen av användare har förbättrats av säkerhetsskäl. Alla användare måste därför logga in på nytt.", "MessageBackupsDescription": "Säkerhetskopior inkluderar användare, användarnas framsteg, biblioteksobjekt,
serverinställningar och bilder lagrade i /metadata/items & /metadata/authors.
De inkluderar INTE några filer lagrade i dina biblioteksmappar.", "MessageBackupsLocationEditNote": "OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit", "MessageBackupsLocationNoEditNote": "OBS: Platsen där säkerhetskopiorna lagras bestäms av en central inställning och kan inte ändras här.", @@ -727,6 +729,7 @@ "MessageChapterStartIsAfter": "Kapitlets start är efter din ljudboks slut", "MessageCheckingCron": "Kontrollerar cron...", "MessageConfirmCloseFeed": "Är du säker på att du vill stänga detta flöde?", + "MessageConfirmDeleteApiKey": "Är du säker på att du vill radera API-nyckel \"{0}\"?", "MessageConfirmDeleteBackup": "Är du säker på att du vill radera säkerhetskopian för {0}?", "MessageConfirmDeleteDevice": "Är du säkert på att du vill radera enheten för e-böcker \"{0}\"?", "MessageConfirmDeleteFile": "Detta kommer att radera filen från ditt filsystem. Är du säker?", @@ -777,6 +780,7 @@ "MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.", "MessageImportantNotice": "Viktig meddelande!", "MessageInsertChapterBelow": "Infoga kapitel nedanför", + "MessageInvalidAsin": "Felaktig ASIN-kod", "MessageItemsSelected": "{0} objekt markerade", "MessageItemsUpdated": "{0} Objekt uppdaterade", "MessageJoinUsOn": "Anslut dig till oss på", From 9cf1711fae3ef7e0e36b50c24fa99aec4920dd3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Sun, 13 Jul 2025 06:54:54 +0200 Subject: [PATCH 140/182] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1128 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index 4655681d..10724bad 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -1,5 +1,6 @@ { "ButtonAdd": "Додати", + "ButtonAddApiKey": "Додати ключ API", "ButtonAddChapters": "Додати глави", "ButtonAddDevice": "Додати пристрій", "ButtonAddLibrary": "Додати бібліотеку", @@ -20,6 +21,7 @@ "ButtonChooseAFolder": "Обрати теку", "ButtonChooseFiles": "Обрати файли", "ButtonClearFilter": "Очистити фільтр", + "ButtonClose": "Закрити", "ButtonCloseFeed": "Закрити стрічку", "ButtonCloseSession": "Закрити відкритий сеанс", "ButtonCollections": "Добірки", @@ -119,6 +121,7 @@ "HeaderAccount": "Профіль", "HeaderAddCustomMetadataProvider": "Додати користувацький постачальник метаданих", "HeaderAdvanced": "Розширені", + "HeaderApiKeys": "Ключі API", "HeaderAppriseNotificationSettings": "Налаштування сповіщень Apprise", "HeaderAudioTracks": "Аудіодоріжки", "HeaderAudiobookTools": "Інструменти керування файлами книг", @@ -162,6 +165,7 @@ "HeaderMetadataOrderOfPrecedence": "Порядок метаданих", "HeaderMetadataToEmbed": "Вбудувати метадані", "HeaderNewAccount": "Новий профіль", + "HeaderNewApiKey": "Новий ключ API", "HeaderNewLibrary": "Нова бібліотека", "HeaderNotificationCreate": "Створити сповіщення", "HeaderNotificationUpdate": "Оновити сповіщення", @@ -206,6 +210,7 @@ "HeaderTableOfContents": "Зміст", "HeaderTools": "Інструменти", "HeaderUpdateAccount": "Оновити профіль", + "HeaderUpdateApiKey": "Оновити ключ API", "HeaderUpdateAuthor": "Оновити автора", "HeaderUpdateDetails": "Оновити подробиці", "HeaderUpdateLibrary": "Оновити бібліотеку", @@ -235,6 +240,10 @@ "LabelAllUsersExcludingGuests": "Усі, крім гостей", "LabelAllUsersIncludingGuests": "Усі, включно з гостями", "LabelAlreadyInYourLibrary": "Вже у вашій бібліотеці", + "LabelApiKeyCreated": "Ключ API \"{0}\" успішно створено.", + "LabelApiKeyCreatedDescription": "Обов’язково скопіюйте ключ API зараз, оскільки ви більше не зможете його побачити.", + "LabelApiKeyUser": "Діяти від імені користувача", + "LabelApiKeyUserDescription": "Цей ключ API матиме ті самі дозволи, що й користувач, від імені якого він діє. Це відображатиметься в журналах так само, як і в разі надсилання запиту користувачем.", "LabelApiToken": "Токен API", "LabelAppend": "Додати", "LabelAudioBitrate": "Бітрейт аудіо (наприклад, 128k)", @@ -346,6 +355,10 @@ "LabelExample": "Приклад", "LabelExpandSeries": "Розгорнути серії", "LabelExpandSubSeries": "Розгорнути підсерії", + "LabelExpired": "Термін дії минув", + "LabelExpiresAt": "Термін дії закінчується о", + "LabelExpiresInSeconds": "Термін дії закінчується через (секунди)", + "LabelExpiresNever": "Ніколи", "LabelExplicit": "Відвертий", "LabelExplicitChecked": "Відверта (з прапорцем)", "LabelExplicitUnchecked": "Не відверта (без прапорця)", @@ -455,6 +468,7 @@ "LabelNewestEpisodes": "Нові епізоди", "LabelNextBackupDate": "Дата наступного резервного копіювання", "LabelNextScheduledRun": "Наступний запланований запуск", + "LabelNoApiKeys": "Без ключів API", "LabelNoCustomMetadataProviders": "Без постачальників метаданих", "LabelNoEpisodesSelected": "Не вибрано жодного епізоду", "LabelNotFinished": "Незавершені", @@ -544,6 +558,7 @@ "LabelSelectAll": "Вибрати все", "LabelSelectAllEpisodes": "Вибрати всі епізоди", "LabelSelectEpisodesShowing": "Вибрати {0} показаних епізодів", + "LabelSelectUser": "Виберіть користувача", "LabelSelectUsers": "Вибрати користувачів", "LabelSendEbookToDevice": "Надіслати електронну книгу на...", "LabelSequence": "Послідовність", @@ -709,6 +724,7 @@ "MessageAppriseDescription": "Щоб скористатися цією функцією, вам потрібно мати запущену Apprise API або API, що оброблятиме ті ж запити.
Аби надсилати сповіщення, URL-адреса API Apprise мусить бути повною, наприклад, якщо ваш API розміщено за адресою http://192.168.1.1:8337, то необхідно вказати адресу http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Переконайтесь, що ви використовуєте ASIN з правильної регіональної Audible зони, а не з Amazon.", "MessageAuthenticationOIDCChangesRestart": "Перезавантажте сервер після збереження, щоб застосувати зміни OIDC.", + "MessageAuthenticationSecurityMessage": "Автентифікацію покращено для безпеки. Усім користувачам потрібно повторно увійти в систему.", "MessageBackupsDescription": "Резервні копії містять користувачів, прогрес, подробиці елементів бібліотеки, налаштування сервера та зображення з /metadata/items та /metadata/authors. Резервні копії не містять жодних файлів з тек бібліотеки.", "MessageBackupsLocationEditNote": "Примітка: оновлення розташування резервної копії не переносить та не змінює існуючих копій", "MessageBackupsLocationNoEditNote": "Примітка: розташування резервної копії встановлюється за допомогою змінної середовища та не може бути змінене тут.", @@ -730,6 +746,7 @@ "MessageChaptersNotFound": "Розділи не знайдені", "MessageCheckingCron": "Перевірка планувальника...", "MessageConfirmCloseFeed": "Ви дійсно бажаєте закрити цей канал?", + "MessageConfirmDeleteApiKey": "Ви впевнені, що хочете видалити ключ API? \"{0}\"?", "MessageConfirmDeleteBackup": "Ви дійсно бажаєте видалити резервну копію за {0}?", "MessageConfirmDeleteDevice": "Ви впевнені, що хочете видалити пристрій для читання \"{0}\"?", "MessageConfirmDeleteFile": "Файл буде видалено з вашої файлової системи. Ви впевнені?", @@ -819,7 +836,7 @@ "MessageNoItems": "Елементи відсутні", "MessageNoItemsFound": "Елементів не знайдено", "MessageNoListeningSessions": "Сеанси прослуховування відсутні", - "MessageNoLogs": "Немає журнали", + "MessageNoLogs": "Немає журналів", "MessageNoMediaProgress": "Прогрес відсутній", "MessageNoNotifications": "Сповіщення відсутні", "MessageNoPodcastFeed": "Некоректний подкаст: немає каналу", @@ -1001,6 +1018,8 @@ "ToastEpisodeDownloadQueueClearSuccess": "Чергу на скачування епізодів очищено", "ToastEpisodeUpdateSuccess": "{0} епізодів оновлено", "ToastErrorCannotShare": "Не можна типово поширити на цей пристрій", + "ToastFailedToCreate": "Не вдалося створити", + "ToastFailedToDelete": "Не вдалося видалити", "ToastFailedToLoadData": "Не вдалося завантажити дані", "ToastFailedToMatch": "Не вдалося знайти відповідність", "ToastFailedToShare": "Не вдалося поділитися", @@ -1032,6 +1051,7 @@ "ToastMustHaveAtLeastOnePath": "Повинен бути хоча б один шлях", "ToastNameEmailRequired": "Ім'я та електронна пошта обов'язкові", "ToastNameRequired": "Ім'я обов'язкове", + "ToastNewApiKeyUserError": "Потрібно вибрати користувача", "ToastNewEpisodesFound": "{0} нових епізодів знайдено", "ToastNewUserCreatedFailed": "Не вдалося створити акаунт: \"{0}\"", "ToastNewUserCreatedSuccess": "Новий акаунт створено", @@ -1066,7 +1086,7 @@ "ToastProviderRemoveSuccess": "Постачальник видалений", "ToastRSSFeedCloseFailed": "Не вдалося закрити RSS-канал", "ToastRSSFeedCloseSuccess": "RSS-канал закрито", - "ToastRemoveFailed": "Не вдалося видалити", + "ToastRemoveFailed": "Не вдалося вилучити", "ToastRemoveItemFromCollectionFailed": "Не вдалося видалити елемент із добірки", "ToastRemoveItemFromCollectionSuccess": "Елемент видалено з добірки", "ToastRemoveItemsWithIssuesFailed": "Не вдалося видалити елементи бібліотеки з проблемами", From 77cb4f75c6279a0a8e0a4a7bf2822be3715eb7dd Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Sun, 13 Jul 2025 07:35:17 +0200 Subject: [PATCH 141/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1128 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 36ec1270..dbaf8fd6 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -1,5 +1,6 @@ { "ButtonAdd": "添加", + "ButtonAddApiKey": "添加 API 密钥", "ButtonAddChapters": "添加章节", "ButtonAddDevice": "添加设备", "ButtonAddLibrary": "添加库", @@ -20,6 +21,7 @@ "ButtonChooseAFolder": "选择文件夹", "ButtonChooseFiles": "选择文件", "ButtonClearFilter": "清除过滤器", + "ButtonClose": "关闭", "ButtonCloseFeed": "关闭源", "ButtonCloseSession": "关闭活动会话", "ButtonCollections": "收藏", @@ -119,6 +121,7 @@ "HeaderAccount": "帐户", "HeaderAddCustomMetadataProvider": "添加自定义元数据提供商", "HeaderAdvanced": "高级", + "HeaderApiKeys": "API 密钥", "HeaderAppriseNotificationSettings": "测试通知设置", "HeaderAudioTracks": "音轨", "HeaderAudiobookTools": "有声读物文件管理工具", @@ -162,6 +165,7 @@ "HeaderMetadataOrderOfPrecedence": "元数据优先级", "HeaderMetadataToEmbed": "嵌入元数据", "HeaderNewAccount": "新建帐户", + "HeaderNewApiKey": "新建API密钥", "HeaderNewLibrary": "新建媒体库", "HeaderNotificationCreate": "创建通知", "HeaderNotificationUpdate": "更新通知", @@ -235,6 +239,10 @@ "LabelAllUsersExcludingGuests": "除访客外的所有用户", "LabelAllUsersIncludingGuests": "包括访客的所有用户", "LabelAlreadyInYourLibrary": "已存在你的库中", + "LabelApiKeyCreated": "API 密钥 \"{0}\" 创建成功。", + "LabelApiKeyCreatedDescription": "请确保现在就复制 API 密钥,之后将无法再次查看。", + "LabelApiKeyUser": "代用户操作", + "LabelApiKeyUserDescription": "此 API 密钥将具有与其代理的用户相同的权限。在日志中,其请求将被视为由该用户直接发出。", "LabelApiToken": "API 令牌", "LabelAppend": "附加", "LabelAudioBitrate": "音频比特率 (例如: 128k)", @@ -346,6 +354,9 @@ "LabelExample": "示例", "LabelExpandSeries": "展开系列", "LabelExpandSubSeries": "展开子系列", + "LabelExpiresAt": "过期时间", + "LabelExpiresInSeconds": "有效期(秒)", + "LabelExpiresNever": "从不", "LabelExplicit": "含成人内容", "LabelExplicitChecked": "成人内容(已核实)", "LabelExplicitUnchecked": "无成人内容 (未核实)", @@ -455,6 +466,7 @@ "LabelNewestEpisodes": "最新剧集", "LabelNextBackupDate": "下次备份日期", "LabelNextScheduledRun": "下次任务运行", + "LabelNoApiKeys": "无 API 密钥", "LabelNoCustomMetadataProviders": "没有自定义元数据提供商", "LabelNoEpisodesSelected": "未选择任何剧集", "LabelNotFinished": "未听完", @@ -544,6 +556,7 @@ "LabelSelectAll": "全选", "LabelSelectAllEpisodes": "选择所有剧集", "LabelSelectEpisodesShowing": "选择正在播放的 {0} 剧集", + "LabelSelectUser": "选择用户", "LabelSelectUsers": "选择用户", "LabelSendEbookToDevice": "发送电子书到...", "LabelSequence": "序列", @@ -709,6 +722,7 @@ "MessageAppriseDescription": "要使用此功能,你需要运行一个 Apprise API 实例或一个可以处理这些相同请求的 API.
Apprise API Url 应该是发送通知的完整 URL 路径, 例如: 如果你的 API 实例运行在 http://192.168.1.1:8337, 那么你可以输入 http://192.168.1.1:8337/notify.", "MessageAsinCheck": "确保你使用的 ASIN 来自正确的 Audible 地区, 而不是亚马逊.", "MessageAuthenticationOIDCChangesRestart": "保存后重新启动服务器以应用 OIDC 更改.", + "MessageAuthenticationSecurityMessage": "身份验证安全性已增强,所有用户都需要重新登录。", "MessageBackupsDescription": "备份包括用户, 用户进度, 媒体库项目详细信息, 服务器设置和图像, 存储在 /metadata/items & /metadata/authors. 备份不包括存储在你的媒体库文件夹中的任何文件.", "MessageBackupsLocationEditNote": "注意: 更新备份位置不会移动或修改现有备份", "MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.", @@ -730,6 +744,7 @@ "MessageChaptersNotFound": "未找到章节", "MessageCheckingCron": "检查计划任务...", "MessageConfirmCloseFeed": "你确定要关闭此订阅源吗?", + "MessageConfirmDeleteApiKey": "你确定要删除 API 密钥 \"{0}\" 吗?", "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteDevice": "你确定要删除电子阅读器设备 \"{0}\" 吗?", "MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?", @@ -1001,6 +1016,8 @@ "ToastEpisodeDownloadQueueClearSuccess": "剧集下载队列已清空", "ToastEpisodeUpdateSuccess": "已更新 {0} 剧集", "ToastErrorCannotShare": "无法在此设备上本地共享", + "ToastFailedToCreate": "创建失败", + "ToastFailedToDelete": "删除失败", "ToastFailedToLoadData": "加载数据失败", "ToastFailedToMatch": "匹配失败", "ToastFailedToShare": "分享失败", @@ -1032,6 +1049,7 @@ "ToastMustHaveAtLeastOnePath": "必须至少有一个路径", "ToastNameEmailRequired": "姓名和电子邮件为必填项", "ToastNameRequired": "姓名为必填项", + "ToastNewApiKeyUserError": "必须选择一个用户", "ToastNewEpisodesFound": "找到 {0} 个新剧集", "ToastNewUserCreatedFailed": "无法创建帐户: \"{0}\"", "ToastNewUserCreatedSuccess": "已创建新帐户", From b59da8bd0c8012e31467fb2b030c0cb0223266a8 Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Sun, 13 Jul 2025 15:24:44 +0200 Subject: [PATCH 142/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1128 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index dbaf8fd6..6fca0fa0 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -165,13 +165,13 @@ "HeaderMetadataOrderOfPrecedence": "元数据优先级", "HeaderMetadataToEmbed": "嵌入元数据", "HeaderNewAccount": "新建帐户", - "HeaderNewApiKey": "新建API密钥", + "HeaderNewApiKey": "新建 API 密钥", "HeaderNewLibrary": "新建媒体库", "HeaderNotificationCreate": "创建通知", "HeaderNotificationUpdate": "更新通知", "HeaderNotifications": "通知", "HeaderOpenIDConnectAuthentication": "OpenID 连接身份验证", - "HeaderOpenListeningSessions": "打开收听会话", + "HeaderOpenListeningSessions": "活动中会话", "HeaderOpenRSSFeed": "打开 RSS 源", "HeaderOtherFiles": "其他文件", "HeaderPasswordAuthentication": "密码认证", @@ -210,6 +210,7 @@ "HeaderTableOfContents": "目录", "HeaderTools": "工具", "HeaderUpdateAccount": "更新帐户", + "HeaderUpdateApiKey": "更新 API 密钥", "HeaderUpdateAuthor": "更新作者", "HeaderUpdateDetails": "更新详情", "HeaderUpdateLibrary": "更新媒体库", @@ -354,6 +355,7 @@ "LabelExample": "示例", "LabelExpandSeries": "展开系列", "LabelExpandSubSeries": "展开子系列", + "LabelExpired": "已过期", "LabelExpiresAt": "过期时间", "LabelExpiresInSeconds": "有效期(秒)", "LabelExpiresNever": "从不", @@ -623,12 +625,12 @@ "LabelStart": "开始", "LabelStartTime": "开始时间", "LabelStarted": "开始于", - "LabelStartedAt": "从这开始", + "LabelStartedAt": "收听始于", "LabelStatsAudioTracks": "音轨", "LabelStatsAuthors": "作者", "LabelStatsBestDay": "单日最高", "LabelStatsDailyAverage": "每日平均值", - "LabelStatsDays": "连续收听", + "LabelStatsDays": "连续", "LabelStatsDaysListened": "收听天数", "LabelStatsHours": "小时", "LabelStatsInARow": "天", @@ -803,7 +805,7 @@ "MessageInvalidAsin": "无效的 ASIN", "MessageItemsSelected": "已选定 {0} 个项目", "MessageItemsUpdated": "已更新 {0} 个项目", - "MessageJoinUsOn": "加入我们", + "MessageJoinUsOn": "加入我们的", "MessageLoading": "正在加载...", "MessageLoadingFolders": "加载文件夹...", "MessageLogsDescription": "日志以 JSON 文件形式存储在 /metadata/logs 目录中. 崩溃日志存储在 /metadata/logs/crash_logs.txt 目录中.", @@ -864,7 +866,7 @@ "MessageRemoveEpisodes": "移除 {0} 剧集", "MessageRemoveFromPlayerQueue": "从播放队列中移除", "MessageRemoveUserWarning": "是否确实要永久删除用户 \"{0}\"?", - "MessageReportBugsAndContribute": "报告错误、请求功能和贡献在", + "MessageReportBugsAndContribute": "反馈问题、建议功能或参与贡献,请访问", "MessageResetChaptersConfirm": "你确定要重置章节并撤消你所做的更改吗?", "MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份", "MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.

备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.

将自动刷新使用服务器的所有客户端.", From a8c2f0d4c83432c45243e9dbab123c811a21b4b7 Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 15 Jul 2025 00:08:06 +0200 Subject: [PATCH 143/182] Translated using Weblate (German) Currently translated at 99.9% (1127 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/de.json b/client/strings/de.json index 0e62a2a0..1b7decf7 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -1,5 +1,6 @@ { "ButtonAdd": "Hinzufügen", + "ButtonAddApiKey": "API-Schlüssel hinzufügen", "ButtonAddChapters": "Kapitel hinzufügen", "ButtonAddDevice": "Gerät hinzufügen", "ButtonAddLibrary": "Bibliothek hinzufügen", @@ -709,6 +710,7 @@ "MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von Apprise API laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann.
Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter http://192.168.1.1:8337 läuft, würdest du http://192.168.1.1:8337/notify eingeben.", "MessageAsinCheck": "Stellen Sie sicher, dass Sie die ASIN aus der richtigen Audible Region verwenden, nicht Amazon.", "MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.", + "MessageAuthenticationSecurityMessage": "Die Anmeldung wurde abgesichert. Benutzersitzungen werden getrennt, alle Benutzer müssen sich erneut anmelden.", "MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in /metadata/items & /metadata/authors gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.", "MessageBackupsLocationEditNote": "Hinweis: Durch das Aktualisieren des Backup-Speicherorts werden vorhandene Sicherungen nicht verschoben oder geändert", "MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.", From 0bc441de20525ea2e8fa27feaf2259f1e33f4dbe Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 15 Jul 2025 00:17:34 +0200 Subject: [PATCH 144/182] Translated using Weblate (German) Currently translated at 99.9% (1127 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 1b7decf7..761b95e3 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -21,6 +21,7 @@ "ButtonChooseAFolder": "Wähle einen Ordner", "ButtonChooseFiles": "Wähle eine Datei", "ButtonClearFilter": "Filter löschen", + "ButtonClose": "Schließen", "ButtonCloseFeed": "Feed schließen", "ButtonCloseSession": "Offene Sitzung schließen", "ButtonCollections": "Sammlungen", @@ -120,6 +121,7 @@ "HeaderAccount": "Konto", "HeaderAddCustomMetadataProvider": "Benutzerdefinierten Metadatenanbieter hinzufügen", "HeaderAdvanced": "Erweitert", + "HeaderApiKeys": "API-Schlüssel", "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", "HeaderAudioTracks": "Audiodateien", "HeaderAudiobookTools": "Hörbuch-Dateiverwaltungswerkzeuge", @@ -163,6 +165,7 @@ "HeaderMetadataOrderOfPrecedence": "Metadaten Rangfolge", "HeaderMetadataToEmbed": "Einzubettende Metadaten", "HeaderNewAccount": "Neues Konto", + "HeaderNewApiKey": "Neuen API-Schlüssel erstellen", "HeaderNewLibrary": "Neue Bibliothek", "HeaderNotificationCreate": "Benachrichtigung erstellen", "HeaderNotificationUpdate": "Benachrichtigung bearbeiten", @@ -207,6 +210,7 @@ "HeaderTableOfContents": "Inhaltsverzeichnis", "HeaderTools": "Werkzeuge", "HeaderUpdateAccount": "Konto aktualisieren", + "HeaderUpdateApiKey": "API-Schlüssel aktualisieren", "HeaderUpdateAuthor": "Autor aktualisieren", "HeaderUpdateDetails": "Details aktualisieren", "HeaderUpdateLibrary": "Bibliothek aktualisieren", @@ -236,6 +240,7 @@ "LabelAllUsersExcludingGuests": "Alle Benutzer außer Gästen", "LabelAllUsersIncludingGuests": "Alle Benutzer und Gäste", "LabelAlreadyInYourLibrary": "Bereits in der Bibliothek", + "LabelApiKeyCreated": "API-Schlüssel \"{0}\" erfolgreich erstellt", "LabelApiToken": "API Schlüssel", "LabelAppend": "Anhängen", "LabelAudioBitrate": "Audiobitrate (z. B. 128 kbit/s)", @@ -710,7 +715,7 @@ "MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von Apprise API laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann.
Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter http://192.168.1.1:8337 läuft, würdest du http://192.168.1.1:8337/notify eingeben.", "MessageAsinCheck": "Stellen Sie sicher, dass Sie die ASIN aus der richtigen Audible Region verwenden, nicht Amazon.", "MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.", - "MessageAuthenticationSecurityMessage": "Die Anmeldung wurde abgesichert. Benutzersitzungen werden getrennt, alle Benutzer müssen sich erneut anmelden.", + "MessageAuthenticationSecurityMessage": "Die Anmeldung wurde abgesichert. Benutzersitzungen werden getrennt, alle Benutzer müssen sich erneut anmelden.", "MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in /metadata/items & /metadata/authors gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.", "MessageBackupsLocationEditNote": "Hinweis: Durch das Aktualisieren des Backup-Speicherorts werden vorhandene Sicherungen nicht verschoben oder geändert", "MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.", From 3e8fe2ef60efdb2adf20717da763f09ed7825611 Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 15 Jul 2025 00:18:33 +0200 Subject: [PATCH 145/182] Translated using Weblate (German) Currently translated at 99.9% (1127 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 761b95e3..4fadc506 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -240,7 +240,8 @@ "LabelAllUsersExcludingGuests": "Alle Benutzer außer Gästen", "LabelAllUsersIncludingGuests": "Alle Benutzer und Gäste", "LabelAlreadyInYourLibrary": "Bereits in der Bibliothek", - "LabelApiKeyCreated": "API-Schlüssel \"{0}\" erfolgreich erstellt", + "LabelApiKeyCreated": "API-Schlüssel \"{0}\" erfolgreich erstellt.", + "LabelApiKeyCreatedDescription": "Speichere den API-Schlüssel an einem sicheren Ort. Du wirst ihn später nicht mehr abrufen können !", "LabelApiToken": "API Schlüssel", "LabelAppend": "Anhängen", "LabelAudioBitrate": "Audiobitrate (z. B. 128 kbit/s)", From 3e75acd4efb68f1d956bbd52a43fea94478d94ef Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 15 Jul 2025 00:21:35 +0200 Subject: [PATCH 146/182] Translated using Weblate (German) Currently translated at 99.9% (1127 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 4fadc506..9639d5c5 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -241,7 +241,9 @@ "LabelAllUsersIncludingGuests": "Alle Benutzer und Gäste", "LabelAlreadyInYourLibrary": "Bereits in der Bibliothek", "LabelApiKeyCreated": "API-Schlüssel \"{0}\" erfolgreich erstellt.", - "LabelApiKeyCreatedDescription": "Speichere den API-Schlüssel an einem sicheren Ort. Du wirst ihn später nicht mehr abrufen können !", + "LabelApiKeyCreatedDescription": "Speichere den API-Schlüssel an einem sicheren Ort, du wirst ihn später nicht mehr abrufen können.", + "LabelApiKeyUser": "Stellvertretend für einen Nutzer handeln", + "LabelApiKeyUserDescription": "Dieser API-Schlüssel hat die gleichen Berechtigungen wie der Benutzer, in dessen Namen er erstellt wurde .In den Protokollen wird es aussehen, als ob der Benutzer die Anfrage durchführte.", "LabelApiToken": "API Schlüssel", "LabelAppend": "Anhängen", "LabelAudioBitrate": "Audiobitrate (z. B. 128 kbit/s)", From 4f658017135d1643ca90451c768b9c36c09dc30d Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 15 Jul 2025 00:23:21 +0200 Subject: [PATCH 147/182] Translated using Weblate (German) Currently translated at 99.9% (1127 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 9639d5c5..d7a59e9e 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -242,7 +242,7 @@ "LabelAlreadyInYourLibrary": "Bereits in der Bibliothek", "LabelApiKeyCreated": "API-Schlüssel \"{0}\" erfolgreich erstellt.", "LabelApiKeyCreatedDescription": "Speichere den API-Schlüssel an einem sicheren Ort, du wirst ihn später nicht mehr abrufen können.", - "LabelApiKeyUser": "Stellvertretend für einen Nutzer handeln", + "LabelApiKeyUser": "Im Kontext eines Nutzers agieren", "LabelApiKeyUserDescription": "Dieser API-Schlüssel hat die gleichen Berechtigungen wie der Benutzer, in dessen Namen er erstellt wurde .In den Protokollen wird es aussehen, als ob der Benutzer die Anfrage durchführte.", "LabelApiToken": "API Schlüssel", "LabelAppend": "Anhängen", @@ -355,6 +355,10 @@ "LabelExample": "Beispiel", "LabelExpandSeries": "Serie ausklappen", "LabelExpandSubSeries": "Unterserie ausklappen", + "LabelExpired": "Abgelaufen", + "LabelExpiresAt": "Läuft ab am", + "LabelExpiresInSeconds": "Ablauf in (seconds) Sekunden", + "LabelExpiresNever": "Niemals", "LabelExplicit": "Explizit (Altersbeschränkung)", "LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)", "LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)", @@ -464,6 +468,7 @@ "LabelNewestEpisodes": "Neueste Episoden", "LabelNextBackupDate": "Nächstes Sicherungsdatum", "LabelNextScheduledRun": "Nächster planmäßiger Durchlauf", + "LabelNoApiKeys": "Keine API-Schlüssel vorhanden", "LabelNoCustomMetadataProviders": "Keine benutzerdefinierten Metadata Anbieter", "LabelNoEpisodesSelected": "Keine Episoden ausgewählt", "LabelNotFinished": "Nicht beendet", @@ -553,6 +558,7 @@ "LabelSelectAll": "Alles auswählen", "LabelSelectAllEpisodes": "Alle Episoden auswählen", "LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt", + "LabelSelectUser": "Benutzer auswählen", "LabelSelectUsers": "Benutzer auswählen", "LabelSendEbookToDevice": "E-Buch senden an …", "LabelSequence": "Reihenfolge", From 889ee333201335974eb38f5d3471e68ac138af7c Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 15 Jul 2025 00:25:02 +0200 Subject: [PATCH 148/182] Translated using Weblate (German) Currently translated at 99.9% (1127 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index d7a59e9e..75ec6aeb 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -558,7 +558,7 @@ "LabelSelectAll": "Alles auswählen", "LabelSelectAllEpisodes": "Alle Episoden auswählen", "LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt", - "LabelSelectUser": "Benutzer auswählen", + "LabelSelectUser": "Ausgewählter Benutzer", "LabelSelectUsers": "Benutzer auswählen", "LabelSendEbookToDevice": "E-Buch senden an …", "LabelSequence": "Reihenfolge", @@ -746,6 +746,7 @@ "MessageChaptersNotFound": "Kapitel gefunden nicht", "MessageCheckingCron": "Überprüfe Cron...", "MessageConfirmCloseFeed": "Feed wird geschlossen! Bist du dir sicher?", + "MessageConfirmDeleteApiKey": "Möchtest du den API-Schlüssel \"{0}\" wirklich entfernen ?", "MessageConfirmDeleteBackup": "Sicherung für {0} wird gelöscht! Bist du dir sicher?", "MessageConfirmDeleteDevice": "Möchtest du das Lesegerät „{0}“ wirklich löschen?", "MessageConfirmDeleteFile": "Datei wird vom System gelöscht! Bist du dir sicher?", @@ -1017,6 +1018,7 @@ "ToastEpisodeDownloadQueueClearSuccess": "Warteschlange für Episoden-Downloads gelöscht", "ToastEpisodeUpdateSuccess": "{0} Episoden aktualisiert", "ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät freigegeben werden", + "ToastFailedToCreate": "Wurde nicht erstellt", "ToastFailedToLoadData": "Daten laden fehlgeschlagen", "ToastFailedToMatch": "Fehler beim Abgleich", "ToastFailedToShare": "Fehler beim Teilen", From 2760517445551093b403b90617eec7c9a70a09ec Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 15 Jul 2025 00:25:37 +0200 Subject: [PATCH 149/182] Translated using Weblate (German) Currently translated at 99.9% (1127 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 75ec6aeb..4ae808a4 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -1018,8 +1018,9 @@ "ToastEpisodeDownloadQueueClearSuccess": "Warteschlange für Episoden-Downloads gelöscht", "ToastEpisodeUpdateSuccess": "{0} Episoden aktualisiert", "ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät freigegeben werden", - "ToastFailedToCreate": "Wurde nicht erstellt", - "ToastFailedToLoadData": "Daten laden fehlgeschlagen", + "ToastFailedToCreate": "Fehler beim erzeugen", + "ToastFailedToDelete": "Fehler beim löschen", + "ToastFailedToLoadData": "Fehler beim laden der Daten", "ToastFailedToMatch": "Fehler beim Abgleich", "ToastFailedToShare": "Fehler beim Teilen", "ToastFailedToUpdate": "Aktualisierung ist fehlgeschlagen", From 8e891805eb4c84027ade87946f72908d7018e82b Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 15 Jul 2025 00:26:55 +0200 Subject: [PATCH 150/182] Translated using Weblate (German) Currently translated at 99.9% (1127 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 4ae808a4..e8d01c47 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -1018,8 +1018,8 @@ "ToastEpisodeDownloadQueueClearSuccess": "Warteschlange für Episoden-Downloads gelöscht", "ToastEpisodeUpdateSuccess": "{0} Episoden aktualisiert", "ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät freigegeben werden", - "ToastFailedToCreate": "Fehler beim erzeugen", - "ToastFailedToDelete": "Fehler beim löschen", + "ToastFailedToCreate": "Fehler beim Erzeugen", + "ToastFailedToDelete": "Fehler beim Löschen", "ToastFailedToLoadData": "Fehler beim laden der Daten", "ToastFailedToMatch": "Fehler beim Abgleich", "ToastFailedToShare": "Fehler beim Teilen", @@ -1051,6 +1051,7 @@ "ToastMustHaveAtLeastOnePath": "Es muss mindestens ein Pfad angegeben werden", "ToastNameEmailRequired": "Name und E-Mail sind erforderlich", "ToastNameRequired": "Name ist erforderlich", + "ToastNewApiKeyUserError": "Bitte wähle einen Benutzer aus (Pflichtfeld)", "ToastNewEpisodesFound": "{0} neue Episoden gefunden", "ToastNewUserCreatedFailed": "Fehler beim erstellen des Accounts: \"{ 0}\"", "ToastNewUserCreatedSuccess": "Neuer Account erstellt", From 9aca824b5959d6b514a64ad8b51c44d06e0d47cc Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 15 Jul 2025 00:29:48 +0200 Subject: [PATCH 151/182] Translated using Weblate (German) Currently translated at 99.9% (1127 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index e8d01c47..c455a120 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -359,7 +359,7 @@ "LabelExpiresAt": "Läuft ab am", "LabelExpiresInSeconds": "Ablauf in (seconds) Sekunden", "LabelExpiresNever": "Niemals", - "LabelExplicit": "Explizit (Altersbeschränkung)", + "LabelExplicit": "Explizit", "LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)", "LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)", "LabelExportOPML": "OPML exportieren", From bdb9d3caebcfd61beb091feef594cdde4c617ee8 Mon Sep 17 00:00:00 2001 From: Fredrik Lindqvist Date: Tue, 15 Jul 2025 11:38:02 +0200 Subject: [PATCH 152/182] Translated using Weblate (Swedish) Currently translated at 95.2% (1074 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 808f2270..17254a50 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -241,6 +241,7 @@ "LabelAlreadyInYourLibrary": "Finns redan i samlingen", "LabelApiKeyCreated": "API-nyckel \"{0}\" har adderats.", "LabelApiKeyCreatedDescription": "Se till att kopiera API-nyckeln omedelbart eftersom du inte kommer att kunna se den igen.", + "LabelApiKeyUserDescription": "Denna API-nyckel kommer att ha samma behörigheter som användaren den agerar på uppdrag av. Detta kommer att visas på samma sätt i loggarna som om användaren gjorde begäran.", "LabelApiToken": "API-token", "LabelAppend": "Lägg till", "LabelAudioBitrate": "Bitrate (t.ex. 128k)", @@ -356,7 +357,7 @@ "LabelExpiresAt": "Gäller till och med", "LabelExpiresInSeconds": "Upphör om (sekunder)", "LabelExpiresNever": "Aldrig", - "LabelExplicit": "Explicit version", + "LabelExplicit": "Bestämd", "LabelExplicitChecked": "Explicit version (markerad)", "LabelExplicitUnchecked": "Ej Explicit version (ej markerad)", "LabelExportOPML": "Exportera OPML-information", @@ -482,12 +483,15 @@ "LabelNotificationsMaxQueueSizeHelp": "Evenemang är begränsade till att utlösa ett per sekund. Evenemang kommer att ignoreras om kön är full. Detta förhindrar aviseringsspam.", "LabelNumberOfBooks": "Antal böcker", "LabelNumberOfEpisodes": "# av Avsnitt", + "LabelOpenIDAdvancedPermsClaimDescription": "Namn på OpenID-anspråket som innehåller avancerade behörigheter för användaråtgärder i applikationen, vilka gäller för icke-administratörsroller (om konfigurerat). Om anspråket saknas i svaret kommer åtkomst till ABS att nekas. Om ett enskilt alternativ saknas kommer det att behandlas som falskt. Se till att identitetsleverantörens anspråk matchar den förväntade strukturen:", + "LabelOpenIDClaims": "Lämna följande alternativ tomma för att inaktivera avancerad grupp- och behörighetstilldelning, och tilldela då automatiskt gruppen 'Användare'.", + "LabelOpenIDGroupClaimDescription": "Namn på OpenID-anspråket som innehåller en lista över användarens grupper. Vanligtvis kallat groups. Om det är konfigurerat kommer programmet automatiskt att tilldela roller baserat på användarens gruppmedlemskap, förutsatt att dessa grupper namnges utan att skiftlägeskänsligt tolkas som 'admin', 'user' eller 'guest' i anspråket. Anspråket ska innehålla en lista, och om en användare tillhör flera grupper kommer programmet att tilldela den roll som motsvarar den högsta åtkomstnivån. Om ingen grupp matchar kommer åtkomst att nekas.", "LabelOpenRSSFeed": "Öppna RSS-flöde", "LabelOverwrite": "Skriv över", "LabelPaginationPageXOfY": "Sida {0} av {1}", "LabelPassword": "Lösenord", "LabelPath": "Sökväg", - "LabelPermanent": "", + "LabelPermanent": "Permanent", "LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek", "LabelPermissionsAccessAllTags": "Kan komma åt alla taggar", "LabelPermissionsAccessExplicitContent": "Kan komma åt explicit version", @@ -499,6 +503,7 @@ "LabelPersonalYearReview": "En sammanställning av ditt år, sidan {0}", "LabelPhotoPathURL": "Bildsökväg/URL", "LabelPlayMethod": "Spelläge", + "LabelPlaybackRateIncrementDecrement": "Uppspelningshastighetsökning/minskning", "LabelPlayerChapterNumberMarker": "{0} av {1}", "LabelPlaylists": "Spellistor", "LabelPodcast": "Podcast", @@ -537,6 +542,7 @@ "LabelReleaseDate": "Utgivningsdatum", "LabelRemoveAllMetadataAbs": "Radera alla 'metadata.abs' filer", "LabelRemoveAllMetadataJson": "Radera alla 'metadata.json' filer", + "LabelRemoveAudibleBranding": "Ta bort Audible intro och outro från kapitel", "LabelRemoveCover": "Ta bort omslag", "LabelRemoveMetadataFile": "Radera metadata-filer i alla mappar i biblioteket", "LabelRemoveMetadataFileHelp": "Radera alla 'metadata.json' och 'metadata.abs' filer i dina {0} mappar.", From 5b4fd5b254362244bbdadcf4b59e98555448b2b4 Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Tue, 15 Jul 2025 03:01:07 +0200 Subject: [PATCH 153/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1128 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 6fca0fa0..84ae64a9 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -871,7 +871,7 @@ "MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份", "MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.

备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.

将自动刷新使用服务器的所有客户端.", "MessageScheduleLibraryScanNote": "对于大多数用户, 建议禁用此功能并保持文件夹监视程序设置启用. 文件夹监视程序将自动检测库文件夹中的更改. 文件夹监视程序不适用于每个文件系统 (如 NFS), 因此可以使用计划库扫描.", - "MessageScheduleRunEveryWeekdayAtTime": "每隔 {0} 在 {1} 运行一次", + "MessageScheduleRunEveryWeekdayAtTime": "每 {0} 的 {1} 执行", "MessageSearchResultsFor": "搜索结果", "MessageSelected": "{0} 已选择", "MessageSeriesSequenceCannotContainSpaces": "系列序列不能包含空格", From 0dccc3bcae19d2bc8ff1a7ba0022c64a9019c2e0 Mon Sep 17 00:00:00 2001 From: Grzegorz Orlowski Date: Tue, 15 Jul 2025 23:19:03 +0200 Subject: [PATCH 154/182] Translated using Weblate (Polish) Currently translated at 76.0% (858 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/ --- client/strings/pl.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/strings/pl.json b/client/strings/pl.json index 7248ae96..0571ffae 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -1,5 +1,6 @@ { "ButtonAdd": "Dodaj", + "ButtonAddApiKey": "Dodaj klucz API", "ButtonAddChapters": "Dodaj rozdziały", "ButtonAddDevice": "Dodaj urządzenie", "ButtonAddLibrary": "Dodaj bibliotekę", @@ -20,6 +21,7 @@ "ButtonChooseAFolder": "Wybierz folder", "ButtonChooseFiles": "Wybierz pliki", "ButtonClearFilter": "Wyczyść filtr", + "ButtonClose": "Zamknij", "ButtonCloseFeed": "Zamknij kanał", "ButtonCloseSession": "Zamknij otwartą sesję", "ButtonCollections": "Kolekcje", @@ -119,6 +121,7 @@ "HeaderAccount": "Konto", "HeaderAddCustomMetadataProvider": "Dodaj niestandardowego dostawcę metadanych", "HeaderAdvanced": "Zaawansowane", + "HeaderApiKeys": "Klucze API", "HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise", "HeaderAudioTracks": "Ścieżki audio", "HeaderAudiobookTools": "Narzędzia do zarządzania audiobookami", @@ -162,6 +165,7 @@ "HeaderMetadataOrderOfPrecedence": "Kolejność metadanych", "HeaderMetadataToEmbed": "Metadane do osadzenia", "HeaderNewAccount": "Nowe konto", + "HeaderNewApiKey": "Nowy klucz API", "HeaderNewLibrary": "Nowa biblioteka", "HeaderNotificationCreate": "Utwórz powiadomienie", "HeaderNotificationUpdate": "Zaktualizuj powiadomienie", @@ -206,6 +210,7 @@ "HeaderTableOfContents": "Spis treści", "HeaderTools": "Narzędzia", "HeaderUpdateAccount": "Zaktualizuj konto", + "HeaderUpdateApiKey": "Aktualizuj klucz API", "HeaderUpdateAuthor": "Zaktualizuj autorów", "HeaderUpdateDetails": "Zaktualizuj szczegóły", "HeaderUpdateLibrary": "Zaktualizuj bibliotekę", @@ -235,6 +240,7 @@ "LabelAllUsersExcludingGuests": "Wszyscy użytkownicy z wyłączeniem gości", "LabelAllUsersIncludingGuests": "Wszyscy użytkownicy, łącznie z gośćmi", "LabelAlreadyInYourLibrary": "Już istnieje w twojej bibliotece", + "LabelApiKeyCreated": "Klucz API \"{0}\" został pomyślnie utworzony.", "LabelApiToken": "API Token", "LabelAppend": "Dołącz", "LabelAudioBitrate": "Audio Bitrate (np. 128k)", @@ -324,6 +330,9 @@ "LabelEmbeddedCover": "Wbudowana okładka", "LabelEnable": "Włącz", "LabelEncodingBackupLocation": "Kopia zapasowa twoich oryginalnych plików audio będzie się znajdować w:", + "LabelEncodingChaptersNotEmbedded": "W audiobookach wielościeżkowych rozdziały nie są osadzone.", + "LabelEncodingClearItemCache": "Pamiętaj o okresowym czyszczeniu pamięci podręcznej elementów.", + "LabelEncodingFinishedM4B": "Ukończony plik M4B zostanie umieszczony w folderze audiobooka pod adresem:", "LabelEnd": "Zakończ", "LabelEndOfChapter": "Koniec rozdziału", "LabelEpisode": "Odcinek", From f963cd4753d6f8f22a80869ae269b91e40aa6f68 Mon Sep 17 00:00:00 2001 From: Grzegorz Orlowski Date: Tue, 15 Jul 2025 23:33:29 +0200 Subject: [PATCH 155/182] Translated using Weblate (Polish) Currently translated at 76.1% (859 of 1128 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/ --- client/strings/pl.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/pl.json b/client/strings/pl.json index 0571ffae..451112bb 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -333,6 +333,7 @@ "LabelEncodingChaptersNotEmbedded": "W audiobookach wielościeżkowych rozdziały nie są osadzone.", "LabelEncodingClearItemCache": "Pamiętaj o okresowym czyszczeniu pamięci podręcznej elementów.", "LabelEncodingFinishedM4B": "Ukończony plik M4B zostanie umieszczony w folderze audiobooka pod adresem:", + "LabelEncodingInfoEmbedded": "Metadane zostaną osadzone w ścieżkach audio w folderze z audiobookiem.", "LabelEnd": "Zakończ", "LabelEndOfChapter": "Koniec rozdziału", "LabelEpisode": "Odcinek", From b911a25c573c537551bae234743eaab449c836b1 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 16 Jul 2025 17:16:43 -0500 Subject: [PATCH 156/182] Version bump v2.26.1 --- client/package-lock.json | 2 +- client/package.json | 2 +- package-lock.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 406ef9db..d321ce58 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.26.0", + "version": "2.26.1", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/client/package.json b/client/package.json index 5ebaab54..1aa9b384 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.26.0", + "version": "2.26.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 5787f713..cdf40fe4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.26.0", + "version": "2.26.1", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 586e8d44..85264c1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.26.0", + "version": "2.26.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 5b6807892f7e624f0f2a4bde9de786b9033eaa34 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 18 Jul 2025 16:59:27 -0500 Subject: [PATCH 157/182] Fix set token on page load #4509 --- client/pages/login.vue | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 01adadcd..b6235845 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -191,7 +191,10 @@ export default { this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) this.$store.commit('user/setUser', user) - this.$store.commit('user/setAccessToken', user.accessToken) + // Access token only returned from login, not authorize + if (user.accessToken) { + this.$store.commit('user/setAccessToken', user.accessToken) + } this.$store.dispatch('user/loadUserSettings') }, @@ -225,6 +228,8 @@ export default { this.processing = true + this.$store.commit('user/setAccessToken', token) + return this.$axios .$post('/api/authorize', null, { headers: { @@ -240,6 +245,7 @@ export default { this.showNewAuthSystemAdminMessage = res.user.type === 'admin' || res.user.type === 'root' return false } + this.setUser(res) return true }) From 8c4bbfd6a2fc8c92e2a9e862b3e4e24b73d1df24 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 21 Jul 2025 16:52:21 -0500 Subject: [PATCH 158/182] Add match confidence as a badge on match book card --- client/components/cards/BookMatchCard.vue | 15 +++++++++++---- client/strings/en-us.json | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/client/components/cards/BookMatchCard.vue b/client/components/cards/BookMatchCard.vue index 17f49752..09b963c5 100644 --- a/client/components/cards/BookMatchCard.vue +++ b/client/components/cards/BookMatchCard.vue @@ -13,10 +13,17 @@

{{ book.publishedYear }}

-

{{ $getString('LabelByAuthor', [book.author]) }}

-

{{ $strings.LabelNarrators }}: {{ book.narrator }}

-

{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}

-

{{ $strings.LabelMatchConfidence }}: {{ book.matchConfidence.toFixed(3) }}

+ +
+
+

{{ $getString('LabelByAuthor', [book.author]) }}

+

{{ $strings.LabelNarrators }}: {{ book.narrator }}

+

{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}

+
+
+
{{ $strings.LabelMatchConfidence }}: {{ (book.matchConfidence * 100).toFixed(0) }}%
+
+

diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 000ab86c..0fd9ae82 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -438,7 +438,7 @@ "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelLowestPriority": "Lowest Priority", - "LabelMatchConfidence": "Match Confidence", + "LabelMatchConfidence": "Confidence", "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", "LabelMaxEpisodesToDownload": "Max # of episodes to download. Use 0 for unlimited.", From 23bf2594c82a96dbe8de96310f63e2cf5e7046f6 Mon Sep 17 00:00:00 2001 From: biuklija Date: Thu, 17 Jul 2025 07:31:42 +0200 Subject: [PATCH 159/182] Translated using Weblate (Croatian) Currently translated at 100.0% (1129 of 1129 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/hr.json b/client/strings/hr.json index 40d9cf5b..5289f803 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -723,6 +723,7 @@ "MessageAddToPlayerQueue": "Dodaj u redoslijed izvođenja", "MessageAppriseDescription": "Da biste se koristili ovom značajkom, treba vam instanca Apprise API-ja ili API koji može rukovati istom vrstom zahtjeva.
The Adresa Apprise API-ja treba biti puna URL putanja za slanje obavijesti, npr. ako vam se API instanca poslužuje na adresi http://192.168.1.1:8337 trebate upisati http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Upišite ASIN iz odgovarajuće Audibleove regije, ne s Amazonov.", + "MessageAuthenticationLegacyTokenWarning": "Starije API tokene ćemo ukloniti. Umjesto njih, koristite se API ključevima .", "MessageAuthenticationOIDCChangesRestart": "Ponovno pokrenite poslužitelj da biste primijenili OIDC promjene.", "MessageAuthenticationSecurityMessage": "Provjera autentičnosti poboljšana je radi sigurnosti. Svi se korisnici moraju ponovno prijaviti.", "MessageBackupsDescription": "Sigurnosne kopije sadrže korisnike, korisnikov napredak medija, pojedinosti knjižničke građe, postavke poslužitelja i slike koje se spremaju u /metadata/items & /metadata/authors. Sigurnosne kopije ne sadrže niti jednu datoteku iz mapa knjižnice.", From 3584f6e24fa677e024f1b9933daec452c4c6aee8 Mon Sep 17 00:00:00 2001 From: Angelo Prandelli Date: Thu, 17 Jul 2025 23:21:30 +0200 Subject: [PATCH 160/182] Translated using Weblate (Italian) Currently translated at 98.2% (1109 of 1129 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/ --- client/strings/it.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/strings/it.json b/client/strings/it.json index 77e4faee..7f4fd7c7 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -1,18 +1,19 @@ { "ButtonAdd": "Aggiungi", + "ButtonAddApiKey": "Aggiungi chiave API", "ButtonAddChapters": "Aggiungi Capitoli", "ButtonAddDevice": "Aggiungi Dispositivo", "ButtonAddLibrary": "Aggiungi Libreria", "ButtonAddPodcasts": "Aggiungi Podcast", - "ButtonAddUser": "Aggiungi User", + "ButtonAddUser": "Aggiungi Utente", "ButtonAddYourFirstLibrary": "Aggiungi la tua prima libreria", "ButtonApply": "Applica", - "ButtonApplyChapters": "Applica", + "ButtonApplyChapters": "Applica Capitoli", "ButtonAuthors": "Autori", "ButtonBack": "Indietro", "ButtonBatchEditPopulateFromExisting": "Popola da esistente", "ButtonBatchEditPopulateMapDetails": "Inserisci i dettagli della mappa", - "ButtonBrowseForFolder": "Per Cartella", + "ButtonBrowseForFolder": "Sfoglia per Cartella", "ButtonCancel": "Annulla", "ButtonCancelEncode": "Ferma la codifica", "ButtonChangeRootPassword": "Cambia la Password di root", @@ -20,6 +21,7 @@ "ButtonChooseAFolder": "Seleziona la Cartella", "ButtonChooseFiles": "Seleziona i File", "ButtonClearFilter": "Elimina filtri", + "ButtonClose": "Chiudi", "ButtonCloseFeed": "Chiudi flusso", "ButtonCloseSession": "Chiudi la sessione aperta", "ButtonCollections": "Raccolte", From 55f0ac871bce5271b67130b8ed9c2ffcb433c151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Thu, 17 Jul 2025 06:41:18 +0200 Subject: [PATCH 161/182] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1129 of 1129 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index 10724bad..c7f76089 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -723,6 +723,7 @@ "MessageAddToPlayerQueue": "Додати до черги відтворення", "MessageAppriseDescription": "Щоб скористатися цією функцією, вам потрібно мати запущену Apprise API або API, що оброблятиме ті ж запити.
Аби надсилати сповіщення, URL-адреса API Apprise мусить бути повною, наприклад, якщо ваш API розміщено за адресою http://192.168.1.1:8337, то необхідно вказати адресу http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Переконайтесь, що ви використовуєте ASIN з правильної регіональної Audible зони, а не з Amazon.", + "MessageAuthenticationLegacyTokenWarning": "Застарілі токени API будуть видалені в майбутньому. Натомість використовуйте Ключі API.", "MessageAuthenticationOIDCChangesRestart": "Перезавантажте сервер після збереження, щоб застосувати зміни OIDC.", "MessageAuthenticationSecurityMessage": "Автентифікацію покращено для безпеки. Усім користувачам потрібно повторно увійти в систему.", "MessageBackupsDescription": "Резервні копії містять користувачів, прогрес, подробиці елементів бібліотеки, налаштування сервера та зображення з /metadata/items та /metadata/authors. Резервні копії не містять жодних файлів з тек бібліотеки.", @@ -836,7 +837,7 @@ "MessageNoItems": "Елементи відсутні", "MessageNoItemsFound": "Елементів не знайдено", "MessageNoListeningSessions": "Сеанси прослуховування відсутні", - "MessageNoLogs": "Немає журналів", + "MessageNoLogs": "Немає журнали", "MessageNoMediaProgress": "Прогрес відсутній", "MessageNoNotifications": "Сповіщення відсутні", "MessageNoPodcastFeed": "Некоректний подкаст: немає каналу", From 1166400ab15a503b296c1f266258431e707a5e1a Mon Sep 17 00:00:00 2001 From: SunSpring Date: Thu, 17 Jul 2025 01:32:05 +0200 Subject: [PATCH 162/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1129 of 1129 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 84ae64a9..09fae381 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -240,10 +240,10 @@ "LabelAllUsersExcludingGuests": "除访客外的所有用户", "LabelAllUsersIncludingGuests": "包括访客的所有用户", "LabelAlreadyInYourLibrary": "已存在你的库中", - "LabelApiKeyCreated": "API 密钥 \"{0}\" 创建成功。", - "LabelApiKeyCreatedDescription": "请确保现在就复制 API 密钥,之后将无法再次查看。", + "LabelApiKeyCreated": "API 密钥 \"{0}\" 创建成功.", + "LabelApiKeyCreatedDescription": "请确保现在就复制 API 密钥, 之后将无法再次查看.", "LabelApiKeyUser": "代用户操作", - "LabelApiKeyUserDescription": "此 API 密钥将具有与其代理的用户相同的权限。在日志中,其请求将被视为由该用户直接发出。", + "LabelApiKeyUserDescription": "此 API 密钥将具有与其代理的用户相同的权限. 在日志中, 其请求将被视为由该用户直接发出.", "LabelApiToken": "API 令牌", "LabelAppend": "附加", "LabelAudioBitrate": "音频比特率 (例如: 128k)", @@ -329,7 +329,7 @@ "LabelEmailSettingsRejectUnauthorized": "拒绝未经授权的证书", "LabelEmailSettingsRejectUnauthorizedHelp": "禁用SSL证书验证可能会使你的连接面临安全风险, 例如中间人攻击. 只有当你了解其中的含义并信任所连接的邮件服务器时, 才能禁用此选项.", "LabelEmailSettingsSecure": "安全", - "LabelEmailSettingsSecureHelp": "开启此选项时,将始终通过TLS连接服务器。关闭此选项时,仅在服务器支持STARTTLS扩展时使用TLS。在大多数情况下,如果连接到端口465,请将此项设为开启。如果连接到端口587或25,请将此设置保持为关闭。(来自nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsSecureHelp": "开启此选项时, 将始终通过TLS连接服务器. 关闭此选项时, 仅在服务器支持STARTTLS扩展时使用TLS. 在大多数情况下, 如果连接到端口465, 请将此项设为开启. 如果连接到端口587或25, 请将此设置保持为关闭. (来自nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "测试地址", "LabelEmbeddedCover": "嵌入封面", "LabelEnable": "启用", @@ -357,10 +357,10 @@ "LabelExpandSubSeries": "展开子系列", "LabelExpired": "已过期", "LabelExpiresAt": "过期时间", - "LabelExpiresInSeconds": "有效期(秒)", + "LabelExpiresInSeconds": "有效期 (秒)", "LabelExpiresNever": "从不", "LabelExplicit": "含成人内容", - "LabelExplicitChecked": "成人内容(已核实)", + "LabelExplicitChecked": "成人内容 (已核实)", "LabelExplicitUnchecked": "无成人内容 (未核实)", "LabelExportOPML": "导出 OPML", "LabelFeedURL": "源 URL", @@ -633,7 +633,7 @@ "LabelStatsDays": "连续", "LabelStatsDaysListened": "收听天数", "LabelStatsHours": "小时", - "LabelStatsInARow": "天", + "LabelStatsInARow": "在一行", "LabelStatsItemsFinished": "已完成的项目", "LabelStatsItemsInLibrary": "媒体库中的项目", "LabelStatsMinutes": "分钟", @@ -723,14 +723,15 @@ "MessageAddToPlayerQueue": "添加到播放队列", "MessageAppriseDescription": "要使用此功能,你需要运行一个 Apprise API 实例或一个可以处理这些相同请求的 API.
Apprise API Url 应该是发送通知的完整 URL 路径, 例如: 如果你的 API 实例运行在 http://192.168.1.1:8337, 那么你可以输入 http://192.168.1.1:8337/notify.", "MessageAsinCheck": "确保你使用的 ASIN 来自正确的 Audible 地区, 而不是亚马逊.", + "MessageAuthenticationLegacyTokenWarning": "旧版 API 令牌将来会被移除. 请改用 API 密钥.", "MessageAuthenticationOIDCChangesRestart": "保存后重新启动服务器以应用 OIDC 更改.", - "MessageAuthenticationSecurityMessage": "身份验证安全性已增强,所有用户都需要重新登录。", + "MessageAuthenticationSecurityMessage": "身份验证安全性已增强, 所有用户都需要重新登录.", "MessageBackupsDescription": "备份包括用户, 用户进度, 媒体库项目详细信息, 服务器设置和图像, 存储在 /metadata/items & /metadata/authors. 备份不包括存储在你的媒体库文件夹中的任何文件.", "MessageBackupsLocationEditNote": "注意: 更新备份位置不会移动或修改现有备份", "MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.", "MessageBackupsLocationPathEmpty": "备份位置路径不能为空", "MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并", - "MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息,填入上方所有勾选的编辑框中", + "MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息, 填入上方所有勾选的编辑框中", "MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.", "MessageBookshelfNoCollections": "你尚未进行任何收藏", "MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.", @@ -746,7 +747,7 @@ "MessageChaptersNotFound": "未找到章节", "MessageCheckingCron": "检查计划任务...", "MessageConfirmCloseFeed": "你确定要关闭此订阅源吗?", - "MessageConfirmDeleteApiKey": "你确定要删除 API 密钥 \"{0}\" 吗?", + "MessageConfirmDeleteApiKey": "你确定要删除 API 密钥 \"{0}\" 吗?", "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteDevice": "你确定要删除电子阅读器设备 \"{0}\" 吗?", "MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?", @@ -774,7 +775,7 @@ "MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?", "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", "MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?", - "MessageConfirmRemoveEpisodeNote": "注意:此操作不会删除音频文件,除非勾选“完全删除文件”选项", + "MessageConfirmRemoveEpisodeNote": "注意: 此操作不会删除音频文件, 除非勾选 \"完全删除文件\" 选项", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveListeningSessions": "你确定要移除 {0} 收听会话吗?", "MessageConfirmRemoveMetadataFiles": "你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?", @@ -866,7 +867,7 @@ "MessageRemoveEpisodes": "移除 {0} 剧集", "MessageRemoveFromPlayerQueue": "从播放队列中移除", "MessageRemoveUserWarning": "是否确实要永久删除用户 \"{0}\"?", - "MessageReportBugsAndContribute": "反馈问题、建议功能或参与贡献,请访问", + "MessageReportBugsAndContribute": "反馈问题, 建议功能或参与贡献, 请访问", "MessageResetChaptersConfirm": "你确定要重置章节并撤消你所做的更改吗?", "MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份", "MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.

备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.

将自动刷新使用服务器的所有客户端.", From e6bfd118f6cbaa3cb3ceaecd2f1574913546b553 Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Fri, 18 Jul 2025 10:11:49 +0200 Subject: [PATCH 163/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1129 of 1129 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 09fae381..5138e84e 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -633,7 +633,7 @@ "LabelStatsDays": "连续", "LabelStatsDaysListened": "收听天数", "LabelStatsHours": "小时", - "LabelStatsInARow": "在一行", + "LabelStatsInARow": "天", "LabelStatsItemsFinished": "已完成的项目", "LabelStatsItemsInLibrary": "媒体库中的项目", "LabelStatsMinutes": "分钟", From 1f23794f8887287c8c5efcd060cccaaaf7189548 Mon Sep 17 00:00:00 2001 From: Simple16 Date: Sat, 19 Jul 2025 11:44:36 +0200 Subject: [PATCH 164/182] Translated using Weblate (Russian) Currently translated at 99.9% (1128 of 1129 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index 3971365c..ce03bdc4 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -357,7 +357,7 @@ "LabelExpandSubSeries": "Развернуть подсерию", "LabelExpired": "Истекший", "LabelExpiresAt": "Истекает в", - "LabelExpiresInSeconds": "Истекает через (seconds)", + "LabelExpiresInSeconds": "Истекает через (секунд)", "LabelExpiresNever": "Никогда", "LabelExplicit": "18+", "LabelExplicitChecked": "18+ (отмечено)", From f293b317bed137f6f5e6b06e05b12b781795d262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serhat=20G=C3=BCla=C5=9Ft=C4=B1?= Date: Sat, 19 Jul 2025 17:03:32 +0200 Subject: [PATCH 165/182] Translated using Weblate (Turkish) Currently translated at 28.3% (320 of 1129 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/tr/ --- client/strings/tr.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/client/strings/tr.json b/client/strings/tr.json index 1f331a3e..13b2bdf0 100644 --- a/client/strings/tr.json +++ b/client/strings/tr.json @@ -1,5 +1,6 @@ { "ButtonAdd": "Ekle", + "ButtonAddApiKey": "API Anahtarı Ekle", "ButtonAddChapters": "Bölüm Ekle", "ButtonAddDevice": "Cihaz Ekle", "ButtonAddLibrary": "Kütüphane Ekle", @@ -20,6 +21,7 @@ "ButtonChooseAFolder": "Klasör seç", "ButtonChooseFiles": "Dosya seç", "ButtonClearFilter": "Filtreyi Temizle", + "ButtonClose": "Kapat", "ButtonCloseFeed": "Akışı Kapat", "ButtonCloseSession": "Acık Oturumu Kapat", "ButtonCollections": "Koleksiyonlar", @@ -95,7 +97,17 @@ "ButtonSearch": "Ara", "ButtonSelectFolderPath": "Klasör Yolunu Seç", "ButtonSeries": "Seriler", + "ButtonShare": "Paylaş", + "ButtonStats": "İstatistikler", "ButtonSubmit": "Gönder", + "ButtonTest": "Dene", + "ButtonUnlinkOpenId": "OpenID ilişiğini kaldır", + "ButtonUpload": "Yükle", + "ButtonUploadBackup": "Yedeği Yükle", + "ButtonUploadCover": "Kapağı Yükle", + "ButtonUploadOPMLFile": "OPML Dosyası Yükle", + "ButtonUserDelete": "{0} kullanıcısını sil.", + "ButtonUserEdit": "{0} kullanıcısını düzenle", "ButtonViewAll": "Tümünü Görüntüle", "ButtonYes": "Evet", "ErrorUploadFetchMetadataAPI": "Üst veriyi almakta hata", @@ -104,6 +116,7 @@ "HeaderAccount": "Hesap", "HeaderAddCustomMetadataProvider": "Özel Üstveri Sağlayıcısı Ekle", "HeaderAdvanced": "Gelişmiş", + "HeaderApiKeys": "API Anahtarları", "HeaderAppriseNotificationSettings": "Bildirim Ayarlarının Haberini Ver", "HeaderAudioTracks": "Ses Kanalları", "HeaderAudiobookTools": "Sesli Kitap Dosya Yönetim Araçları", @@ -111,13 +124,23 @@ "HeaderBackups": "Yedeklemeler", "HeaderChangePassword": "Parolayı Değiştir", "HeaderChapters": "Bölümler", + "HeaderChooseAFolder": "Klasör Seç", "HeaderCollection": "Koleksiyon", "HeaderCollectionItems": "Koleksiyon Öğeleri", + "HeaderCover": "Kapak", + "HeaderCurrentDownloads": "Geçerli İndirmeler", + "HeaderCustomMessageOnLogin": "Girişteki Kişiselleştirilmiş Mesaj", + "HeaderCustomMetadataProviders": "Kişiselleştirilmiş Metadata Sağlayıcıları", "HeaderDetails": "Detaylar", + "HeaderDownloadQueue": "Kuyruktakileri İndir", "HeaderEbookFiles": "Ebook Dosyaları", + "HeaderEmail": "Email", + "HeaderEmailSettings": "Email Ayarları", "HeaderEpisodes": "Bölümler", + "HeaderEreaderDevices": "Ekitap Cihazları", "HeaderEreaderSettings": "Ereader Ayarları", "HeaderFiles": "Dosyalar", + "HeaderFindChapters": "Bölümleri Bul", "HeaderIgnoredFiles": "Görmezden Gelinen Dosyalar", "HeaderItemFiles": "Öğe Dosyaları", "HeaderItemMetadataUtils": "Öğe Üstveri Araçları", From 70d67156f0abdcea34433788dffe53be6e45dd09 Mon Sep 17 00:00:00 2001 From: kuci-JK Date: Mon, 21 Jul 2025 19:45:55 +0200 Subject: [PATCH 166/182] Translated using Weblate (Czech) Currently translated at 99.2% (1121 of 1129 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/client/strings/cs.json b/client/strings/cs.json index 7da79857..547806d3 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -1,5 +1,6 @@ { "ButtonAdd": "Přidat", + "ButtonAddApiKey": "Přidat API klíč", "ButtonAddChapters": "Přidat kapitoly", "ButtonAddDevice": "Přidat zařízení", "ButtonAddLibrary": "Přidat knihovnu", @@ -20,6 +21,7 @@ "ButtonChooseAFolder": "Vybrat složku", "ButtonChooseFiles": "Vybrat soubory", "ButtonClearFilter": "Vymazat filtr", + "ButtonClose": "Zavřít", "ButtonCloseFeed": "Zavřít kanál", "ButtonCloseSession": "Zavřít otevřenou relaci", "ButtonCollections": "Kolekce", @@ -119,6 +121,7 @@ "HeaderAccount": "Účet", "HeaderAddCustomMetadataProvider": "Přidat vlastního poskytovatele metadat", "HeaderAdvanced": "Pokročilé", + "HeaderApiKeys": "API klíče", "HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise", "HeaderAudioTracks": "Zvukové stopy", "HeaderAudiobookTools": "Nástroje pro správu souborů audioknih", @@ -162,6 +165,7 @@ "HeaderMetadataOrderOfPrecedence": "Pořadí priorit metadat", "HeaderMetadataToEmbed": "Metadata k vložení", "HeaderNewAccount": "Nový účet", + "HeaderNewApiKey": "Nový API klíč", "HeaderNewLibrary": "Nová knihovna", "HeaderNotificationCreate": "Vytvořit notifikaci", "HeaderNotificationUpdate": "Aktualizovat notifikaci", @@ -206,6 +210,7 @@ "HeaderTableOfContents": "Obsah", "HeaderTools": "Nástroje", "HeaderUpdateAccount": "Aktualizovat účet", + "HeaderUpdateApiKey": "Aktualizovat API klíč", "HeaderUpdateAuthor": "Aktualizovat autora", "HeaderUpdateDetails": "Aktualizovat podrobnosti", "HeaderUpdateLibrary": "Aktualizovat knihovnu", @@ -235,6 +240,10 @@ "LabelAllUsersExcludingGuests": "Všichni uživatelé kromě hostů", "LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů", "LabelAlreadyInYourLibrary": "Již ve vaší knihovně", + "LabelApiKeyCreated": "API klíč \"{0}\" byl úspěšně vytvořen.", + "LabelApiKeyCreatedDescription": "Zkopírujte si API klíč nyní, později již nebude možné jej zobrazit.", + "LabelApiKeyUser": "Vydávat se za uživatele", + "LabelApiKeyUserDescription": "Tento API klíč bude mít stejná oprávnění jako uživatel za něhož vystupuje. V protokolech to bude vypadat jako kdyby požadavky vytvářel přímo daný uživatel.", "LabelApiToken": "API Token", "LabelAppend": "Připojit", "LabelAudioBitrate": "Bitový tok zvuku (např. 128k)", @@ -346,6 +355,10 @@ "LabelExample": "Příklad", "LabelExpandSeries": "Rozbalit série", "LabelExpandSubSeries": "Rozbalit podsérie", + "LabelExpired": "Expirovaný", + "LabelExpiresAt": "Expiruje v", + "LabelExpiresInSeconds": "Expiruje za (sekundy)", + "LabelExpiresNever": "Nikdy", "LabelExplicit": "Explicitně", "LabelExplicitChecked": "Explicitní (zaškrtnuto)", "LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)", @@ -455,6 +468,7 @@ "LabelNewestEpisodes": "Nejnovější epizody", "LabelNextBackupDate": "Datum příští zálohy", "LabelNextScheduledRun": "Další naplánované spuštění", + "LabelNoApiKeys": "Žádné API klíče", "LabelNoCustomMetadataProviders": "Žádní vlastní poskytovatelé metadat", "LabelNoEpisodesSelected": "Nebyly vybrány žádné epizody", "LabelNotFinished": "Nedokončeno", @@ -544,6 +558,7 @@ "LabelSelectAll": "Vybrat vše", "LabelSelectAllEpisodes": "Vybrat všechny epizody", "LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují", + "LabelSelectUser": "Vybrat uživatele", "LabelSelectUsers": "Vybrat uživatele", "LabelSendEbookToDevice": "Odeslat e-knihu do...", "LabelSequence": "Sekvence", @@ -708,7 +723,9 @@ "MessageAddToPlayerQueue": "Přidat do fronty přehrávače", "MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci Apprise API nebo API, které bude zpracovávat stejné požadavky.
Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese http://192.168.1.1:8337 pak byste měli zadat http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Ujistěte se, že používáte ASIN ze správného regionu Audible a ne z Amazonu.", + "MessageAuthenticationLegacyTokenWarning": "Zastaralé API tokeny budou v budoucnu odstraněny. Použijte místo nich API klíče.", "MessageAuthenticationOIDCChangesRestart": "Po uložení restartujte server, aby se změny OIDC použily.", + "MessageAuthenticationSecurityMessage": "Bezpečnost autentizace byla vylepšena. Všichni uživatelé se musí znovu přihlásit.", "MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v /metadata/items a /metadata/authors. Zálohy ne zahrnují všechny soubory uložené ve složkách knihovny.", "MessageBackupsLocationEditNote": "Poznámka: Změna umístění záloh nepřesune ani nezmění existující zálohy", "MessageBackupsLocationNoEditNote": "Poznámka: Umístění záloh je nastavené z proměnných prostředí a nelze zde změnit.", @@ -730,6 +747,7 @@ "MessageChaptersNotFound": "Kapitoly nenalezeny", "MessageCheckingCron": "Kontrola cronu...", "MessageConfirmCloseFeed": "Opravdu chcete zavřít tento kanál?", + "MessageConfirmDeleteApiKey": "Opravdu chcete vymazat API klíč \"{0}\"?", "MessageConfirmDeleteBackup": "Opravdu chcete smazat zálohu pro {0}?", "MessageConfirmDeleteDevice": "Opravdu chcete vymazat zařízení e-reader \"{0}\"?", "MessageConfirmDeleteFile": "Tento krok smaže soubor ze souborového systému. Jsi si jisti?", @@ -1001,6 +1019,8 @@ "ToastEpisodeDownloadQueueClearSuccess": "Fronta stahování epizod je prázdná", "ToastEpisodeUpdateSuccess": "{0} epizod aktualizováno", "ToastErrorCannotShare": "Na tomto zařízení nelze nativně sdílet", + "ToastFailedToCreate": "Nepodařilo se vytvořit", + "ToastFailedToDelete": "Nepodařilo se odstranit", "ToastFailedToLoadData": "Nepodařilo se načíst data", "ToastFailedToMatch": "Nepodařilo se spárovat", "ToastFailedToShare": "Sdílení selhalo", @@ -1032,6 +1052,7 @@ "ToastMustHaveAtLeastOnePath": "Musí mít minimálně jednu cestu", "ToastNameEmailRequired": "Jméno a email jsou vyžadovány", "ToastNameRequired": "Jméno je vyžadováno", + "ToastNewApiKeyUserError": "Je nutné vybrat uživatele", "ToastNewEpisodesFound": "{0} nových epizod bylo nalezeno", "ToastNewUserCreatedFailed": "Chyba při vytváření účtu: \"{0}\"", "ToastNewUserCreatedSuccess": "Vytvořen nový účet", From d96ebbe82d90c59ca60c3db12c760b02d1d00bf3 Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 21 Jul 2025 19:18:18 +0200 Subject: [PATCH 167/182] Translated using Weblate (French) Currently translated at 100.0% (1129 of 1129 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/client/strings/fr.json b/client/strings/fr.json index 03a0cdee..60ae3fe9 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -1,5 +1,6 @@ { "ButtonAdd": "Ajouter", + "ButtonAddApiKey": "Ajouter une clé API", "ButtonAddChapters": "Ajouter des chapitres", "ButtonAddDevice": "Ajouter un appareil", "ButtonAddLibrary": "Ajouter une bibliothèque", @@ -20,6 +21,7 @@ "ButtonChooseAFolder": "Sélectionner un dossier", "ButtonChooseFiles": "Sélectionner des fichiers", "ButtonClearFilter": "Effacer le filtre", + "ButtonClose": "Fermer", "ButtonCloseFeed": "Fermer le flux", "ButtonCloseSession": "Fermer la session", "ButtonCollections": "Collections", @@ -119,6 +121,7 @@ "HeaderAccount": "Compte", "HeaderAddCustomMetadataProvider": "Ajouter un fournisseur de métadonnées personnalisé", "HeaderAdvanced": "Avancé", + "HeaderApiKeys": "Clés API", "HeaderAppriseNotificationSettings": "Configuration des notifications Apprise", "HeaderAudioTracks": "Pistes audio", "HeaderAudiobookTools": "Outils de gestion de fichiers de livres audio", @@ -162,6 +165,7 @@ "HeaderMetadataOrderOfPrecedence": "Ordre de priorité des métadonnées", "HeaderMetadataToEmbed": "Métadonnées à intégrer", "HeaderNewAccount": "Nouveau compte", + "HeaderNewApiKey": "Nouvelle clé API", "HeaderNewLibrary": "Nouvelle bibliothèque", "HeaderNotificationCreate": "Créer une notification", "HeaderNotificationUpdate": "Mise à jour de la notification", @@ -177,6 +181,7 @@ "HeaderPlaylist": "Liste de lecture", "HeaderPlaylistItems": "Éléments de la liste de lecture", "HeaderPodcastsToAdd": "Podcasts à ajouter", + "HeaderPresets": "Préréglages", "HeaderPreviewCover": "Prévisualiser la couverture", "HeaderRSSFeedGeneral": "Détails du flux RSS", "HeaderRSSFeedIsOpen": "Le flux RSS est actif", @@ -205,6 +210,7 @@ "HeaderTableOfContents": "Table des matières", "HeaderTools": "Outils", "HeaderUpdateAccount": "Mettre à jour le compte", + "HeaderUpdateApiKey": "Mettre à jour la clé API", "HeaderUpdateAuthor": "Mettre à jour l’auteur", "HeaderUpdateDetails": "Mettre à jour les détails", "HeaderUpdateLibrary": "Mettre à jour la bibliothèque", @@ -234,6 +240,10 @@ "LabelAllUsersExcludingGuests": "Tous les utilisateurs à l’exception des invités", "LabelAllUsersIncludingGuests": "Tous les utilisateurs, y compris les invités", "LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque", + "LabelApiKeyCreated": "La clé API « {0} » a été créée avec succès.", + "LabelApiKeyCreatedDescription": "Assurez-vous de copier la clé API maintenant car vous ne pourrez plus la voir.", + "LabelApiKeyUser": "Agir au nom de l’utilisateur", + "LabelApiKeyUserDescription": "Cette clé API disposera des mêmes autorisations que l’utilisateur pour lequel elle agit. Elle apparaîtra dans les journaux comme si c’était l’utilisateur qui effectuait la requête.", "LabelApiToken": "Token API", "LabelAppend": "Ajouter", "LabelAudioBitrate": "Débit audio (par exemple 128k)", @@ -345,6 +355,10 @@ "LabelExample": "Exemple", "LabelExpandSeries": "Développer la série", "LabelExpandSubSeries": "Développer les sous-séries", + "LabelExpired": "Expiré", + "LabelExpiresAt": "Expire à", + "LabelExpiresInSeconds": "Expire dans (secondes)", + "LabelExpiresNever": "Jamais", "LabelExplicit": "Restriction", "LabelExplicitChecked": "Explicite (vérifié)", "LabelExplicitUnchecked": "Non explicite (non vérifié)", @@ -454,6 +468,7 @@ "LabelNewestEpisodes": "Épisodes récents", "LabelNextBackupDate": "Date de la prochaine sauvegarde", "LabelNextScheduledRun": "Prochain lancement prévu", + "LabelNoApiKeys": "Aucune clé API", "LabelNoCustomMetadataProviders": "Aucun fournisseurs de métadonnées personnalisés", "LabelNoEpisodesSelected": "Aucun épisode sélectionné", "LabelNotFinished": "Non terminé", @@ -543,6 +558,7 @@ "LabelSelectAll": "Tout sélectionner", "LabelSelectAllEpisodes": "Sélectionner tous les épisodes", "LabelSelectEpisodesShowing": "Sélectionner {0} épisode(s) en cours", + "LabelSelectUser": "Sélectionner l’utilisateur", "LabelSelectUsers": "Sélectionner les utilisateurs", "LabelSendEbookToDevice": "Envoyer le livre numérique à…", "LabelSequence": "Séquence", @@ -707,7 +723,9 @@ "MessageAddToPlayerQueue": "Ajouter en file d’attente", "MessageAppriseDescription": "Nécessite une instance d’API Apprise pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.
L’URL de l’API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur http://192.168.1.1:8337 alors vous devez mettre http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Assurez-vous d’utiliser l’ASIN de la bonne région Audible, et non d’Amazon.", + "MessageAuthenticationLegacyTokenWarning": "Les jetons d’API hérités seront supprimés à l’avenir. Utilisez plutôt les clés API.", "MessageAuthenticationOIDCChangesRestart": "Redémarrez votre serveur après avoir enregistré pour appliquer les modifications OIDC.", + "MessageAuthenticationSecurityMessage": "L’authentification a été améliorée pour plus de sécurité. Tous les utilisateurs doivent se reconnecter.", "MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression des utilisateurs, les détails des éléments de la bibliothèque, les paramètres du serveur et les images stockées dans /metadata/items & /metadata/authors. Les sauvegardes n’incluent pas les fichiers stockés dans les dossiers de votre bibliothèque.", "MessageBackupsLocationEditNote": "Remarque : Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes", "MessageBackupsLocationNoEditNote": "Remarque : l’emplacement de sauvegarde est défini via une variable d’environnement et ne peut pas être modifié ici.", @@ -729,6 +747,7 @@ "MessageChaptersNotFound": "Chapitres non trouvés", "MessageCheckingCron": "Vérification du cron…", "MessageConfirmCloseFeed": "Êtes-vous sûr·e de vouloir fermer ce flux ?", + "MessageConfirmDeleteApiKey": "Êtes-vous sûr de vouloir supprimer la clé API « {0} » ?", "MessageConfirmDeleteBackup": "Êtes-vous sûr·e de vouloir supprimer la sauvegarde de « {0} » ?", "MessageConfirmDeleteDevice": "Êtes-vous sûr·e de vouloir supprimer la liseuse « {0} » ?", "MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?", @@ -756,6 +775,7 @@ "MessageConfirmRemoveAuthor": "Êtes-vous sûr·e de vouloir supprimer l’auteur « {0} » ?", "MessageConfirmRemoveCollection": "Êtes-vous sûr·e de vouloir supprimer la collection « {0} » ?", "MessageConfirmRemoveEpisode": "Êtes-vous sûr·e de vouloir supprimer l’épisode « {0} » ?", + "MessageConfirmRemoveEpisodeNote": "Remarque : cela ne supprime pas le fichier audio, sauf si vous activez « Supprimer définitivement le fichier »", "MessageConfirmRemoveEpisodes": "Êtes-vous sûr·e de vouloir supprimer {0} épisodes ?", "MessageConfirmRemoveListeningSessions": "Êtes-vous sûr·e de vouloir supprimer {0} sessions d’écoute ?", "MessageConfirmRemoveMetadataFiles": "Êtes-vous sûr·e de vouloir supprimer tous les fichiers « metatadata.{0} » des dossiers d’éléments de votre bibliothèque ?", @@ -917,6 +937,8 @@ "NotificationOnBackupCompletedDescription": "Déclenché lorsqu’une sauvegarde est terminée", "NotificationOnBackupFailedDescription": "Déclenché lorsqu'une sauvegarde échoue", "NotificationOnEpisodeDownloadedDescription": "Déclenché lorsqu’un épisode de podcast est téléchargé automatiquement", + "NotificationOnRSSFeedDisabledDescription": "Déclenché lorsque les téléchargements automatiques d’épisodes sont désactivés en raison d’un trop grand nombre de tentatives infructueuses", + "NotificationOnRSSFeedFailedDescription": "Déclenché lorsque la demande de flux RSS échoue pour un téléchargement automatique d’épisode", "NotificationOnTestDescription": "Événement pour tester le système de notification", "PlaceholderNewCollection": "Nom de la nouvelle collection", "PlaceholderNewFolderPath": "Nouveau chemin de dossier", @@ -997,6 +1019,8 @@ "ToastEpisodeDownloadQueueClearSuccess": "File d’attente de téléchargement des épisodes effacée", "ToastEpisodeUpdateSuccess": "{0} épisodes mis à jour", "ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil", + "ToastFailedToCreate": "Échec de la création", + "ToastFailedToDelete": "Échec de la suppression", "ToastFailedToLoadData": "Échec du chargement des données", "ToastFailedToMatch": "Échec de la correspondance", "ToastFailedToShare": "Échec du partage", @@ -1028,6 +1052,7 @@ "ToastMustHaveAtLeastOnePath": "Doit avoir au moins un chemin", "ToastNameEmailRequired": "Le nom et le courriel sont requis", "ToastNameRequired": "Le nom est requis", + "ToastNewApiKeyUserError": "Vous devez sélectionner un utilisateur", "ToastNewEpisodesFound": "{0} nouveaux épisodes trouvés", "ToastNewUserCreatedFailed": "La création du compte à échouée : « {0} »", "ToastNewUserCreatedSuccess": "Nouveau compte créé", From 880d85eaef6ee4a8ccbda540c23b3de8fd83367b Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 21 Jul 2025 17:07:06 -0500 Subject: [PATCH 168/182] Version bump v2.26.2 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index d321ce58..37fb4903 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.26.1", + "version": "2.26.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.26.0", + "version": "2.26.2", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 1aa9b384..828fedf9 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.26.1", + "version": "2.26.2", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index cdf40fe4..10e3389e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.26.1", + "version": "2.26.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.26.0", + "version": "2.26.2", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 85264c1f..5e7d4bc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.26.1", + "version": "2.26.2", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 8c38987d923af784a6d59de3b2eb98896c01161b Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 22 Jul 2025 14:44:36 -0500 Subject: [PATCH 169/182] Fix podcast episode track index null in playback session --- server/controllers/SessionController.js | 7 ++++++- server/models/PodcastEpisode.js | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js index 7160eace..ed662ee8 100644 --- a/server/controllers/SessionController.js +++ b/server/controllers/SessionController.js @@ -288,7 +288,12 @@ class SessionController { return res.sendStatus(404) } - const audioTrack = playbackSession.audioTracks.find((t) => t.index === audioTrackIndex) + let audioTrack = playbackSession.audioTracks.find((t) => toNumber(t.index, 1) === audioTrackIndex) + + // Support clients passing 0 or 1 for podcast episode audio track index (handles old episodes pre-v2.21.0 having null index) + if (!audioTrack && playbackSession.mediaType === 'podcast' && audioTrackIndex === 0) { + audioTrack = playbackSession.audioTracks[0] + } if (!audioTrack) { Logger.error(`[SessionController] Unable to find audio track with index=${audioTrackIndex}`) return res.sendStatus(404) diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 27e702a1..996f55f5 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -185,6 +185,7 @@ class PodcastEpisode extends Model { const track = structuredClone(this.audioFile) track.startOffset = 0 track.title = this.audioFile.metadata.filename + track.index = 1 // Podcast episodes only have one track track.contentUrl = `/api/items/${libraryItemId}/file/${track.ino}` return track } From 45e8e727596f73144620fdd8db09d1c2dc6ae39e Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 22 Jul 2025 15:17:00 -0500 Subject: [PATCH 170/182] Update change password to support null or empty string passwords #4522 --- server/controllers/MeController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 9451a765..20d03242 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -280,7 +280,7 @@ class MeController { } const { password, newPassword } = req.body - if (!password || !newPassword || typeof password !== 'string' || typeof newPassword !== 'string') { + if ((typeof password !== 'string' && password !== null) || (typeof newPassword !== 'string' && newPassword !== null)) { return res.status(400).send('Missing or invalid password or new password') } From b156ebeb9f0935f49e93d4cf1f8aba82687ea919 Mon Sep 17 00:00:00 2001 From: Daniel Schosser Date: Tue, 22 Jul 2025 10:15:55 +0200 Subject: [PATCH 171/182] Translated using Weblate (German) Currently translated at 99.9% (1129 of 1130 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/de.json b/client/strings/de.json index c455a120..2e913c11 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -438,6 +438,7 @@ "LabelLogLevelWarn": "Warnungen", "LabelLookForNewEpisodesAfterDate": "Suche nach neuen Episoden nach diesem Datum", "LabelLowestPriority": "Niedrigste Priorität", + "LabelMatchConfidence": "Zuversicht", "LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit", "LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID vom SSO-Anbieter zugeordnet", "LabelMaxEpisodesToDownload": "Max. Anzahl an Episoden zum Herunterladen, 0 für unbegrenzte Episoden.", @@ -723,6 +724,7 @@ "MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen", "MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von Apprise API laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann.
Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter http://192.168.1.1:8337 läuft, würdest du http://192.168.1.1:8337/notify eingeben.", "MessageAsinCheck": "Stellen Sie sicher, dass Sie die ASIN aus der richtigen Audible Region verwenden, nicht Amazon.", + "MessageAuthenticationLegacyTokenWarning": "Alte API tokens werden in Zukunft entfernt. Benutze stattdessen API Keys.", "MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.", "MessageAuthenticationSecurityMessage": "Die Anmeldung wurde abgesichert. Benutzersitzungen werden getrennt, alle Benutzer müssen sich erneut anmelden.", "MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in /metadata/items & /metadata/authors gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.", From c3f0fb8e5e4fdf8c815cb5d09d167898941059cb Mon Sep 17 00:00:00 2001 From: Dmitry Date: Tue, 22 Jul 2025 00:55:00 +0200 Subject: [PATCH 172/182] Translated using Weblate (Russian) Currently translated at 100.0% (1130 of 1130 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/ru.json b/client/strings/ru.json index ce03bdc4..4caceacf 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -438,6 +438,7 @@ "LabelLogLevelWarn": "Предупреждение", "LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты", "LabelLowestPriority": "Самый низкий приоритет", + "LabelMatchConfidence": "Уверенность", "LabelMatchExistingUsersBy": "Сопоставление существующих пользователей по", "LabelMatchExistingUsersByDescription": "Используется для подключения существующих пользователей. После подключения пользователям будет присвоен уникальный идентификатор от поставщика единого входа", "LabelMaxEpisodesToDownload": "Максимальное количество эпизодов для загрузки. Используйте 0 для неограниченного количества.", @@ -723,6 +724,7 @@ "MessageAddToPlayerQueue": "Добавить в очередь проигрывателя", "MessageAppriseDescription": "Для использования этой функции необходимо иметь запущенный экземпляр Apprise API или api которое обрабатывает те же самые запросы.
URL-адрес API Apprise должен быть полным URL-адресом для отправки уведомления, т.е., если API запущено по адресу http://192.168.1.1:8337 тогда нужно указать http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Убедитесь, что вы используете ASIN из правильной региональной зоны Audible, а не из Amazon.", + "MessageAuthenticationLegacyTokenWarning": "Устаревшие токены API в будущем будут удалены. Вместо них используйте API-ключи.", "MessageAuthenticationOIDCChangesRestart": "Перезапустите ваш сервер после сохранения для применения изменений в OIDC.", "MessageAuthenticationSecurityMessage": "В целях безопасности была улучшена аутентификация. Всем пользователям необходимо повторно войти в систему.", "MessageBackupsDescription": "Бэкап включает пользователей, прогресс пользователей, данные элементов библиотеки, настройки сервера и изображения хранящиеся в /metadata/items и /metadata/authors. Бэкапы НЕ сохраняют файлы из папок библиотек.", From 472240f99402310af418203fc0780e51c9158d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Tue, 22 Jul 2025 06:44:15 +0200 Subject: [PATCH 173/182] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1130 of 1130 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index c7f76089..94e01e3d 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -438,6 +438,7 @@ "LabelLogLevelWarn": "Увага", "LabelLookForNewEpisodesAfterDate": "Шукати нові епізоди після вказаної дати", "LabelLowestPriority": "Найнижчий пріоритет", + "LabelMatchConfidence": "Впевненість", "LabelMatchExistingUsersBy": "Шукати наявних користувачів за", "LabelMatchExistingUsersByDescription": "Використовується для підключення наявних користувачів. Після підключення користувач отримає унікальний id від вашого сервісу SSO", "LabelMaxEpisodesToDownload": "Максимальна кількість епізодів для скачування. Використовуйте 0 для необмеженої кількості.", @@ -837,7 +838,7 @@ "MessageNoItems": "Елементи відсутні", "MessageNoItemsFound": "Елементів не знайдено", "MessageNoListeningSessions": "Сеанси прослуховування відсутні", - "MessageNoLogs": "Немає журнали", + "MessageNoLogs": "Немає журналів'", "MessageNoMediaProgress": "Прогрес відсутній", "MessageNoNotifications": "Сповіщення відсутні", "MessageNoPodcastFeed": "Некоректний подкаст: немає каналу", From 93160b83bfbb65c0609dfa718d2d4e29a0bb0c0d Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Tue, 22 Jul 2025 02:25:07 +0200 Subject: [PATCH 174/182] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1130 of 1130 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 5138e84e..4d86781e 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -438,6 +438,7 @@ "LabelLogLevelWarn": "警告", "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", "LabelLowestPriority": "最低优先级", + "LabelMatchConfidence": "置信度", "LabelMatchExistingUsersBy": "匹配现有用户", "LabelMatchExistingUsersByDescription": "用于连接现有用户. 连接后, 用户将通过 SSO 提供商提供的唯一 id 进行匹配", "LabelMaxEpisodesToDownload": "可下载的最大集数. 输入 0 表示无限制.", From a7a3a565098791a8157ed54b46b5258bb97c141d Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 23 Jul 2025 17:18:51 -0500 Subject: [PATCH 175/182] Version bump v2.26.3 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 37fb4903..5dc4883a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.26.2", + "version": "2.26.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.26.2", + "version": "2.26.3", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 828fedf9..78811710 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.26.2", + "version": "2.26.3", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 10e3389e..ac3b1f6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.26.2", + "version": "2.26.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.26.2", + "version": "2.26.3", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 5e7d4bc3..87c052c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.26.2", + "version": "2.26.3", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From d9f2d8bf1d98725b1b569c5f60c7bd0918d8d313 Mon Sep 17 00:00:00 2001 From: Felix <52048864+FelixSche@users.noreply.github.com> Date: Thu, 24 Jul 2025 13:57:26 +0200 Subject: [PATCH 176/182] Update SideRail.vue Changed cursor at version to pointer --- client/components/app/SideRail.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index 2b05ef36..5f364201 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -116,7 +116,7 @@

-

v{{ $config.version }}

+

v{{ $config.version }}

Update

{{ Source }}

From 832165716bb67af91a0731cd1f21899185c868fa Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 24 Jul 2025 17:29:08 -0500 Subject: [PATCH 177/182] Fix ereader update socket event sending all devices #4529 --- server/controllers/MeController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 20d03242..51773a5a 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -450,7 +450,7 @@ class MeController { if (updated) { await Database.updateSetting(Database.emailSettings) SocketAuthority.clientEmitter(req.user.id, 'ereader-devices-updated', { - ereaderDevices: Database.emailSettings.ereaderDevices + ereaderDevices: Database.emailSettings.getEReaderDevices(req.user) }) } res.json({ From c9eaf2db2d17fe9efc94d99689761954528e4168 Mon Sep 17 00:00:00 2001 From: Michael Vinci Date: Fri, 25 Jul 2025 16:47:34 -0500 Subject: [PATCH 178/182] Add 'sepia' theme to EpubReader --- client/components/readers/EpubReader.vue | 28 ++++++++++++++++-------- client/components/readers/Reader.vue | 8 +++++-- client/strings/en-us.json | 1 + 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index ac8e3397..a4c51539 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -99,22 +99,32 @@ export default { return `/api/items/${this.libraryItemId}/ebook` }, themeRules() { - const isDark = this.ereaderSettings.theme === 'dark' - const fontColor = isDark ? '#fff' : '#000' - const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)' + const theme = this.ereaderSettings.theme + const isDark = theme === 'dark' + const isSepia = theme === 'sepia' + + const fontColor = isDark + ? '#fff' + : isSepia + ? '#5b4636' + : '#000' + + const backgroundColor = isDark + ? 'rgb(35 35 35)' + : isSepia + ? 'rgb(244, 236, 216)' + : 'rgb(255, 255, 255)' const lineSpacing = this.ereaderSettings.lineSpacing / 100 - - const fontScale = this.ereaderSettings.fontScale / 100 - - const textStroke = this.ereaderSettings.textStroke / 100 + const fontScale = this.ereaderSettings.fontScale / 100 + const textStroke = this.ereaderSettings.textStroke / 100 return { '*': { color: `${fontColor}!important`, 'background-color': `${backgroundColor}!important`, - 'line-height': lineSpacing * fontScale + 'rem!important', - '-webkit-text-stroke': textStroke + 'px ' + fontColor + '!important' + 'line-height': `${lineSpacing * fontScale}rem!important`, + '-webkit-text-stroke': `${textStroke}px ${fontColor}!important` }, a: { color: `${fontColor}!important` diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue index a7a5ac3d..7ceddf3f 100644 --- a/client/components/readers/Reader.vue +++ b/client/components/readers/Reader.vue @@ -1,5 +1,5 @@