From 9b01d11b27fa0e56debac8610dd02b1cea3aeecc Mon Sep 17 00:00:00 2001 From: Lauri Vuorela Date: Tue, 22 Oct 2024 23:58:09 +0200 Subject: [PATCH 001/762] allow setting createdAt and respect set finishedAt when syncing progress --- server/models/User.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/models/User.js b/server/models/User.js index aa63aea8..84f471e9 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -607,13 +607,14 @@ class User extends Model { ebookLocation: progressPayload.ebookLocation || null, ebookProgress: isNullOrNaN(progressPayload.ebookProgress) ? 0 : Number(progressPayload.ebookProgress), finishedAt: progressPayload.finishedAt || null, + createdAt: progressPayload.createdAt || new Date(), extraData: { libraryItemId: progressPayload.libraryItemId, progress: isNullOrNaN(progressPayload.progress) ? 0 : Number(progressPayload.progress) } } if (newMediaProgressPayload.isFinished) { - newMediaProgressPayload.finishedAt = new Date() + newMediaProgressPayload.finishedAt = newMediaProgressPayload.finishedAt || new Date() newMediaProgressPayload.extraData.progress = 1 } else { newMediaProgressPayload.finishedAt = null From 524cf5ec5b8f97a44b778dfed70e11efc2b35c9f Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 29 Oct 2024 21:42:44 +0200 Subject: [PATCH 002/762] Fix incorrect call to handleDownloadError --- server/controllers/LibraryItemController.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 0b4d3d0c..a51a6e06 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -115,7 +115,7 @@ class LibraryItemController { res.sendStatus(200) } - #handleDownloadError(error, res) { + static handleDownloadError(error, res) { if (!res.headersSent) { if (error.code === 'ENOENT') { return res.status(404).send('File not found') @@ -158,7 +158,7 @@ class LibraryItemController { Logger.info(`[LibraryItemController] Downloaded item "${itemTitle}" at "${libraryItemPath}"`) } catch (error) { Logger.error(`[LibraryItemController] Download failed for item "${itemTitle}" at "${libraryItemPath}"`, error) - this.#handleDownloadError(error, res) + LibraryItemController.handleDownloadError(error, res) } } @@ -865,7 +865,7 @@ class LibraryItemController { Logger.info(`[LibraryItemController] Downloaded file "${libraryFile.metadata.path}"`) } catch (error) { Logger.error(`[LibraryItemController] Failed to download file "${libraryFile.metadata.path}"`, error) - this.#handleDownloadError(error, res) + LibraryItemController.handleDownloadError(error, res) } } @@ -909,7 +909,7 @@ class LibraryItemController { Logger.info(`[LibraryItemController] Downloaded ebook file "${ebookFilePath}"`) } catch (error) { Logger.error(`[LibraryItemController] Failed to download ebook file "${ebookFilePath}"`, error) - this.#handleDownloadError(error, res) + LibraryItemController.handleDownloadError(error, res) } } From 6eba467b91af8a65b47e683509294183b0aa891e Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 29 Oct 2024 15:41:31 -0500 Subject: [PATCH 003/762] Fix:Session sync for streaming podcast episodes using incorrect duration #3560 --- server/managers/PlaybackSessionManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index 33a3ccd2..ce43fc8c 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -366,7 +366,7 @@ class PlaybackSessionManager { libraryItemId: libraryItem.id, episodeId: session.episodeId, // duration no longer required (v2.15.1) but used if available - duration: syncData.duration || libraryItem.media.duration || 0, + duration: syncData.duration || session.duration || 0, currentTime: syncData.currentTime, progress: session.progress, markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining, From c69e97ea241b30c2cbbaaee993ae4948fc3b2a66 Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 28 Oct 2024 23:05:33 +0000 Subject: [PATCH 004/762] Translated using Weblate (French) Currently translated at 95.7% (1025 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/fr.json b/client/strings/fr.json index 3674acc3..d31c5971 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -250,11 +250,13 @@ "LabelBackupsNumberToKeep": "Nombre de sauvegardes à conserver", "LabelBackupsNumberToKeepHelp": "Seule une sauvegarde sera supprimée à la fois. Si vous avez déjà plus de sauvegardes à effacer, vous devez les supprimer manuellement.", "LabelBitrate": "Débit binaire", + "LabelBonus": "Bonus", "LabelBooks": "Livres", "LabelButtonText": "Texte du bouton", "LabelByAuthor": "par {0}", "LabelChangePassword": "Modifier le mot de passe", "LabelChannels": "Canaux", + "LabelChapterCount": "{0} Chapitres", "LabelChapterTitle": "Titre du chapitre", "LabelChapters": "Chapitres", "LabelChaptersFound": "chapitres trouvés", From e05cb0ef4de000eb20e46b6557a35fa12cc6b9a0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 29 Oct 2024 16:11:36 -0500 Subject: [PATCH 005/762] Version bump v2.16.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 f69dab4d..f31266cb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.16.1", + "version": "2.16.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.16.1", + "version": "2.16.2", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 703d7f66..2feb833b 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.16.1", + "version": "2.16.2", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 189781ba..6e3276ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.16.1", + "version": "2.16.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.16.1", + "version": "2.16.2", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 09c79711..d31f2022 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.16.1", + "version": "2.16.2", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 63fdf0d18e9efceab3e6442ff6b50a1a64bdae9a Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Tue, 29 Oct 2024 18:22:38 -0700 Subject: [PATCH 006/762] Update: user directive in docker compose file --- docker-compose.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 68e012fb..b8d428a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,4 @@ ### EXAMPLE DOCKER COMPOSE ### -version: "3.7" - services: audiobookshelf: image: ghcr.io/advplyr/audiobookshelf:latest @@ -23,8 +21,7 @@ services: # you are running ABS on - ./config:/config restart: unless-stopped - # You can use the following environment variable to run the ABS + # You can use the following user directive to run the ABS # docker container as a specific user. You will need to change # the UID and GID to the correct values for your user. - #environment: - # - user=1000:1000 + # user: 1000:1000 From e0c66ea6dfa86f4a213864532999a703e99feeab Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 30 Oct 2024 15:27:18 -0500 Subject: [PATCH 007/762] Fix:Global search unclickable from trackpad due to blur event closing menu --- client/components/controls/GlobalSearch.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/components/controls/GlobalSearch.vue b/client/components/controls/GlobalSearch.vue index cb46895a..58796fde 100644 --- a/client/components/controls/GlobalSearch.vue +++ b/client/components/controls/GlobalSearch.vue @@ -9,7 +9,7 @@ close -
+
  • {{ $strings.MessageThinking }}

    @@ -157,7 +157,7 @@ export default { clearTimeout(this.focusTimeout) this.focusTimeout = setTimeout(() => { this.showMenu = false - }, 200) + }, 100) }, async runSearch(value) { this.lastSearch = value From 32105665c19187a9360311388da7eb15bb8fdf7b Mon Sep 17 00:00:00 2001 From: Achim Date: Thu, 31 Oct 2024 15:29:40 +0100 Subject: [PATCH 008/762] 'mpg' and 'mpeg' added as supported audio-type/file-extension --- server/utils/globals.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/globals.js b/server/utils/globals.js index 877cf07a..5a5bd951 100644 --- a/server/utils/globals.js +++ b/server/utils/globals.js @@ -1,6 +1,6 @@ const globals = { SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'], - SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'], + SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpg', 'mpeg'], SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], TextFileTypes: ['txt', 'nfo'], MetadataFileTypes: ['opf', 'abs', 'xml', 'json'] From ae9efe63596493be6413650586725b881a24e6d2 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Thu, 31 Oct 2024 15:30:51 +0000 Subject: [PATCH 009/762] Add keyboard focus to MultiSelectQueryInput edit and close --- client/components/ui/MultiSelectQueryInput.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/components/ui/MultiSelectQueryInput.vue b/client/components/ui/MultiSelectQueryInput.vue index 099ee709..6b33acf3 100644 --- a/client/components/ui/MultiSelectQueryInput.vue +++ b/client/components/ui/MultiSelectQueryInput.vue @@ -5,9 +5,9 @@
    -
    - edit - close +
    + edit + close
    {{ item[textKey] }}
    @@ -65,6 +65,7 @@ export default { currentSearch: null, typingTimeout: null, isFocused: false, + inputFocused: false, menu: null, items: [] } @@ -114,6 +115,9 @@ export default { getIsSelected(itemValue) { return !!this.selected.find((i) => i.id === itemValue) }, + setInputFocused(focused) { + this.inputFocused = focused + }, search() { if (!this.textInput) return this.currentSearch = this.textInput From e55db0afdca634b4a0b9e4fafbb9bdf519c6b029 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Thu, 31 Oct 2024 15:44:19 +0000 Subject: [PATCH 010/762] Add focus and enter key support to the add button in MultiSelectQueryInput --- client/components/ui/MultiSelectQueryInput.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/ui/MultiSelectQueryInput.vue b/client/components/ui/MultiSelectQueryInput.vue index 6b33acf3..d0bdcef2 100644 --- a/client/components/ui/MultiSelectQueryInput.vue +++ b/client/components/ui/MultiSelectQueryInput.vue @@ -12,7 +12,7 @@ {{ item[textKey] }}
    - add + add
    From a0b3960ee416ffd641e257b487171cc3a54817ab Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Thu, 31 Oct 2024 16:29:48 +0000 Subject: [PATCH 011/762] Fix enter key and focus for edit modal --- client/components/ui/MultiSelectQueryInput.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/ui/MultiSelectQueryInput.vue b/client/components/ui/MultiSelectQueryInput.vue index d0bdcef2..fe7187ee 100644 --- a/client/components/ui/MultiSelectQueryInput.vue +++ b/client/components/ui/MultiSelectQueryInput.vue @@ -6,7 +6,7 @@
    - edit + edit close
    {{ item[textKey] }} From f3d2b781ab38569e52000815079957252195bb4a Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 1 Nov 2024 09:12:40 -0500 Subject: [PATCH 012/762] Add mime types for MPEG/MPG --- client/players/LocalAudioPlayer.js | 11 +++++------ client/plugins/constants.js | 8 +++----- server/utils/constants.js | 4 +++- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/client/players/LocalAudioPlayer.js b/client/players/LocalAudioPlayer.js index eb1484bb..7fc17e7a 100644 --- a/client/players/LocalAudioPlayer.js +++ b/client/players/LocalAudioPlayer.js @@ -147,7 +147,7 @@ export default class LocalAudioPlayer extends EventEmitter { timeoutRetry: { maxNumRetry: 4, retryDelayMs: 0, - maxRetryDelayMs: 0, + maxRetryDelayMs: 0 }, errorRetry: { maxNumRetry: 8, @@ -160,7 +160,7 @@ export default class LocalAudioPlayer extends EventEmitter { } return retry } - }, + } } } } @@ -194,7 +194,7 @@ export default class LocalAudioPlayer extends EventEmitter { setDirectPlay() { // Set initial track and track time offset - var trackIndex = this.audioTracks.findIndex(t => this.startTime >= t.startOffset && this.startTime < (t.startOffset + t.duration)) + var trackIndex = this.audioTracks.findIndex((t) => this.startTime >= t.startOffset && this.startTime < t.startOffset + t.duration) this.currentTrackIndex = trackIndex >= 0 ? trackIndex : 0 this.loadCurrentTrack() @@ -270,7 +270,7 @@ export default class LocalAudioPlayer extends EventEmitter { // Seeking Direct play if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) { // Change Track - var trackIndex = this.audioTracks.findIndex(t => time >= t.startOffset && time < (t.startOffset + t.duration)) + var trackIndex = this.audioTracks.findIndex((t) => time >= t.startOffset && time < t.startOffset + t.duration) if (trackIndex >= 0) { this.startTime = time this.currentTrackIndex = trackIndex @@ -293,7 +293,6 @@ export default class LocalAudioPlayer extends EventEmitter { this.player.volume = volume } - // Utils isValidDuration(duration) { if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) { @@ -338,4 +337,4 @@ export default class LocalAudioPlayer extends EventEmitter { var last = bufferedRanges[bufferedRanges.length - 1] return last.end } -} \ No newline at end of file +} diff --git a/client/plugins/constants.js b/client/plugins/constants.js index d89fbbbd..90c40b8c 100644 --- a/client/plugins/constants.js +++ b/client/plugins/constants.js @@ -1,6 +1,6 @@ const SupportedFileTypes = { image: ['png', 'jpg', 'jpeg', 'webp'], - audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'], + audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpeg', 'mpg'], ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], info: ['nfo'], text: ['txt'], @@ -81,11 +81,9 @@ const Hotkeys = { } } -export { - Constants -} +export { Constants } export default ({ app }, inject) => { inject('constants', Constants) inject('keynames', KeyNames) inject('hotkeys', Hotkeys) -} \ No newline at end of file +} diff --git a/server/utils/constants.js b/server/utils/constants.js index cbfe65f2..dd52e2e1 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -49,5 +49,7 @@ module.exports.AudioMimeType = { WEBMA: 'audio/webm', MKA: 'audio/x-matroska', AWB: 'audio/amr-wb', - CAF: 'audio/x-caf' + CAF: 'audio/x-caf', + MPEG: 'audio/mpeg', + MPG: 'audio/mpeg' } From 431ae97593da6930e5a9be0bed126f5afc8e135d Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 2 Nov 2024 09:02:23 +0200 Subject: [PATCH 013/762] add Database.getLibraryItemCoverPath --- server/Database.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/server/Database.js b/server/Database.js index 9bce2605..9b9f9cf9 100644 --- a/server/Database.js +++ b/server/Database.js @@ -808,6 +808,28 @@ class Database { return `${normalizedColumn} LIKE ${pattern}` } } + + async getLibraryItemCoverPath(libraryItemId) { + const libraryItem = await this.libraryItemModel.findByPk(libraryItemId, { + attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], + include: [ + { + model: this.bookModel, + attributes: ['id', 'coverPath'] + }, + { + model: this.podcastModel, + attributes: ['id', 'coverPath'] + } + ] + }) + if (!libraryItem) { + Logger.warn(`[Database] getCover: Library item "${libraryItemId}" does not exist`) + return null + } + + return libraryItem.media.coverPath + } } module.exports = new Database() From 9e990d79272f05ef79415d5b94835787d25200cd Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 2 Nov 2024 09:05:30 +0200 Subject: [PATCH 014/762] Optimize LibraryItemController.getCover --- server/controllers/LibraryItemController.js | 43 ++++++--------------- server/managers/CacheManager.js | 19 ++++++--- 2 files changed, 25 insertions(+), 37 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 0b4d3d0c..f0f5c86d 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -342,44 +342,25 @@ class LibraryItemController { query: { width, height, format, raw } } = req - const libraryItem = await Database.libraryItemModel.findByPk(req.params.id, { - attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], - include: [ - { - model: Database.bookModel, - attributes: ['id', 'coverPath', 'tags', 'explicit'] - }, - { - model: Database.podcastModel, - attributes: ['id', 'coverPath', 'tags', 'explicit'] - } - ] - }) - if (!libraryItem) { - Logger.warn(`[LibraryItemController] getCover: Library item "${req.params.id}" does not exist`) - return res.sendStatus(404) - } - - // Check if user can access this library item - if (!req.user.checkCanAccessLibraryItem(libraryItem)) { - return res.sendStatus(403) - } - - // Check if library item media has a cover path - if (!libraryItem.media.coverPath || !(await fs.pathExists(libraryItem.media.coverPath))) { - return res.sendStatus(404) - } - if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400') + const libraryItemId = req.params.id + if (!libraryItemId) { + return res.sendStatus(400) + } + if (raw) { + const coverPath = await Database.getLibraryItemCoverPath(libraryItemId) + if (!coverPath || !(await fs.pathExists(coverPath))) { + return res.sendStatus(404) + } // any value if (global.XAccel) { - const encodedURI = encodeUriPath(global.XAccel + libraryItem.media.coverPath) + const encodedURI = encodeUriPath(global.XAccel + coverPath) Logger.debug(`Use X-Accel to serve static file ${encodedURI}`) return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() } - return res.sendFile(libraryItem.media.coverPath) + return res.sendFile(coverPath) } const options = { @@ -387,7 +368,7 @@ class LibraryItemController { height: height ? parseInt(height) : null, width: width ? parseInt(width) : null } - return CacheManager.handleCoverCache(res, libraryItem.id, libraryItem.media.coverPath, options) + return CacheManager.handleCoverCache(res, libraryItemId, options) } /** diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index b4d2f270..d1b27423 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -4,6 +4,7 @@ const stream = require('stream') const Logger = require('../Logger') const { resizeImage } = require('../utils/ffmpegHelpers') const { encodeUriPath } = require('../utils/fileUtils') +const Database = require('../Database') class CacheManager { constructor() { @@ -29,24 +30,24 @@ class CacheManager { await fs.ensureDir(this.ItemCachePath) } - async handleCoverCache(res, libraryItemId, coverPath, options = {}) { + async handleCoverCache(res, libraryItemId, options = {}) { const format = options.format || 'webp' const width = options.width || 400 const height = options.height || null res.type(`image/${format}`) - const path = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format + const cachePath = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format // Cache exists - if (await fs.pathExists(path)) { + if (await fs.pathExists(cachePath)) { if (global.XAccel) { - const encodedURI = encodeUriPath(global.XAccel + path) + const encodedURI = encodeUriPath(global.XAccel + cachePath) Logger.debug(`Use X-Accel to serve static file ${encodedURI}`) return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() } - const r = fs.createReadStream(path) + const r = fs.createReadStream(cachePath) const ps = new stream.PassThrough() stream.pipeline(r, ps, (err) => { if (err) { @@ -57,7 +58,13 @@ class CacheManager { return ps.pipe(res) } - const writtenFile = await resizeImage(coverPath, path, width, height) + // Cached cover does not exist, generate it + const coverPath = await Database.getLibraryItemCoverPath(libraryItemId) + if (!coverPath || !(await fs.pathExists(coverPath))) { + return res.sendStatus(404) + } + + const writtenFile = await resizeImage(coverPath, cachePath, width, height) if (!writtenFile) return res.sendStatus(500) if (global.XAccel) { From 4224b8a486b106d8c26185a5f24253a0d4587975 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 2 Nov 2024 15:17:11 +0200 Subject: [PATCH 015/762] No auth and req.user for cover images --- server/Auth.js | 20 ++++++++++++++++++++ server/Server.js | 14 +++++++------- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 60af2a1e..6e5a4621 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -18,6 +18,26 @@ class Auth { constructor() { // Map of openId sessions indexed by oauth2 state-variable this.openIdAuthSession = new Map() + this.ignorePattern = /\/api\/items\/[^/]+\/cover/ + } + + /** + * Checks if the request should not be authenticated. + * @param {import('express').Request} req + * @returns {boolean} + * @private + */ + authNotNeeded(req) { + return req.method === 'GET' && this.ignorePattern.test(req.originalUrl) + } + + ifAuthNeeded(middleware) { + return (req, res, next) => { + if (this.authNotNeeded(req)) { + return next() + } + middleware(req, res, next) + } } /** diff --git a/server/Server.js b/server/Server.js index d8265237..58a2079e 100644 --- a/server/Server.js +++ b/server/Server.js @@ -238,7 +238,7 @@ class Server { // init passport.js app.use(passport.initialize()) // register passport in express-session - app.use(passport.session()) + app.use(this.auth.ifAuthNeeded(passport.session())) // config passport.js await this.auth.initPassportJs() @@ -268,6 +268,10 @@ class Server { router.use(express.urlencoded({ extended: true, limit: '5mb' })) router.use(express.json({ limit: '5mb' })) + router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router) + router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) + router.use('/public', this.publicRouter.router) + // Static path to generated nuxt const distPath = Path.join(global.appRoot, '/client/dist') router.use(express.static(distPath)) @@ -275,10 +279,6 @@ class Server { // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) - router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router) - router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) - router.use('/public', this.publicRouter.router) - // RSS Feed temp route router.get('/feed/:slug', (req, res) => { Logger.info(`[Server] Requesting rss feed ${req.params.slug}`) @@ -296,7 +296,7 @@ class Server { await this.auth.initAuthRoutes(router) // Client dynamic routes - const dyanimicRoutes = [ + const dynamicRoutes = [ '/item/:id', '/author/:id', '/audiobook/:id/chapters', @@ -319,7 +319,7 @@ class Server { '/playlist/:id', '/share/:slug' ] - dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) + dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) router.post('/init', (req, res) => { if (Database.hasRootUser) { From c25acb41fa9a6302e9d3e1261c5da38ef4a7a499 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 2 Nov 2024 15:37:14 +0200 Subject: [PATCH 016/762] Remove token from cover image urls --- client/store/globals.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/store/globals.js b/client/store/globals.js index c0e7d788..65878fb4 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -98,7 +98,7 @@ export const getters = { const userToken = rootGetters['user/getToken'] const lastUpdate = libraryItem.updatedAt || Date.now() const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers - return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}${raw ? '&raw=1' : ''}` + return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?ts=${lastUpdate}${raw ? '&raw=1' : ''}` }, getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => @@ -106,7 +106,7 @@ export const getters = { const placeholder = `${rootState.routerBasePath}/book_placeholder.jpg` if (!libraryItemId) return placeholder const userToken = rootGetters['user/getToken'] - return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}` + return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}` }, getIsBatchSelectingMediaItems: (state) => { return state.selectedMediaItems.length From 7a1623e6a11307842060360bac97ee10256548f9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 2 Nov 2024 12:56:40 -0500 Subject: [PATCH 017/762] Move cover path func to LibraryItem model --- server/Auth.js | 2 +- server/Database.js | 22 ----------------- server/controllers/LibraryItemController.js | 2 +- server/managers/CacheManager.js | 2 +- server/models/LibraryItem.js | 27 +++++++++++++++++++++ 5 files changed, 30 insertions(+), 25 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 6e5a4621..da124b72 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -23,7 +23,7 @@ class Auth { /** * Checks if the request should not be authenticated. - * @param {import('express').Request} req + * @param {Request} req * @returns {boolean} * @private */ diff --git a/server/Database.js b/server/Database.js index 9b9f9cf9..9bce2605 100644 --- a/server/Database.js +++ b/server/Database.js @@ -808,28 +808,6 @@ class Database { return `${normalizedColumn} LIKE ${pattern}` } } - - async getLibraryItemCoverPath(libraryItemId) { - const libraryItem = await this.libraryItemModel.findByPk(libraryItemId, { - attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], - include: [ - { - model: this.bookModel, - attributes: ['id', 'coverPath'] - }, - { - model: this.podcastModel, - attributes: ['id', 'coverPath'] - } - ] - }) - if (!libraryItem) { - Logger.warn(`[Database] getCover: Library item "${libraryItemId}" does not exist`) - return null - } - - return libraryItem.media.coverPath - } } module.exports = new Database() diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index f0f5c86d..1976c34d 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -350,7 +350,7 @@ class LibraryItemController { } if (raw) { - const coverPath = await Database.getLibraryItemCoverPath(libraryItemId) + const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId) if (!coverPath || !(await fs.pathExists(coverPath))) { return res.sendStatus(404) } diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index d1b27423..83efae90 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -59,7 +59,7 @@ class CacheManager { } // Cached cover does not exist, generate it - const coverPath = await Database.getLibraryItemCoverPath(libraryItemId) + const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId) if (!coverPath || !(await fs.pathExists(coverPath))) { return res.sendStatus(404) } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index dd07747a..9815b216 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -863,6 +863,33 @@ class LibraryItem extends Model { return this.getOldLibraryItem(libraryItem) } + /** + * + * @param {string} libraryItemId + * @returns {Promise} + */ + static async getCoverPath(libraryItemId) { + const libraryItem = await this.findByPk(libraryItemId, { + attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], + include: [ + { + model: this.bookModel, + attributes: ['id', 'coverPath'] + }, + { + model: this.podcastModel, + attributes: ['id', 'coverPath'] + } + ] + }) + if (!libraryItem) { + Logger.warn(`[LibraryItem] getCoverPath: Library item "${libraryItemId}" does not exist`) + return null + } + + return libraryItem.media.coverPath + } + /** * * @param {import('sequelize').FindOptions} options From 7a49681dd205ec36a8b083cdaee1cf8e6c78d1e2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 2 Nov 2024 13:02:40 -0500 Subject: [PATCH 018/762] Fix includes --- server/models/LibraryItem.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 9815b216..17c3b125 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -873,11 +873,11 @@ class LibraryItem extends Model { attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], include: [ { - model: this.bookModel, + model: this.sequelize.models.book, attributes: ['id', 'coverPath'] }, { - model: this.podcastModel, + model: this.sequelize.models.podcast, attributes: ['id', 'coverPath'] } ] From 3bc29414456b60d0653d32b9a3fc72c88c3b2400 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 3 Nov 2024 08:44:57 +0200 Subject: [PATCH 019/762] No db access for author image if in disk cache --- server/controllers/AuthorController.js | 21 ++++++++++++++------- server/managers/CacheManager.js | 17 +++++++++++------ server/routers/ApiRouter.js | 2 +- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 54a64185..45bbdf84 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -381,16 +381,23 @@ class AuthorController { */ async getImage(req, res) { const { - query: { width, height, format, raw }, - author + query: { width, height, format, raw } } = req - if (!author.imagePath || !(await fs.pathExists(author.imagePath))) { - Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`) - return res.sendStatus(404) - } + const authorId = req.params.id if (raw) { + const author = await Database.authorModel.findByPk(authorId) + if (!author) { + Logger.warn(`[AuthorController] Author "${authorId}" not found`) + return res.sendStatus(404) + } + + if (!author.imagePath || !(await fs.pathExists(author.imagePath))) { + Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`) + return res.sendStatus(404) + } + return res.sendFile(author.imagePath) } @@ -399,7 +406,7 @@ class AuthorController { height: height ? parseInt(height) : null, width: width ? parseInt(width) : null } - return CacheManager.handleAuthorCache(res, author, options) + return CacheManager.handleAuthorCache(res, authorId, options) } /** diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index 83efae90..f0375691 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -134,22 +134,22 @@ class CacheManager { /** * * @param {import('express').Response} res - * @param {import('../models/Author')} author + * @param {String} authorId * @param {{ format?: string, width?: number, height?: number }} options * @returns */ - async handleAuthorCache(res, author, options = {}) { + async handleAuthorCache(res, authorId, options = {}) { const format = options.format || 'webp' const width = options.width || 400 const height = options.height || null res.type(`image/${format}`) - var path = Path.join(this.ImageCachePath, `${author.id}_${width}${height ? `x${height}` : ''}`) + '.' + format + var cachePath = Path.join(this.ImageCachePath, `${authorId}_${width}${height ? `x${height}` : ''}`) + '.' + format // Cache exists - if (await fs.pathExists(path)) { - const r = fs.createReadStream(path) + if (await fs.pathExists(cachePath)) { + const r = fs.createReadStream(cachePath) const ps = new stream.PassThrough() stream.pipeline(r, ps, (err) => { if (err) { @@ -160,7 +160,12 @@ class CacheManager { return ps.pipe(res) } - let writtenFile = await resizeImage(author.imagePath, path, width, height) + const author = await Database.authorModel.findByPk(authorId) + if (!author || !author.imagePath || !(await fs.pathExists(author.imagePath))) { + return res.sendStatus(404) + } + + let writtenFile = await resizeImage(author.imagePath, cachePath, width, height) if (!writtenFile) return res.sendStatus(500) var readStream = fs.createReadStream(writtenFile) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index f81bc26d..c9399d79 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -216,7 +216,7 @@ class ApiRouter { this.router.patch('/authors/:id', AuthorController.middleware.bind(this), AuthorController.update.bind(this)) this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this)) this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this)) - this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this)) + this.router.get('/authors/:id/image', AuthorController.getImage.bind(this)) this.router.post('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.uploadImage.bind(this)) this.router.delete('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.deleteImage.bind(this)) From bf8407274e3ee300af1927ee660d078a7a801e1c Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 3 Nov 2024 08:45:43 +0200 Subject: [PATCH 020/762] No auth for author images --- server/Auth.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index da124b72..5b2d8bcd 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -18,7 +18,7 @@ class Auth { constructor() { // Map of openId sessions indexed by oauth2 state-variable this.openIdAuthSession = new Map() - this.ignorePattern = /\/api\/items\/[^/]+\/cover/ + this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/] } /** @@ -28,7 +28,7 @@ class Auth { * @private */ authNotNeeded(req) { - return req.method === 'GET' && this.ignorePattern.test(req.originalUrl) + return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.originalUrl)) } ifAuthNeeded(middleware) { From 68fd1d67cb867c1deeb0cac772dede2935a63527 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 3 Nov 2024 08:46:09 +0200 Subject: [PATCH 021/762] Remove token from author image URLs --- client/components/covers/AuthorImage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/covers/AuthorImage.vue b/client/components/covers/AuthorImage.vue index 01926363..e320e552 100644 --- a/client/components/covers/AuthorImage.vue +++ b/client/components/covers/AuthorImage.vue @@ -56,7 +56,7 @@ export default { }, imgSrc() { if (!this.imagePath) return null - return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}` + return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?ts=${this.updatedAt}` } }, methods: { From 7ef14aabedae8c4a038943633c6b2c2ec52b067e Mon Sep 17 00:00:00 2001 From: snakehnb Date: Mon, 4 Nov 2024 16:13:14 +0800 Subject: [PATCH 022/762] Avoid parsing first and last names in Chinese, Japanese and Korean languages --- server/utils/parsers/parseNameString.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/utils/parsers/parseNameString.js b/server/utils/parsers/parseNameString.js index 741beb09..4b16b496 100644 --- a/server/utils/parsers/parseNameString.js +++ b/server/utils/parsers/parseNameString.js @@ -52,6 +52,13 @@ module.exports.parse = (nameString) => { } if (splitNames.length) splitNames = splitNames.map((a) => a.trim()) + // If names are in Chinese,Japanese and Korean languages, return as is. + if (/[\u4e00-\u9fff\u3040-\u30ff\u31f0-\u31ff]/.test(splitNames[0])) { + return { + names: splitNames + } + } + var names = [] // 1 name FIRST LAST From 0812e189f74cb0cf176acf7924fb3b91a419a093 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Thu, 7 Nov 2024 03:38:30 +0000 Subject: [PATCH 023/762] Add keyboard input to MultiSelect component --- client/components/ui/MultiSelect.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/components/ui/MultiSelect.vue b/client/components/ui/MultiSelect.vue index 37018262..da4bcc13 100644 --- a/client/components/ui/MultiSelect.vue +++ b/client/components/ui/MultiSelect.vue @@ -5,9 +5,9 @@
    -
    +
    edit - close + close
    {{ item }}
    @@ -66,7 +66,8 @@ export default { typingTimeout: null, isFocused: false, menu: null, - filteredItems: null + filteredItems: null, + inputFocused: false } }, watch: { @@ -129,6 +130,9 @@ export default { }, 100) this.setInputWidth() }, + setInputFocused(focused) { + this.inputFocused = focused + }, setInputWidth() { setTimeout(() => { var value = this.$refs.input.value From 41fe5373a7096fc050962acb7eaf1522d5fd5d43 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Wed, 6 Nov 2024 22:06:58 -0700 Subject: [PATCH 024/762] Add: check that `migrationsMeta` table is well formed --- server/managers/MigrationManager.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js index dc2f9235..003f8dfa 100644 --- a/server/managers/MigrationManager.js +++ b/server/managers/MigrationManager.js @@ -191,7 +191,21 @@ class MigrationManager { const queryInterface = this.sequelize.getQueryInterface() let migrationsMetaTableExists = await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE) + // If the table exists, check that the `version` and `maxVersion` rows exist + if (migrationsMetaTableExists) { + const [{ count }] = await this.sequelize.query("SELECT COUNT(*) as count FROM :migrationsMeta WHERE key IN ('version', 'maxVersion')", { + replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE }, + type: Sequelize.QueryTypes.SELECT + }) + if (count < 2) { + Logger.warn(`[MigrationManager] migrationsMeta table exists but is missing 'version' or 'maxVersion' row. Dropping it...`) + await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE) + migrationsMetaTableExists = false + } + } + if (this.isDatabaseNew && migrationsMetaTableExists) { + Logger.warn(`[MigrationManager] migrationsMeta table already exists. Dropping it...`) // This can happen if database was initialized with force: true await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE) migrationsMetaTableExists = false From a5ebd89817cf1e22306252ea613190fcf0315a08 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 7 Nov 2024 16:32:05 -0600 Subject: [PATCH 025/762] Update FolderWatcher to singleton --- server/Server.js | 9 ++++----- server/Watcher.js | 2 +- server/controllers/LibraryController.js | 7 ++++--- server/controllers/MiscController.js | 7 ++++--- server/managers/PodcastManager.js | 9 ++++----- server/routers/ApiRouter.js | 2 -- 6 files changed, 17 insertions(+), 19 deletions(-) diff --git a/server/Server.js b/server/Server.js index 58a2079e..be129464 100644 --- a/server/Server.js +++ b/server/Server.js @@ -62,7 +62,6 @@ class Server { fs.mkdirSync(global.MetadataPath) } - this.watcher = new Watcher() this.auth = new Auth() // Managers @@ -70,7 +69,7 @@ class Server { this.backupManager = new BackupManager() this.abMergeManager = new AbMergeManager() this.playbackSessionManager = new PlaybackSessionManager() - this.podcastManager = new PodcastManager(this.watcher) + this.podcastManager = new PodcastManager() this.audioMetadataManager = new AudioMetadataMangaer() this.rssFeedManager = new RssFeedManager() this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager) @@ -147,9 +146,9 @@ class Server { if (Database.serverSettings.scannerDisableWatcher) { Logger.info(`[Server] Watcher is disabled`) - this.watcher.disabled = true + Watcher.disabled = true } else { - this.watcher.initWatcher(libraries) + Watcher.initWatcher(libraries) } } @@ -435,7 +434,7 @@ class Server { */ async stop() { Logger.info('=== Stopping Server ===') - await this.watcher.close() + Watcher.close() Logger.info('Watcher Closed') return new Promise((resolve) => { diff --git a/server/Watcher.js b/server/Watcher.js index 0e34fc66..8c2d652e 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -409,4 +409,4 @@ class FolderWatcher extends EventEmitter { }, 5000) } } -module.exports = FolderWatcher +module.exports = new FolderWatcher() diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 61ffb5bd..0bd499f1 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -17,6 +17,7 @@ const naturalSort = createNewSortInstance({ const LibraryScanner = require('../scanner/LibraryScanner') const Scanner = require('../scanner/Scanner') const Database = require('../Database') +const Watcher = require('../Watcher') const libraryFilters = require('../utils/queries/libraryFilters') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') const authorFilters = require('../utils/queries/authorFilters') @@ -158,7 +159,7 @@ class LibraryController { SocketAuthority.emitter('library_added', library.toOldJSON(), userFilter) // Add library watcher - this.watcher.addLibrary(library) + Watcher.addLibrary(library) res.json(library.toOldJSON()) } @@ -440,7 +441,7 @@ class LibraryController { req.library.libraryFolders = await req.library.getLibraryFolders() // Update watcher - this.watcher.updateLibrary(req.library) + Watcher.updateLibrary(req.library) hasUpdates = true } @@ -466,7 +467,7 @@ class LibraryController { */ async delete(req, res) { // Remove library watcher - this.watcher.removeLibrary(req.library) + Watcher.removeLibrary(req.library) // Remove collections for library const numCollectionsRemoved = await Database.collectionModel.removeAllForLibrary(req.library.id) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index f3dd0c6d..cf901bea 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -5,6 +5,7 @@ const fs = require('../libs/fsExtra') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') +const Watcher = require('../Watcher') const libraryItemFilters = require('../utils/queries/libraryItemFilters') const patternValidation = require('../libs/nodeCron/pattern-validation') @@ -557,10 +558,10 @@ class MiscController { switch (type) { case 'add': - this.watcher.onFileAdded(libraryId, path) + Watcher.onFileAdded(libraryId, path) break case 'unlink': - this.watcher.onFileRemoved(libraryId, path) + Watcher.onFileRemoved(libraryId, path) break case 'rename': const oldPath = req.body.oldPath @@ -568,7 +569,7 @@ class MiscController { Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`) return res.sendStatus(400) } - this.watcher.onFileRename(libraryId, oldPath, path) + Watcher.onFileRename(libraryId, oldPath, path) break default: Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`) diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 503f47c0..f9eb72e4 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -1,6 +1,7 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') +const Watcher = require('../Watcher') const fs = require('../libs/fsExtra') @@ -23,9 +24,7 @@ const AudioFile = require('../objects/files/AudioFile') const LibraryItem = require('../objects/LibraryItem') class PodcastManager { - constructor(watcher) { - this.watcher = watcher - + constructor() { this.downloadQueue = [] this.currentDownload = null @@ -97,7 +96,7 @@ class PodcastManager { } // Ignores all added files to this dir - this.watcher.addIgnoreDir(this.currentDownload.libraryItem.path) + Watcher.addIgnoreDir(this.currentDownload.libraryItem.path) // Make sure podcast library item folder exists if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) { @@ -151,7 +150,7 @@ class PodcastManager { SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient()) SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) - this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) + Watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) this.currentDownload = null if (this.downloadQueue.length) { this.startPodcastEpisodeDownload(this.downloadQueue.shift()) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index c9399d79..7f21c3ac 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -45,8 +45,6 @@ class ApiRouter { this.abMergeManager = Server.abMergeManager /** @type {import('../managers/BackupManager')} */ this.backupManager = Server.backupManager - /** @type {import('../Watcher')} */ - this.watcher = Server.watcher /** @type {import('../managers/PodcastManager')} */ this.podcastManager = Server.podcastManager /** @type {import('../managers/AudioMetadataManager')} */ From 850ed4895577e5ed073556e4dd93f08f1a028bf7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 7 Nov 2024 17:26:51 -0600 Subject: [PATCH 026/762] Fix:Podcast episodes duplicated when a scan runs while the episode is downloading #2785 --- server/Server.js | 3 +++ server/Watcher.js | 22 ++++++++++++++++++++-- server/managers/PodcastManager.js | 3 +++ server/objects/PodcastEpisodeDownload.js | 9 +++++---- server/scanner/LibraryItemScanner.js | 9 +++++++++ 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/server/Server.js b/server/Server.js index be129464..e40e7c57 100644 --- a/server/Server.js +++ b/server/Server.js @@ -149,6 +149,9 @@ class Server { Watcher.disabled = true } else { Watcher.initWatcher(libraries) + Watcher.on('scanFilesChanged', (pendingFileUpdates, pendingTask) => { + LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask) + }) } } diff --git a/server/Watcher.js b/server/Watcher.js index 8c2d652e..85c13e2a 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -2,7 +2,6 @@ const Path = require('path') const EventEmitter = require('events') const Watcher = require('./libs/watcher/watcher') const Logger = require('./Logger') -const LibraryScanner = require('./scanner/LibraryScanner') const Task = require('./objects/Task') const TaskManager = require('./managers/TaskManager') @@ -31,6 +30,8 @@ class FolderWatcher extends EventEmitter { this.filesBeingAdded = new Set() + /** @type {Set} */ + this.ignoreFilePathsDownloading = new Set() /** @type {string[]} */ this.ignoreDirs = [] /** @type {string[]} */ @@ -333,7 +334,7 @@ class FolderWatcher extends EventEmitter { } if (this.pendingFileUpdates.length) { - LibraryScanner.scanFilesChanged(this.pendingFileUpdates, this.pendingTask) + this.emit('scanFilesChanged', this.pendingFileUpdates, this.pendingTask) } else { const taskFinishedString = { text: 'No files to scan', @@ -348,12 +349,29 @@ class FolderWatcher extends EventEmitter { }, this.pendingDelay) } + /** + * + * @param {string} path + * @returns {boolean} + */ checkShouldIgnorePath(path) { return !!this.ignoreDirs.find((dirpath) => { return isSameOrSubPath(dirpath, path) }) } + /** + * When scanning a library item folder these files should be ignored + * Either a podcast episode downloading or a file that is pending by the watcher + * + * @param {string} path + * @returns {boolean} + */ + checkShouldIgnoreFilePath(path) { + if (this.pendingFilePaths.includes(path)) return true + return this.ignoreFilePathsDownloading.has(path) + } + /** * Convert to POSIX and remove trailing slash * @param {string} path diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index f9eb72e4..96ffcb6a 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -97,6 +97,7 @@ class PodcastManager { // Ignores all added files to this dir Watcher.addIgnoreDir(this.currentDownload.libraryItem.path) + Watcher.ignoreFilePathsDownloading.add(this.currentDownload.targetPath) // Make sure podcast library item folder exists if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) { @@ -151,6 +152,8 @@ class PodcastManager { SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) Watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) + + Watcher.ignoreFilePathsDownloading.delete(this.currentDownload.targetPath) this.currentDownload = null if (this.downloadQueue.length) { this.startPodcastEpisodeDownload(this.downloadQueue.shift()) diff --git a/server/objects/PodcastEpisodeDownload.js b/server/objects/PodcastEpisodeDownload.js index 2dfdc52e..86a85801 100644 --- a/server/objects/PodcastEpisodeDownload.js +++ b/server/objects/PodcastEpisodeDownload.js @@ -1,6 +1,6 @@ const Path = require('path') -const uuidv4 = require("uuid").v4 -const { sanitizeFilename } = require('../utils/fileUtils') +const uuidv4 = require('uuid').v4 +const { sanitizeFilename, filePathToPOSIX } = require('../utils/fileUtils') const globals = require('../utils/globals') class PodcastEpisodeDownload { @@ -60,7 +60,7 @@ class PodcastEpisodeDownload { return sanitizeFilename(filename) } get targetPath() { - return Path.join(this.libraryItem.path, this.targetFilename) + return filePathToPOSIX(Path.join(this.libraryItem.path, this.targetFilename)) } get targetRelPath() { return this.targetFilename @@ -74,7 +74,8 @@ class PodcastEpisodeDownload { this.podcastEpisode = podcastEpisode const url = podcastEpisode.enclosure.url - if (decodeURIComponent(url) !== url) { // Already encoded + if (decodeURIComponent(url) !== url) { + // Already encoded this.url = url } else { this.url = encodeURI(url) diff --git a/server/scanner/LibraryItemScanner.js b/server/scanner/LibraryItemScanner.js index 38608e47..5edfc2e2 100644 --- a/server/scanner/LibraryItemScanner.js +++ b/server/scanner/LibraryItemScanner.js @@ -4,7 +4,9 @@ const { LogLevel, ScanResult } = require('../utils/constants') const fileUtils = require('../utils/fileUtils') const scanUtils = require('../utils/scandir') const libraryFilters = require('../utils/queries/libraryFilters') +const Logger = require('../Logger') const Database = require('../Database') +const Watcher = require('../Watcher') const LibraryScan = require('./LibraryScan') const LibraryItemScanData = require('./LibraryItemScanData') const BookScanner = require('./BookScanner') @@ -128,6 +130,13 @@ class LibraryItemScanner { const libraryFiles = [] for (let i = 0; i < fileItems.length; i++) { const fileItem = fileItems[i] + + if (Watcher.checkShouldIgnoreFilePath(fileItem.fullpath)) { + // Skip file if it's pending + Logger.info(`[LibraryItemScanner] Skipping watcher pending file "${fileItem.fullpath}" during scan of library item path "${libraryItemPath}"`) + continue + } + const newLibraryFile = new LibraryFile() // fileItem.path is the relative path await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path) From d7e810fc2f873e05eedb8ec41919656e21496412 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 8 Nov 2024 08:04:50 -0600 Subject: [PATCH 027/762] Update readme localization chart to for web client only --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 44551acb..e2d1f107 100644 --- a/readme.md +++ b/readme.md @@ -114,7 +114,7 @@ server { proxy_pass http://; proxy_redirect http:// https://; - # Prevent 413 Request Entity Too Large error + # Prevent 413 Request Entity Too Large error # by increasing the maximum allowed size of the client request body # For example, set it to 10 GiB client_max_body_size 10240M; @@ -339,7 +339,7 @@ This application is built using [NodeJs](https://nodejs.org/). ### Localization -Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). Translation status +Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). Translation status ### Dev Container Setup From 435b7fda7e8ddc3ef413a4e583ea09f91627d485 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 09:09:18 -0700 Subject: [PATCH 028/762] Add: check for changes to library items --- server/utils/queries/libraryFilters.js | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 34c3fe54..f66df568 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -485,6 +485,60 @@ module.exports = { } } } else { + // To reduce the cold-start load time, first check if any library items, series, + // or authors have had an "updatedAt" timestamp since the last time the filter + // data was loaded. If so, we can skip loading all of the data. + // Because many items could change, just check the count of items. + const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0 + + const changedBooks = await Database.bookModel.count({ + include: { + model: Database.libraryItemModel, + attributes: [], + where: { + libraryId: libraryId, + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + } + }, + where: { + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + }, + limit: 1 + }) + + const changedSeries = await Database.seriesModel.count({ + where: { + libraryId: libraryId, + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + }, + limit: 1 + }) + + const changedAuthors = await Database.authorModel.count({ + where: { + libraryId: libraryId, + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + }, + limit: 1 + }) + + if (changedBooks + changedSeries + changedAuthors === 0) { + // If nothing has changed, update the cache to current time for 30 minute + // cache time and return the cached data + Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) + Database.libraryFilterData[libraryId].loadedAt = Date.now() + return cachedFilterData + } + + // Something has changed in one of the tables, so reload all of the filter data for library const books = await Database.bookModel.findAll({ include: { model: Database.libraryItemModel, From e57d4cc54435dfa651a7de30a1a4b0a8fcd8d926 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 09:33:34 -0700 Subject: [PATCH 029/762] Add: filter update check to podcast libraries --- server/utils/queries/libraryFilters.js | 41 ++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index f66df568..2be415e2 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -462,7 +462,42 @@ module.exports = { numIssues: 0 } + const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0 + if (mediaType === 'podcast') { + // To reduce the cold-start load time, first check if any podcasts + // have an "updatedAt" timestamp since the last time the filter + // data was loaded. If so, we can skip loading all of the data. + // Because many items could change, just check the count of items instead + // of actually loading the data twice + const changedPodcasts = await Database.podcastModel.count({ + include: { + model: Database.libraryItemModel, + attributes: [], + where: { + libraryId: libraryId, + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + } + }, + where: { + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + }, + limit: 1 + }) + + if (changedPodcasts === 0) { + // If nothing has changed, update the cache to current time for 30 minute + // cache time and return the cached data + Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) + Database.libraryFilterData[libraryId].loadedAt = Date.now() + return cachedFilterData + } + + // Something has changed in the podcasts table, so reload all of the filter data for library const podcasts = await Database.podcastModel.findAll({ include: { model: Database.libraryItemModel, @@ -486,10 +521,10 @@ module.exports = { } } else { // To reduce the cold-start load time, first check if any library items, series, - // or authors have had an "updatedAt" timestamp since the last time the filter + // or authors have an "updatedAt" timestamp since the last time the filter // data was loaded. If so, we can skip loading all of the data. - // Because many items could change, just check the count of items. - const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0 + // Because many items could change, just check the count of items instead + // of actually loading the data twice const changedBooks = await Database.bookModel.count({ include: { From e8d8b67c0aa170f5b0fe4fe8c5996a00b49bbdc0 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 10:49:12 -0700 Subject: [PATCH 030/762] Add: check for deleted items --- server/utils/queries/libraryFilters.js | 72 ++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 2be415e2..64ad07ee 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -459,12 +459,29 @@ module.exports = { languages: new Set(), publishers: new Set(), publishedDecades: new Set(), + bookCount: 0, // How many books returned from database query + authorCount: 0, // How many authors returned from database query + seriesCount: 0, // How many series returned from database query + podcastCount: 0, // How many podcasts returned from database query numIssues: 0 } const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0 if (mediaType === 'podcast') { + // Check how many podcasts are in library to determine if we need to load all of the data + // This is done to handle the edge case of podcasts having been deleted and not having + // an updatedAt timestamp to trigger a reload of the filter data + const podcastCountFromDatabase = await Database.podcastModel.count({ + include: { + model: Database.libraryItemModel, + attributes: [], + where: { + libraryId: libraryId + } + } + }) + // To reduce the cold-start load time, first check if any podcasts // have an "updatedAt" timestamp since the last time the filter // data was loaded. If so, we can skip loading all of the data. @@ -490,11 +507,14 @@ module.exports = { }) if (changedPodcasts === 0) { - // If nothing has changed, update the cache to current time for 30 minute - // cache time and return the cached data - Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) - Database.libraryFilterData[libraryId].loadedAt = Date.now() - return cachedFilterData + // If nothing has changed, check if the number of podcasts in + // library is still the same as prior check before updating cache creation time + + if (podcastCountFromDatabase === Database.libraryFilterData[libraryId].podcastCount) { + Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) + Database.libraryFilterData[libraryId].loadedAt = Date.now() + return cachedFilterData + } } // Something has changed in the podcasts table, so reload all of the filter data for library @@ -519,7 +539,32 @@ module.exports = { data.languages.add(podcast.language) } } + + // Set podcast count for later comparison + data.podcastCount = podcastCountFromDatabase } else { + const bookCountFromDatabase = await Database.bookModel.count({ + include: { + model: Database.libraryItemModel, + attributes: [], + where: { + libraryId: libraryId + } + } + }) + + const seriesCountFromDatabase = await Database.seriesModel.count({ + where: { + libraryId: libraryId + } + }) + + const authorCountFromDatabase = await Database.authorModel.count({ + where: { + libraryId: libraryId + } + }) + // To reduce the cold-start load time, first check if any library items, series, // or authors have an "updatedAt" timestamp since the last time the filter // data was loaded. If so, we can skip loading all of the data. @@ -566,13 +611,20 @@ module.exports = { }) if (changedBooks + changedSeries + changedAuthors === 0) { - // If nothing has changed, update the cache to current time for 30 minute - // cache time and return the cached data - Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) - Database.libraryFilterData[libraryId].loadedAt = Date.now() - return cachedFilterData + // If nothing has changed, check if the number of authors, series, and books + // matches the prior check before updating cache creation time + if (bookCountFromDatabase === Database.libraryFilterData[libraryId].bookCount && seriesCountFromDatabase === Database.libraryFilterData[libraryId].seriesCount && authorCountFromDatabase === Database.libraryFilterData[libraryId].authorCount) { + Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) + Database.libraryFilterData[libraryId].loadedAt = Date.now() + return cachedFilterData + } } + // Store the counts for later comparison + data.bookCount = bookCountFromDatabase + data.seriesCount = seriesCountFromDatabase + data.authorCount = authorCountFromDatabase + // Something has changed in one of the tables, so reload all of the filter data for library const books = await Database.bookModel.findAll({ include: { From 1fa67535f9c88466aab8da662f40c7f136fe5e49 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 11:20:02 -0700 Subject: [PATCH 031/762] Update: only run CodeQL and Integration actions if code changed --- .github/workflows/codeql.yml | 77 +++++++++++++++----------- .github/workflows/integration-test.yml | 7 +++ 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a77ab3e0..8d43311b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,11 +1,25 @@ -name: "CodeQL" +name: 'CodeQL' on: push: - branches: [ 'master' ] + branches: ['master'] + # Only build when files in these directories have been changed + paths: + - client/** + - server/** + - test/** + - index.js + - package.json pull_request: # The branches below must be a subset of the branches above - branches: [ 'master' ] + branches: ['master'] + # Only build when files in these directories have been changed + paths: + - client/** + - server/** + - test/** + - index.js + - package.json schedule: - cron: '16 5 * * 4' @@ -21,45 +35,44 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'javascript' ] + language: ['javascript'] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Use only 'java' to analyze code written in Java, Kotlin or both # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v3 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: '/language:${{matrix.language}}' diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 3e499468..580c0f50 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -5,6 +5,13 @@ on: push: branches-ignore: - 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests + # Only build when files in these directories have been changed + paths: + - client/** + - server/** + - test/** + - index.js + - package.json jobs: build: From 713bdcbc419b6b5db9af68effd3b0984be93aa41 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 9 Nov 2024 13:10:46 -0700 Subject: [PATCH 032/762] Add: migration for mediaId to use UUID instead of UUIDV4 --- server/migrations/v2.16.3-uuid-replacement.js | 50 +++++++++++++++++++ server/models/LibraryItem.js | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 server/migrations/v2.16.3-uuid-replacement.js diff --git a/server/migrations/v2.16.3-uuid-replacement.js b/server/migrations/v2.16.3-uuid-replacement.js new file mode 100644 index 00000000..66bf21ac --- /dev/null +++ b/server/migrations/v2.16.3-uuid-replacement.js @@ -0,0 +1,50 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script changes the `mediaId` column in the `libraryItems` table to be a UUID and match other tables. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.16.3 migration] UPGRADE BEGIN: 2.16.3-uuid-replacement') + + // Change mediaId column to using the query interface + logger.info('[2.16.3 migration] Changing mediaId column to UUID') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUID' + }) + + // Completed migration + logger.info('[2.16.3 migration] UPGRADE END: 2.16.3-uuid-replacement') +} + +/** + * This downward migration script changes the `mediaId` column in the `libraryItems` table to be a UUIDV4 again. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.16.3 migration] DOWNGRADE BEGIN: 2.16.3-uuid-replacement') + + // Change mediaId column to using the query interface + logger.info('[2.16.3 migration] Changing mediaId column to UUIDV4') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUIDV4' + }) + + // Completed migration + logger.info('[2.16.3 migration] DOWNGRADE END: 2.16.3-uuid-replacement') +} + +module.exports = { up, down } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 17c3b125..c2a01785 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -1059,7 +1059,7 @@ class LibraryItem extends Model { ino: DataTypes.STRING, path: DataTypes.STRING, relPath: DataTypes.STRING, - mediaId: DataTypes.UUIDV4, + mediaId: DataTypes.UUID, mediaType: DataTypes.STRING, isFile: DataTypes.BOOLEAN, isMissing: DataTypes.BOOLEAN, From 161a3f4da925821d1f2ff41e0d9954291588a308 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 9 Nov 2024 13:20:59 -0700 Subject: [PATCH 033/762] Update migrations changelog for 2.16.3 --- server/migrations/changelog.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 3623300f..bffd4682 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,8 +2,9 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| Server Version | Migration Script Name | Description | +| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------- | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.16.3 | v2.16.3-uuid-replacement | Changes `mediaId` column in `libraryItem` table to match the primary key type of `books` and `podcasts` | From 2e970cbb3984e4f65a214993c66474e2800e5803 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 9 Nov 2024 18:03:50 -0600 Subject: [PATCH 034/762] Fix:Series Progress filters incorrect - showing for any users progress #2923 --- server/utils/queries/seriesFilters.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/utils/queries/seriesFilters.js b/server/utils/queries/seriesFilters.js index 06ca2547..c293f1df 100644 --- a/server/utils/queries/seriesFilters.js +++ b/server/utils/queries/seriesFilters.js @@ -73,15 +73,19 @@ module.exports = { userPermissionBookWhere.replacements.filterValue = filterValue } else if (filterGroup === 'progress') { if (filterValue === 'not-finished') { - attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + userPermissionBookWhere.replacements.userId = user.id } else if (filterValue === 'finished') { - const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0)) + userPermissionBookWhere.replacements.userId = user.id } else if (filterValue === 'not-started') { - const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)' + const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)' seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0)) + userPermissionBookWhere.replacements.userId = user.id } else if (filterValue === 'in-progress') { - attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0' + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0' + userPermissionBookWhere.replacements.userId = user.id } } From a38248217303413fd9bb14bd96c79801b43c60b2 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 10 Nov 2024 08:34:47 +0200 Subject: [PATCH 035/762] Add in-memory user cache --- server/Auth.js | 30 ++++------- server/models/User.js | 112 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 117 insertions(+), 25 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 5b2d8bcd..b0046799 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -990,28 +990,18 @@ class Auth { }) } } - - Database.userModel - .update( - { - pash: pw - }, - { - where: { id: matchingUser.id } - } - ) - .then(() => { - Logger.info(`[Auth] User "${matchingUser.username}" changed password`) - res.json({ - success: true - }) + 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' - }) + } catch (error) { + Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error) + res.json({ + error: 'Unknown error' }) + } } } diff --git a/server/models/User.js b/server/models/User.js index 906a7d68..259f841c 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -3,6 +3,53 @@ const sequelize = require('sequelize') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const { isNullOrNaN } = require('../utils') +const { LRUCache } = require('lru-cache') + +class UserCache { + constructor() { + this.cache = new LRUCache({ max: 100 }) + } + + getById(id) { + const user = this.cache.get(id) + return user + } + + getByEmail(email) { + const user = this.cache.find((u) => u.email === email) + return user + } + + getByUsername(username) { + const user = this.cache.find((u) => u.username === username) + return user + } + + getByOldId(oldUserId) { + const user = this.cache.find((u) => u.extraData?.oldUserId === oldUserId) + return user + } + + getByOpenIDSub(sub) { + const user = this.cache.find((u) => u.extraData?.authOpenIDSub === sub) + return user + } + + set(user) { + user.fromCache = true + this.cache.set(user.id, user) + } + + delete(userId) { + this.cache.delete(userId) + } + + maybeInvalidate(user) { + if (!user.fromCache) this.delete(user.id) + } +} + +const userCache = new UserCache() const { DataTypes, Model } = sequelize @@ -206,7 +253,11 @@ class User extends Model { */ static async getUserByUsername(username) { if (!username) return null - return this.findOne({ + + const cachedUser = userCache.getByUsername(username) + if (cachedUser) return cachedUser + + const user = await this.findOne({ where: { username: { [sequelize.Op.like]: username @@ -214,6 +265,10 @@ class User extends Model { }, include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -223,7 +278,11 @@ class User extends Model { */ static async getUserByEmail(email) { if (!email) return null - return this.findOne({ + + const cachedUser = userCache.getByEmail(email) + if (cachedUser) return cachedUser + + const user = await this.findOne({ where: { email: { [sequelize.Op.like]: email @@ -231,6 +290,10 @@ class User extends Model { }, include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -240,9 +303,17 @@ class User extends Model { */ static async getUserById(userId) { if (!userId) return null - return this.findByPk(userId, { + + const cachedUser = userCache.getById(userId) + if (cachedUser) return cachedUser + + const user = await this.findByPk(userId, { include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -254,12 +325,19 @@ class User extends Model { */ static async getUserByIdOrOldId(userId) { if (!userId) return null - return this.findOne({ + const cachedUser = userCache.getById(userId) || userCache.getByOldId(userId) + if (cachedUser) return cachedUser + + const user = await this.findOne({ where: { [sequelize.Op.or]: [{ id: userId }, { 'extraData.oldUserId': userId }] }, include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -269,10 +347,18 @@ class User extends Model { */ static async getUserByOpenIDSub(sub) { if (!sub) return null - return this.findOne({ + + const cachedUser = userCache.getByOpenIDSub(sub) + if (cachedUser) return cachedUser + + const user = await this.findOne({ where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub), include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -623,6 +709,7 @@ class User extends Model { mediaProgress = await this.sequelize.models.mediaProgress.create(newMediaProgressPayload) this.mediaProgresses.push(mediaProgress) } + userCache.maybeInvalidate(this) return { mediaProgress } @@ -804,6 +891,21 @@ class User extends Model { return hasUpdates } + + async update(values, options) { + userCache.maybeInvalidate(this) + return await super.update(values, options) + } + + async save(options) { + userCache.maybeInvalidate(this) + return await super.save(options) + } + + async destroy(options) { + userCache.delete(this.id) + await super.destroy(options) + } } module.exports = User From 0d54b571517343427ef2a314a831330a196adf00 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Mon, 11 Nov 2024 21:20:53 -0700 Subject: [PATCH 036/762] Add: PR template --- .github/pull_request_template.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..f41e46cc --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,26 @@ + + +## Brief summary + + + +## In-depth Description + + + +## How have you tested this? + + + +## Screenshots + + From b50d7f09278c74eeccffdff1ecc0f2eb4cb8f04f Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 12 Nov 2024 07:25:10 +0200 Subject: [PATCH 037/762] Remove unnecessary socket event causing OOM --- client/pages/library/_library/podcast/download-queue.vue | 5 ----- server/managers/PodcastManager.js | 2 -- 2 files changed, 7 deletions(-) diff --git a/client/pages/library/_library/podcast/download-queue.vue b/client/pages/library/_library/podcast/download-queue.vue index 777ddfc1..5f4bab62 100644 --- a/client/pages/library/_library/podcast/download-queue.vue +++ b/client/pages/library/_library/podcast/download-queue.vue @@ -104,9 +104,6 @@ export default { this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id) } }, - episodeDownloadQueueUpdated(downloadQueueDetails) { - this.episodeDownloadsQueued = downloadQueueDetails.queue.filter((q) => q.libraryId == this.libraryId) - }, async loadInitialDownloadQueue() { this.processing = true const queuePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episode-downloads`).catch((error) => { @@ -128,7 +125,6 @@ export default { this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.on('episode_download_started', this.episodeDownloadStarted) this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished) - this.$root.socket.on('episode_download_queue_updated', this.episodeDownloadQueueUpdated) } }, mounted() { @@ -138,7 +134,6 @@ export default { this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.off('episode_download_started', this.episodeDownloadStarted) this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished) - this.$root.socket.off('episode_download_queue_updated', this.episodeDownloadQueueUpdated) } } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 96ffcb6a..01e661d9 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -63,7 +63,6 @@ class PodcastManager { } async startPodcastEpisodeDownload(podcastEpisodeDownload) { - SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) if (this.currentDownload) { this.downloadQueue.push(podcastEpisodeDownload) SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient()) @@ -149,7 +148,6 @@ class PodcastManager { TaskManager.taskFinished(task) SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient()) - SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) Watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) From 8626fa3e00871555a80a647e058cd8f62ba2ea59 Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 12 Nov 2024 07:37:38 +0200 Subject: [PATCH 038/762] Add episode_download_queue_cleared socket event --- client/pages/item/_id/index.vue | 7 +++++++ server/managers/PodcastManager.js | 1 + 2 files changed, 8 insertions(+) diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 57a1ae74..1baf521c 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -638,6 +638,11 @@ export default { this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id) } }, + episodeDownloadQueueCleared(libraryItemId) { + if (libraryItemId === this.libraryItemId) { + this.episodeDownloadsQueued = [] + } + }, rssFeedOpen(data) { if (data.entityId === this.libraryItemId) { console.log('RSS Feed Opened', data) @@ -776,6 +781,7 @@ export default { this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.on('episode_download_started', this.episodeDownloadStarted) this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished) + this.$root.socket.on('episode_download_queue_cleared', this.episodeDownloadQueueCleared) }, beforeDestroy() { this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated) @@ -787,6 +793,7 @@ export default { this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.off('episode_download_started', this.episodeDownloadStarted) this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished) + this.$root.socket.off('episode_download_queue_cleared', this.episodeDownloadQueueCleared) } } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 01e661d9..0a32e3ca 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -46,6 +46,7 @@ class PodcastManager { var itemDownloads = this.getEpisodeDownloadsInQueue(libraryItemId) Logger.info(`[PodcastManager] Clearing downloads in queue for item "${libraryItemId}" (${itemDownloads.length})`) this.downloadQueue = this.downloadQueue.filter((d) => d.libraryItemId !== libraryItemId) + SocketAuthority.emitter('episode_download_queue_cleared', libraryItemId) } } From c1b626da147c55c1723f236a7a6eed4d9b4deae7 Mon Sep 17 00:00:00 2001 From: Charlie Date: Wed, 30 Oct 2024 15:42:23 +0000 Subject: [PATCH 039/762] Translated using Weblate (French) Currently translated at 97.5% (1045 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index d31c5971..a32296f7 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -180,6 +180,7 @@ "HeaderRemoveEpisodes": "Suppression de {0} épisodes", "HeaderSavedMediaProgress": "Progression de la sauvegarde des médias", "HeaderSchedule": "Programmation", + "HeaderScheduleEpisodeDownloads": "Programmer des téléchargements automatiques d'épisodes", "HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque", "HeaderSession": "Session", "HeaderSetBackupSchedule": "Activer la sauvegarde automatique", @@ -225,6 +226,7 @@ "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", + "LabelApiToken": "Token API", "LabelAppend": "Ajouter", "LabelAudioBitrate": "Débit audio (par exemple 128k)", "LabelAudioChannels": "Canaux audio (1 ou 2)", @@ -261,6 +263,7 @@ "LabelChapters": "Chapitres", "LabelChaptersFound": "chapitres trouvés", "LabelClickForMoreInfo": "Cliquez ici pour plus d’informations", + "LabelClickToUseCurrentValue": "Cliquez pour utiliser la valeur actuelle", "LabelClosePlayer": "Fermer le lecteur", "LabelCodec": "Codec", "LabelCollapseSeries": "Réduire les séries", @@ -306,7 +309,7 @@ "LabelEmailSettingsRejectUnauthorized": "Rejeter les certificats non autorisés", "LabelEmailSettingsRejectUnauthorizedHelp": "Désactiver la validation du certificat SSL peut exposer votre connexion à des risques de sécurité, tels que des attaques de type « Attaque de l’homme du milieu ». Ne désactivez cette option que si vous en comprenez les implications et si vous faites confiance au serveur de messagerie auquel vous vous connectez.", "LabelEmailSettingsSecure": "Sécurisé", - "LabelEmailSettingsSecureHelp": "Si cette option est activée, la connexion utilisera TLS lors de la connexion au serveur. Si elle est désactivée, TLS sera utilisé uniquement si le serveur prend en charge l’extension STARTTLS. Dans la plupart des cas, définissez cette valeur sur « true » si vous vous connectez au port 465. Pour les ports 587 ou 25, laissez-la sur « false ». (source : nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsSecureHelp": "Si cette option est activée, la connexion utilisera TLS lors de la connexion au serveur. Si elle est désactivée, TLS sera utilisé uniquement si le serveur prend en charge l’extension STARTTLS. Dans la plupart des cas, définissez cette valeur sur « true » si vous vous connectez au port 465. Pour les ports 587 ou 25, laissez-la sur « false ». (source : nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Adresse de test", "LabelEmbeddedCover": "Couverture du livre intégrée", "LabelEnable": "Activer", @@ -322,9 +325,12 @@ "LabelEnd": "Fin", "LabelEndOfChapter": "Fin du chapitre", "LabelEpisode": "Épisode", + "LabelEpisodeNotLinkedToRssFeed": "Épisode non lié au flux RSS", + "LabelEpisodeNumber": "Épisode n°{0}", "LabelEpisodeTitle": "Titre de l’épisode", "LabelEpisodeType": "Type de l’épisode", "LabelEpisodes": "Épisodes", + "LabelEpisodic": "Épisodique", "LabelExample": "Exemple", "LabelExpandSeries": "Développer la série", "LabelExpandSubSeries": "Développer les sous-séries", @@ -352,6 +358,7 @@ "LabelFontScale": "Taille de la police de caractère", "LabelFontStrikethrough": "Barrer", "LabelFormat": "Format", + "LabelFull": "Complet", "LabelGenre": "Genre", "LabelGenres": "Genres", "LabelHardDeleteFile": "Suppression du fichier", @@ -407,6 +414,9 @@ "LabelLowestPriority": "Priorité la plus basse", "LabelMatchExistingUsersBy": "Correspondance avec les utilisateurs existants", "LabelMatchExistingUsersByDescription": "Utilisé pour connecter les utilisateurs existants. Une fois connectés, les utilisateurs seront associés à un identifiant unique provenant de votre fournisseur SSO", + "LabelMaxEpisodesToDownload": "Nombre maximum d’épisodes à télécharger. 0 pour illimité.", + "LabelMaxEpisodesToDownloadPerCheck": "Nombre maximum de nouveaux épisodes à télécharger par vérification", + "LabelMaxEpisodesToKeep": "Nombre maximum d’épisodes à conserver", "LabelMediaPlayer": "Lecteur multimédia", "LabelMediaType": "Type de média", "LabelMetaTag": "Balise de métadonnée", @@ -452,12 +462,14 @@ "LabelOpenIDGroupClaimDescription": "Nom de la demande OpenID qui contient une liste des groupes de l’utilisateur. Communément appelé groups. Si elle est configurée, l’application attribuera automatiquement des rôles en fonction de l’appartenance de l’utilisateur à un groupe, à condition que ces groupes soient nommés -sensible à la casse- tel que « admin », « user » ou « guest » dans la demande. Elle doit contenir une liste, et si un utilisateur appartient à plusieurs groupes, l’application attribuera le rôle correspondant au niveau d’accès le plus élevé. Si aucun groupe ne correspond, l’accès sera refusé.", "LabelOpenRSSFeed": "Ouvrir le flux RSS", "LabelOverwrite": "Écraser", + "LabelPaginationPageXOfY": "Page {0} sur {1}", "LabelPassword": "Mot de passe", "LabelPath": "Chemin", "LabelPermanent": "Permanent", "LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque", "LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes", "LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint", + "LabelPermissionsCreateEreader": "Peut créer une liseuse", "LabelPermissionsDelete": "Peut supprimer", "LabelPermissionsDownload": "Peut télécharger", "LabelPermissionsUpdate": "Peut mettre à jour", @@ -502,18 +514,24 @@ "LabelRedo": "Refaire", "LabelRegion": "Région", "LabelReleaseDate": "Date de parution", + "LabelRemoveAllMetadataAbs": "Supprimer tous les fichiers metadata.abs", + "LabelRemoveAllMetadataJson": "Supprimer tous les fichiers metadata.json", "LabelRemoveCover": "Supprimer la couverture", + "LabelRemoveMetadataFile": "Supprimer les fichiers de métadonnées dans les dossiers des éléments de la bibliothèque", + "LabelRemoveMetadataFileHelp": "Supprimer tous les fichiers metadata.json et metadata.abs de vos dossiers {0}.", "LabelRowsPerPage": "Lignes par page", "LabelSearchTerm": "Terme de recherche", "LabelSearchTitle": "Titre de recherche", "LabelSearchTitleOrASIN": "Recherche du titre ou ASIN", "LabelSeason": "Saison", + "LabelSeasonNumber": "Saison n°{0}", "LabelSelectAll": "Tout sélectionner", "LabelSelectAllEpisodes": "Sélectionner tous les épisodes", "LabelSelectEpisodesShowing": "Sélectionner {0} épisode(s) en cours", "LabelSelectUsers": "Sélectionner les utilisateurs", "LabelSendEbookToDevice": "Envoyer le livre numérique à…", "LabelSequence": "Séquence", + "LabelSerial": "N° de série", "LabelSeries": "Séries", "LabelSeriesName": "Nom de la série", "LabelSeriesProgress": "Progression de séries", @@ -542,6 +560,7 @@ "LabelSettingsHideSingleBookSeriesHelp": "Les séries qui ne comportent qu’un seul livre seront masquées sur la page de la série et sur les étagères de la page d’accueil.", "LabelSettingsHomePageBookshelfView": "Utiliser la vue étagère sur la page d’accueil", "LabelSettingsLibraryBookshelfView": "Utiliser la vue étagère pour la bibliothèque", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Le pourcentage d'achèvement est supérieur à", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sauter les livres précédents dans « Continuer la série »", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "L’étagère de la page d’accueil « Continuer la série » affiche le premier livre non commencé dans les séries dont au moins un livre est terminé et aucun livre n’est en cours. L’activation de ce paramètre permet de poursuivre la série à partir du dernier livre terminé au lieu du premier livre non commencé.", "LabelSettingsParseSubtitles": "Analyser les sous-titres", @@ -626,6 +645,7 @@ "LabelTracksMultiTrack": "Piste multiple", "LabelTracksNone": "Aucune piste", "LabelTracksSingleTrack": "Piste simple", + "LabelTrailer": "Bande-annonce", "LabelType": "Type", "LabelUnabridged": "Version intégrale", "LabelUndo": "Annuler", From 3a5f6ab6f14f18b3194934cefd96e5195dabc4a5 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 30 Oct 2024 20:17:01 +0000 Subject: [PATCH 040/762] Translated using Weblate (Russian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index d27f138f..b0743af6 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -229,7 +229,7 @@ "LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке", "LabelApiToken": "Токен API", "LabelAppend": "Добавить", - "LabelAudioBitrate": "Битрейт аудио (напр. 128k)", + "LabelAudioBitrate": "Битрейт (напр. 128k)", "LabelAudioChannels": "Аудиоканалы (1 или 2)", "LabelAudioCodec": "Аудиокодек", "LabelAuthor": "Автор", @@ -366,7 +366,7 @@ "LabelHardDeleteFile": "Жесткое удаление файла", "LabelHasEbook": "Есть e-книга", "LabelHasSupplementaryEbook": "Есть дополнительная e-книга", - "LabelHideSubtitles": "Скрыть субтитры", + "LabelHideSubtitles": "Скрыть серии", "LabelHighestPriority": "Наивысший приоритет", "LabelHost": "Хост", "LabelHour": "Часы", @@ -496,8 +496,8 @@ "LabelPubDate": "Дата публикации", "LabelPublishYear": "Год публикации", "LabelPublishedDate": "Опубликовано {0}", - "LabelPublishedDecade": "Опубликованное десятилетие", - "LabelPublishedDecades": "Опубликованные десятилетия", + "LabelPublishedDecade": "Декада публикации", + "LabelPublishedDecades": "Декады публикации", "LabelPublisher": "Издатель", "LabelPublishers": "Издатели", "LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца", @@ -588,7 +588,7 @@ "LabelShareURL": "Общедоступный URL", "LabelShowAll": "Показать все", "LabelShowSeconds": "Отображать секунды", - "LabelShowSubtitles": "Показать субтитры", + "LabelShowSubtitles": "Показать серии", "LabelSize": "Размер", "LabelSleepTimer": "Таймер сна", "LabelSlug": "Слизень", From f161158d838e00cebd1b458a4097048c90b2fad8 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Fri, 1 Nov 2024 15:55:01 +0000 Subject: [PATCH 041/762] Translated using Weblate (Spanish) Currently translated at 98.3% (1053 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index dbd8bbc6..fea1a85f 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -415,6 +415,9 @@ "LabelMatchExistingUsersBy": "Emparejar a los usuarios existentes por", "LabelMatchExistingUsersByDescription": "Se utiliza para conectar usuarios existentes. Una vez conectados, los usuarios serán emparejados por un identificador único de su proveedor de SSO", "LabelMaxEpisodesToDownload": "Número máximo # de episodios para descargar. Usa 0 para descargar una cantidad ilimitada.", + "LabelMaxEpisodesToDownloadPerCheck": "Número máximo de episodios nuevos que se descargarán por comprobación", + "LabelMaxEpisodesToKeep": "Número máximo de episodios que se mantendrán", + "LabelMaxEpisodesToKeepHelp": "El valor 0 no establece un límite máximo. Después de que se descargue automáticamente un nuevo episodio, esto eliminará el episodio más antiguo si tiene más de X episodios. Esto solo eliminará 1 episodio por nueva descarga.", "LabelMediaPlayer": "Reproductor de Medios", "LabelMediaType": "Tipo de multimedia", "LabelMetaTag": "Metaetiqueta", @@ -460,12 +463,14 @@ "LabelOpenIDGroupClaimDescription": "Nombre de la declaración OpenID que contiene una lista de grupos del usuario. Comúnmente conocidos como grupos. Si se configura, la aplicación asignará automáticamente roles en función de la pertenencia a grupos del usuario, siempre que estos grupos se denominen \"admin\", \"user\" o \"guest\" en la notificación. La solicitud debe contener una lista, y si un usuario pertenece a varios grupos, la aplicación asignará el rol correspondiente al mayor nivel de acceso. Si ningún grupo coincide, se denegará el acceso.", "LabelOpenRSSFeed": "Abrir Fuente RSS", "LabelOverwrite": "Sobrescribir", + "LabelPaginationPageXOfY": "Página {0} de {1}", "LabelPassword": "Contraseña", "LabelPath": "Ruta de carpeta", "LabelPermanent": "Permanente", "LabelPermissionsAccessAllLibraries": "Puede Accesar a Todas las bibliotecas", "LabelPermissionsAccessAllTags": "Pueda Accesar a Todas las Etiquetas", "LabelPermissionsAccessExplicitContent": "Puede Accesar a Contenido Explicito", + "LabelPermissionsCreateEreader": "Puede crear un gestor de proyectos", "LabelPermissionsDelete": "Puede Eliminar", "LabelPermissionsDownload": "Puede Descargar", "LabelPermissionsUpdate": "Puede Actualizar", @@ -510,18 +515,24 @@ "LabelRedo": "Rehacer", "LabelRegion": "Región", "LabelReleaseDate": "Fecha de Estreno", + "LabelRemoveAllMetadataAbs": "Eliminar todos los archivos metadata.abs", + "LabelRemoveAllMetadataJson": "Eliminar todos los archivos metadata.json", "LabelRemoveCover": "Remover Portada", + "LabelRemoveMetadataFile": "Eliminar archivos de metadatos en carpetas de elementos de biblioteca", + "LabelRemoveMetadataFileHelp": "Elimine todos los archivos metadata.json y metadata.abs de sus carpetas {0}.", "LabelRowsPerPage": "Filas por página", "LabelSearchTerm": "Buscar Termino", "LabelSearchTitle": "Buscar Titulo", "LabelSearchTitleOrASIN": "Buscar Título o ASIN", "LabelSeason": "Temporada", + "LabelSeasonNumber": "Sesión #{0}", "LabelSelectAll": "Seleccionar todo", "LabelSelectAllEpisodes": "Seleccionar todos los episodios", "LabelSelectEpisodesShowing": "Seleccionar los {0} episodios visibles", "LabelSelectUsers": "Seleccionar usuarios", "LabelSendEbookToDevice": "Enviar Ebook a...", "LabelSequence": "Secuencia", + "LabelSerial": "Serial", "LabelSeries": "Series", "LabelSeriesName": "Nombre de la Serie", "LabelSeriesProgress": "Progreso de la Serie", @@ -550,6 +561,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Las series con un solo libro no aparecerán en la página de series ni la repisa para series de la página principal.", "LabelSettingsHomePageBookshelfView": "Usar la vista de librero en la página principal", "LabelSettingsLibraryBookshelfView": "Usar la vista de librero en la biblioteca", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "El porcentaje completado es mayor que", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "El tiempo restante es menor a (segundos)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Marcar el archivo multimedia como terminado cuando", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Saltar libros anteriores de la serie Continuada", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "El estante de la página de inicio de Continuar Serie muestra el primer libro no iniciado de una serie que tenga por lo menos un libro finalizado y no tenga libros en progreso. Habilitar esta opción le permitirá continuar series desde el último libro que ha completado en vez del primer libro que no ha empezado.", "LabelSettingsParseSubtitles": "Extraer Subtítulos", @@ -614,6 +628,7 @@ "LabelTimeDurationXMinutes": "{0} minutos", "LabelTimeDurationXSeconds": "{0} segundos", "LabelTimeInMinutes": "Tiempo en minutos", + "LabelTimeLeft": "Quedan {0}", "LabelTimeListened": "Tiempo Escuchando", "LabelTimeListenedToday": "Tiempo Escuchando Hoy", "LabelTimeRemaining": "{0} restante", @@ -634,6 +649,7 @@ "LabelTracksMultiTrack": "Varias pistas", "LabelTracksNone": "Ninguna pista", "LabelTracksSingleTrack": "Una pista", + "LabelTrailer": "Tráiler", "LabelType": "Tipo", "LabelUnabridged": "No Abreviado", "LabelUndo": "Deshacer", @@ -650,6 +666,7 @@ "LabelUseAdvancedOptions": "Usar opciones avanzadas", "LabelUseChapterTrack": "Usar pista por capitulo", "LabelUseFullTrack": "Usar pista completa", + "LabelUseZeroForUnlimited": "Utilice 0 para ilimitado", "LabelUser": "Usuario", "LabelUsername": "Nombre de Usuario", "LabelValue": "Valor", @@ -708,6 +725,7 @@ "MessageConfirmPurgeCache": "Purgar el caché eliminará el directorio completo ubicado en /metadata/cache.

    ¿Está seguro que desea eliminar el directorio del caché?", "MessageConfirmPurgeItemsCache": "Purgar la caché de los elementos eliminará todo el directorio /metadata/cache/items.
    ¿Estás seguro?", "MessageConfirmQuickEmbed": "¡Advertencia! La integración rápida no realiza copias de seguridad a ninguno de tus archivos de audio. Asegúrate de haber realizado una copia de los mismos previamente.

    ¿Deseas continuar?", + "MessageConfirmQuickMatchEpisodes": "El reconocimiento rápido de extensiones sobrescribirá los detalles si se encuentra una coincidencia. Se actualizarán las extensiones no reconocidas. ¿Está seguro?", "MessageConfirmReScanLibraryItems": "¿Estás seguro de querer re escanear {0} elemento(s)?", "MessageConfirmRemoveAllChapters": "¿Está seguro de que desea remover todos los capitulos?", "MessageConfirmRemoveAuthor": "¿Está seguro de que desea remover el autor \"{0}\"?", @@ -715,6 +733,7 @@ "MessageConfirmRemoveEpisode": "¿Está seguro de que desea remover el episodio \"{0}\"?", "MessageConfirmRemoveEpisodes": "¿Está seguro de que desea remover {0} episodios?", "MessageConfirmRemoveListeningSessions": "¿Está seguro que desea remover {0} sesiones de escuchar?", + "MessageConfirmRemoveMetadataFiles": "¿Está seguro de que desea eliminar todos los archivos de metadatos.{0} en las carpetas de elementos de su biblioteca?", "MessageConfirmRemoveNarrator": "¿Está seguro de que desea remover el narrador \"{0}\"?", "MessageConfirmRemovePlaylist": "¿Está seguro de que desea remover la lista de reproducción \"{0}\"?", "MessageConfirmRenameGenre": "¿Está seguro de que desea renombrar el genero \"{0}\" a \"{1}\" de todos los elementos?", @@ -893,6 +912,7 @@ "StatsYearInReview": "RESEÑA DEL AÑO", "ToastAccountUpdateSuccess": "Cuenta actualizada", "ToastAppriseUrlRequired": "Debes ingresar una URL de Apprise", + "ToastAsinRequired": "Se requiere ASIN", "ToastAuthorImageRemoveSuccess": "Se eliminó la imagen del autor", "ToastAuthorNotFound": "No se encontró el autor \"{0}\"", "ToastAuthorRemoveSuccess": "Autor eliminado", From 4be2909b245b0598e3490a9ef1085ffab8b7d81a Mon Sep 17 00:00:00 2001 From: Charlie Date: Fri, 1 Nov 2024 14:39:45 +0000 Subject: [PATCH 042/762] Translated using Weblate (French) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index a32296f7..a1f5c2c8 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -122,7 +122,7 @@ "HeaderBackups": "Sauvegardes", "HeaderChangePassword": "Modifier le mot de passe", "HeaderChapters": "Chapitres", - "HeaderChooseAFolder": "Choisir un dossier", + "HeaderChooseAFolder": "Sélectionner un dossier", "HeaderCollection": "Collection", "HeaderCollectionItems": "Entrées de la collection", "HeaderCover": "Couverture", @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Mise à jour de la notification", "HeaderNotifications": "Notifications", "HeaderOpenIDConnectAuthentication": "Authentification via OpenID Connect", + "HeaderOpenListeningSessions": "Ouvrir les sessions d'écoutes", "HeaderOpenRSSFeed": "Ouvrir le flux RSS", "HeaderOtherFiles": "Autres fichiers", "HeaderPasswordAuthentication": "Authentification par mot de passe", @@ -329,6 +330,7 @@ "LabelEpisodeNumber": "Épisode n°{0}", "LabelEpisodeTitle": "Titre de l’épisode", "LabelEpisodeType": "Type de l’épisode", + "LabelEpisodeUrlFromRssFeed": "URL de l’épisode à partir du flux RSS", "LabelEpisodes": "Épisodes", "LabelEpisodic": "Épisodique", "LabelExample": "Exemple", @@ -417,6 +419,7 @@ "LabelMaxEpisodesToDownload": "Nombre maximum d’épisodes à télécharger. 0 pour illimité.", "LabelMaxEpisodesToDownloadPerCheck": "Nombre maximum de nouveaux épisodes à télécharger par vérification", "LabelMaxEpisodesToKeep": "Nombre maximum d’épisodes à conserver", + "LabelMaxEpisodesToKeepHelp": "La valeur 0 ne définit aucune limite maximale. Une fois qu’un nouvel épisode est téléchargé automatiquement, l’épisode le plus ancien sera supprimé si vous avez plus de X épisodes. Cela ne supprimera qu’un seul épisode par nouveau téléchargement.", "LabelMediaPlayer": "Lecteur multimédia", "LabelMediaType": "Type de média", "LabelMetaTag": "Balise de métadonnée", @@ -561,6 +564,8 @@ "LabelSettingsHomePageBookshelfView": "Utiliser la vue étagère sur la page d’accueil", "LabelSettingsLibraryBookshelfView": "Utiliser la vue étagère pour la bibliothèque", "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Le pourcentage d'achèvement est supérieur à", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Le temps restant est inférieur à (secondes)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Marquer l’élément multimédia comme terminé lorsque", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sauter les livres précédents dans « Continuer la série »", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "L’étagère de la page d’accueil « Continuer la série » affiche le premier livre non commencé dans les séries dont au moins un livre est terminé et aucun livre n’est en cours. L’activation de ce paramètre permet de poursuivre la série à partir du dernier livre terminé au lieu du premier livre non commencé.", "LabelSettingsParseSubtitles": "Analyser les sous-titres", @@ -625,6 +630,7 @@ "LabelTimeDurationXMinutes": "{0} minutes", "LabelTimeDurationXSeconds": "{0} secondes", "LabelTimeInMinutes": "Temps en minutes", + "LabelTimeLeft": "{0} restant", "LabelTimeListened": "Temps d’écoute", "LabelTimeListenedToday": "Nombres d’écoutes aujourd’hui", "LabelTimeRemaining": "{0} restantes", @@ -662,6 +668,7 @@ "LabelUseAdvancedOptions": "Utiliser les options avancées", "LabelUseChapterTrack": "Utiliser la piste du chapitre", "LabelUseFullTrack": "Utiliser la piste complète", + "LabelUseZeroForUnlimited": "0 pour illimité", "LabelUser": "Utilisateur", "LabelUsername": "Nom d’utilisateur", "LabelValue": "Valeur", @@ -708,7 +715,7 @@ "MessageConfirmDeleteMetadataProvider": "Êtes-vous sûr·e de vouloir supprimer le fournisseur de métadonnées personnalisées « {0} » ?", "MessageConfirmDeleteNotification": "Êtes-vous sûr·e de vouloir supprimer cette notification ?", "MessageConfirmDeleteSession": "Êtes-vous sûr·e de vouloir supprimer cette session ?", - "MessageConfirmEmbedMetadataInAudioFiles": "Souhaitez-vous vraiment intégrer des métadonnées dans {0} fichiers audio ?", + "MessageConfirmEmbedMetadataInAudioFiles": "Êtes-vous sûr·e de vouloir intégrer des métadonnées dans {0} fichiers audio ?", "MessageConfirmForceReScan": "Êtes-vous sûr·e de vouloir lancer une analyse forcée ?", "MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr·e de marquer tous les épisodes comme terminés ?", "MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr·e de vouloir marquer tous les épisodes comme non terminés ?", @@ -719,7 +726,8 @@ "MessageConfirmNotificationTestTrigger": "Déclencher cette notification avec des données de test ?", "MessageConfirmPurgeCache": "La purge du cache supprimera l’intégralité du répertoire à /metadata/cache.

    Êtes-vous sûr·e de vouloir supprimer le répertoire de cache ?", "MessageConfirmPurgeItemsCache": "Purger le cache des éléments supprimera l'ensemble du répertoire /metadata/cache/items.
    Êtes-vous sûr ?", - "MessageConfirmQuickEmbed": "Attention ! L'intégration rapide ne permet pas de sauvegarder vos fichiers audio. Assurez-vous d’avoir effectuer une sauvegarde de vos fichiers audio.

    Souhaitez-vous continuer ?", + "MessageConfirmQuickEmbed": "Attention ! L'intégration rapide ne permet pas de sauvegarder vos fichiers audio. Assurez-vous d’avoir effectuer une sauvegarde de vos fichiers audio.

    Êtes-vous sûr·e de vouloir continuer ?", + "MessageConfirmQuickMatchEpisodes": "Les épisodes correspondants seront écrasés si une correspondance est trouvée. Seuls les épisodes non correspondants seront mis à jour. Êtes-vous sûr·e ?", "MessageConfirmReScanLibraryItems": "Êtes-vous sûr·e de vouloir réanalyser {0} éléments ?", "MessageConfirmRemoveAllChapters": "Êtes-vous sûr·e de vouloir supprimer tous les chapitres ?", "MessageConfirmRemoveAuthor": "Êtes-vous sûr·e de vouloir supprimer l’auteur « {0} » ?", @@ -727,6 +735,7 @@ "MessageConfirmRemoveEpisode": "Êtes-vous sûr·e de vouloir supprimer l’épisode « {0} » ?", "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 ?", "MessageConfirmRemoveNarrator": "Êtes-vous sûr·e de vouloir supprimer le narrateur « {0} » ?", "MessageConfirmRemovePlaylist": "Êtes-vous sûr·e de vouloir supprimer la liste de lecture « {0} » ?", "MessageConfirmRenameGenre": "Êtes-vous sûr·e de vouloir renommer le genre « {0} » en « {1} » pour tous les éléments ?", @@ -807,6 +816,7 @@ "MessagePodcastSearchField": "Saisissez le terme de recherche ou l'URL du flux RSS", "MessageQuickEmbedInProgress": "Intégration rapide en cours", "MessageQuickEmbedQueue": "En file d'attente pour une intégration rapide ({0} dans la file d'attente)", + "MessageQuickMatchAllEpisodes": "Associer rapidement tous les épisodes", "MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de « {0} ». N’écrase pas les données présentes à moins que le paramètre « Préférer les Métadonnées par correspondance » soit activé.", "MessageRemoveChapter": "Supprimer le chapitre", "MessageRemoveEpisodes": "Suppression de {0} épisode(s)", @@ -905,6 +915,7 @@ "StatsYearInReview": "BILAN DE L’ANNÉE", "ToastAccountUpdateSuccess": "Compte mis à jour", "ToastAppriseUrlRequired": "Vous devez entrer une URL Apprise", + "ToastAsinRequired": "ASIN requis", "ToastAuthorImageRemoveSuccess": "Image de l’auteur supprimée", "ToastAuthorNotFound": "Auteur \"{0}\" non trouvé", "ToastAuthorRemoveSuccess": "Auteur supprimé", @@ -924,6 +935,8 @@ "ToastBackupUploadSuccess": "Sauvegarde téléversée", "ToastBatchDeleteFailed": "Échec de la suppression par lot", "ToastBatchDeleteSuccess": "Suppression par lot réussie", + "ToastBatchQuickMatchFailed": "Échec de la correspondance rapide par lot !", + "ToastBatchQuickMatchStarted": "La correspondance rapide par lots de {0} livres a commencé !", "ToastBatchUpdateFailed": "Échec de la mise à jour par lot", "ToastBatchUpdateSuccess": "Mise à jour par lot terminée", "ToastBookmarkCreateFailed": "Échec de la création de signet", @@ -935,6 +948,7 @@ "ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs", "ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre", "ToastChaptersRemoved": "Chapitres supprimés", + "ToastChaptersUpdated": "Chapitres mis à jour", "ToastCollectionItemsAddFailed": "Échec de l’ajout de(s) élément(s) à la collection", "ToastCollectionItemsAddSuccess": "Ajout de(s) élément(s) à la collection réussi", "ToastCollectionItemsRemoveSuccess": "Élément(s) supprimé(s) de la collection", @@ -952,11 +966,14 @@ "ToastEncodeCancelSucces": "Encodage annulé", "ToastEpisodeDownloadQueueClearFailed": "Échec de la suppression de la file d'attente", "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", "ToastFailedToLoadData": "Échec du chargement des données", + "ToastFailedToMatch": "Échec de la correspondance", "ToastFailedToShare": "Échec du partage", "ToastFailedToUpdate": "Échec de la mise à jour", "ToastInvalidImageUrl": "URL de l'image invalide", + "ToastInvalidMaxEpisodesToDownload": "Nombre maximum d’épisodes à télécharger non valide", "ToastInvalidUrl": "URL invalide", "ToastItemCoverUpdateSuccess": "Couverture mise à jour", "ToastItemDeletedFailed": "La suppression de l'élément à échouée", @@ -975,14 +992,21 @@ "ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée", "ToastLibraryUpdateSuccess": "Bibliothèque « {0} » mise à jour", "ToastMatchAllAuthorsFailed": "Tous les auteurs et autrices n’ont pas pu être classés", + "ToastMetadataFilesRemovedError": "Erreur lors de la suppression des fichiers « metadata.{0} »", + "ToastMetadataFilesRemovedNoneFound": "Aucun fichier « metadata.{0} » trouvé dans la bibliothèque", + "ToastMetadataFilesRemovedNoneRemoved": "Aucun fichier « metadata.{0} » n’a été supprimé", + "ToastMetadataFilesRemovedSuccess": "{0} fichiers metadata.{1} supprimés", + "ToastMustHaveAtLeastOnePath": "Doit avoir au moins un chemin", "ToastNameEmailRequired": "Le nom et le courriel sont requis", "ToastNameRequired": "Le nom est requis", + "ToastNewEpisodesFound": "{0} nouveaux épisodes trouvés", "ToastNewUserCreatedFailed": "La création du compte à échouée : « {0} »", "ToastNewUserCreatedSuccess": "Nouveau compte créé", "ToastNewUserLibraryError": "Au moins une bibliothèque est requise", "ToastNewUserPasswordError": "Un mot de passe est requis, seul l’utilisateur root peut avoir un mot de passe vide", "ToastNewUserTagError": "Au moins une étiquette est requise", "ToastNewUserUsernameError": "Entrez un nom d’utilisateur", + "ToastNoNewEpisodesFound": "Aucun nouvel épisode trouvé", "ToastNoUpdatesNecessary": "Aucune mise à jour nécessaire", "ToastNotificationCreateFailed": "La création de la notification à échouée", "ToastNotificationDeleteFailed": "La suppression de la notification à échouée", @@ -1001,6 +1025,7 @@ "ToastPodcastGetFeedFailed": "Échec de la récupération du flux du podcast", "ToastPodcastNoEpisodesInFeed": "Aucun épisode trouvé dans le flux RSS", "ToastPodcastNoRssFeed": "Le podcast n’a pas de flux RSS", + "ToastProgressIsNotBeingSynced": "La progression n’est pas synchronisée, redémarrez la lecture", "ToastProviderCreatedFailed": "Échec de l’ajout du fournisseur", "ToastProviderCreatedSuccess": "Nouveau fournisseur ajouté", "ToastProviderNameAndUrlRequired": "Nom et URL requis", @@ -1027,6 +1052,7 @@ "ToastSessionCloseFailed": "Échec de la fermeture de la session", "ToastSessionDeleteFailed": "Échec de la suppression de session", "ToastSessionDeleteSuccess": "Session supprimée", + "ToastSleepTimerDone": "Minuterie de mise en veille terminée… zZzzZz", "ToastSlugMustChange": "L’identifiant d’URL contient des caractères invalides", "ToastSlugRequired": "L’identifiant d’URL est requis", "ToastSocketConnected": "WebSocket connecté", From 9bf46b6367a09c511edaf0eeb7b3989e075f7576 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Sun, 3 Nov 2024 14:05:56 +0000 Subject: [PATCH 043/762] Translated using Weblate (Spanish) Currently translated at 98.4% (1054 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/es.json b/client/strings/es.json index fea1a85f..5ad3111a 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -226,6 +226,7 @@ "LabelAllUsersExcludingGuests": "Todos los usuarios excepto invitados", "LabelAllUsersIncludingGuests": "Todos los usuarios e invitados", "LabelAlreadyInYourLibrary": "Ya existe en la Biblioteca", + "LabelApiToken": "Token de la API", "LabelAppend": "Adjuntar", "LabelAudioBitrate": "Tasa de bits del audio (por ejemplo, 128k)", "LabelAudioChannels": "Canales de audio (1 o 2)", From 4ad130a11a78b5fd06fcc0f39263ab0c1620ce0f Mon Sep 17 00:00:00 2001 From: thehijacker Date: Sat, 2 Nov 2024 18:21:27 +0000 Subject: [PATCH 044/762] Translated using Weblate (Slovenian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index b4e4383f..9966f7d9 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -773,7 +773,7 @@ "MessageMarkAllEpisodesNotFinished": "Označi vse epizode kot nedokončane", "MessageMarkAsFinished": "Označi kot dokončano", "MessageMarkAsNotFinished": "Označi kot nedokončano", - "MessageMatchBooksDescription": "bo poskušal povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisuje čez obstoječe podatke.", + "MessageMatchBooksDescription": "bo poskušalo povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisuje čez obstoječe podatke.", "MessageNoAudioTracks": "Ni zvočnih posnetkov", "MessageNoAuthors": "Brez avtorjev", "MessageNoBackups": "Brez varnostnih kopij", @@ -902,7 +902,7 @@ "StatsBooksFinishedThisYear": "Nekaj knjig, ki so bile dokončane letos…", "StatsBooksListenedTo": "poslušanih knjig", "StatsCollectionGrewTo": "Vaša zbirka knjig se je povečala na …", - "StatsSessions": "sej", + "StatsSessions": "seje", "StatsSpentListening": "porabil za poslušanje", "StatsTopAuthor": "TOP AVTOR", "StatsTopAuthors": "TOP AVTORJI", From 7cbb1c60a277bab5daf259eaa49a65eb1f96095d Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Wed, 6 Nov 2024 10:22:29 +0000 Subject: [PATCH 045/762] Translated using Weblate (Ukrainian) Currently translated at 88.6% (949 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 97 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index 4c1941d4..6005954d 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "Обрати файли", "ButtonClearFilter": "Очистити фільтр", "ButtonCloseFeed": "Закрити стрічку", + "ButtonCloseSession": "Закрити відкритий сеанс", "ButtonCollections": "Добірки", "ButtonConfigureScanner": "Налаштувати сканер", "ButtonCreate": "Створити", @@ -28,6 +29,9 @@ "ButtonEdit": "Редагувати", "ButtonEditChapters": "Редагувати глави", "ButtonEditPodcast": "Редагувати подкаст", + "ButtonEnable": "Увімкнути", + "ButtonFireAndFail": "Вогонь і невдача", + "ButtonFireOnTest": "Випробування на вогнестійкість", "ButtonForceReScan": "Примусово сканувати", "ButtonFullPath": "Повний шлях", "ButtonHide": "Приховати", @@ -46,19 +50,23 @@ "ButtonNevermind": "Скасувати", "ButtonNext": "Наступний", "ButtonNextChapter": "Наступна глава", + "ButtonNextItemInQueue": "Наступний елемент у черзі", "ButtonOk": "Гаразд", "ButtonOpenFeed": "Відкрити стрічку", "ButtonOpenManager": "Відкрити менеджер", "ButtonPause": "Пауза", "ButtonPlay": "Слухати", + "ButtonPlayAll": "Відтворити все", "ButtonPlaying": "Відтворюється", "ButtonPlaylists": "Списки відтворення", "ButtonPrevious": "Попередній", "ButtonPreviousChapter": "Попередня глава", + "ButtonProbeAudioFile": "Перевірити аудіофайл", "ButtonPurgeAllCache": "Очистити весь кеш", "ButtonPurgeItemsCache": "Очистити кеш елементів", "ButtonQueueAddItem": "Додати до черги", "ButtonQueueRemoveItem": "Вилучити з черги", + "ButtonQuickEmbed": "Швидке вбудовування", "ButtonQuickEmbedMetadata": "Швидко вбудувати метадані", "ButtonQuickMatch": "Швидкий пошук", "ButtonReScan": "Пересканувати", @@ -92,6 +100,7 @@ "ButtonStats": "Статистика", "ButtonSubmit": "Надіслати", "ButtonTest": "Перевірити", + "ButtonUnlinkOpenId": "Вимкнути OpenID", "ButtonUpload": "Завантажити", "ButtonUploadBackup": "Завантажити резервну копію", "ButtonUploadCover": "Завантажити обкладинку", @@ -104,6 +113,7 @@ "ErrorUploadFetchMetadataNoResults": "Не вдалося отримати метадані — спробуйте оновити заголовок та/або автора", "ErrorUploadLacksTitle": "Назва обов'язкова", "HeaderAccount": "Профіль", + "HeaderAddCustomMetadataProvider": "Додати користувацький постачальник метаданих", "HeaderAdvanced": "Розширені", "HeaderAppriseNotificationSettings": "Налаштування сповіщень Apprise", "HeaderAudioTracks": "Аудіодоріжки", @@ -149,8 +159,11 @@ "HeaderMetadataToEmbed": "Вбудувати метадані", "HeaderNewAccount": "Новий профіль", "HeaderNewLibrary": "Нова бібліотека", + "HeaderNotificationCreate": "Створити сповіщення", + "HeaderNotificationUpdate": "Оновити сповіщення", "HeaderNotifications": "Сповіщення", "HeaderOpenIDConnectAuthentication": "Автентифікація OpenID Connect", + "HeaderOpenListeningSessions": "Відкриті сеанси прослуховування", "HeaderOpenRSSFeed": "Відкрити RSS-канал", "HeaderOtherFiles": "Інші файли", "HeaderPasswordAuthentication": "Автентифікація за паролем", @@ -168,6 +181,7 @@ "HeaderRemoveEpisodes": "Видалити епізодів: {0}", "HeaderSavedMediaProgress": "Збережений прогрес медіа", "HeaderSchedule": "Розклад", + "HeaderScheduleEpisodeDownloads": "Запланувати автоматичне завантаження епізодів", "HeaderScheduleLibraryScans": "Розклад автосканування бібліотеки", "HeaderSession": "Сеанс", "HeaderSetBackupSchedule": "Встановити розклад резервного копіювання", @@ -206,13 +220,18 @@ "LabelAddToPlaylist": "Додати до списку відтворення", "LabelAddToPlaylistBatch": "Додано елементів у список відтворення: {0}", "LabelAddedAt": "Дата додавання", + "LabelAddedDate": "Додано {0}", "LabelAdminUsersOnly": "Тільки для адміністраторів", "LabelAll": "Усе", "LabelAllUsers": "Усі користувачі", "LabelAllUsersExcludingGuests": "Усі, крім гостей", "LabelAllUsersIncludingGuests": "Усі, включно з гостями", "LabelAlreadyInYourLibrary": "Вже у вашій бібліотеці", + "LabelApiToken": "Токен API", "LabelAppend": "Додати", + "LabelAudioBitrate": "Бітрейт аудіо (напр. 128k)", + "LabelAudioChannels": "Канали аудіо (1 або 2)", + "LabelAudioCodec": "Аудіокодек", "LabelAuthor": "Автор", "LabelAuthorFirstLast": "Автор (за ім'ям)", "LabelAuthorLastFirst": "Автор (за прізвищем)", @@ -225,6 +244,7 @@ "LabelAutoRegister": "Автореєстрація", "LabelAutoRegisterDescription": "Автоматично створювати нових користувачів після входу", "LabelBackToUser": "Повернутися до користувача", + "LabelBackupAudioFiles": "Резервне копіювання аудіофайлів", "LabelBackupLocation": "Розташування резервних копій", "LabelBackupsEnableAutomaticBackups": "Автоматичне резервне копіювання", "LabelBackupsEnableAutomaticBackupsHelp": "Резервні копії збережено у /metadata/backups", @@ -233,18 +253,22 @@ "LabelBackupsNumberToKeep": "Кількість резервних копій", "LabelBackupsNumberToKeepHelp": "Лиш 1 резервну копію буде видалено за раз, тож якщо їх багато, то вам варто видалити їх вручну.", "LabelBitrate": "Бітрейт", + "LabelBonus": "Бонус", "LabelBooks": "Книги", "LabelButtonText": "Текст кнопки", "LabelByAuthor": "від {0}", "LabelChangePassword": "Змінити пароль", "LabelChannels": "Канали", + "LabelChapterCount": "{0} Глав", "LabelChapterTitle": "Назва глави", "LabelChapters": "Глави", "LabelChaptersFound": "глав знайдено", "LabelClickForMoreInfo": "Натисніть, щоб дізнатися більше", + "LabelClickToUseCurrentValue": "Натисніть, щоб використати поточне значення", "LabelClosePlayer": "Закрити програвач", "LabelCodec": "Кодек", "LabelCollapseSeries": "Згорнути серії", + "LabelCollapseSubSeries": "Згорнути підсерії", "LabelCollection": "Добірка", "LabelCollections": "Добірки", "LabelComplete": "Завершити", @@ -290,13 +314,28 @@ "LabelEmailSettingsTestAddress": "Тестова адреса", "LabelEmbeddedCover": "Вбудована обкладинка", "LabelEnable": "Увімкнути", + "LabelEncodingBackupLocation": "Резервна копія ваших оригінальних аудіофайлів буде збережена в:", + "LabelEncodingChaptersNotEmbedded": "Глави не вбудовуються в багатодоріжкові аудіокниги.", + "LabelEncodingClearItemCache": "Переконайтесь, що періодично очищуєте кеш елементів.", + "LabelEncodingFinishedM4B": "Готовий M4B буде поміщений у вашу папку з аудіокнигами за адресою:", + "LabelEncodingInfoEmbedded": "Метадані будуть вбудовані в звукові доріжки всередині папки вашої аудіокниги.", + "LabelEncodingStartedNavigation": "Як тільки завдання розпочнеться, ви можете покинути цю сторінку.", + "LabelEncodingTimeWarning": "Кодування може зайняти до 30 хвилин.", + "LabelEncodingWarningAdvancedSettings": "Увага: не змінюйте ці налаштування, якщо ви не знайомі з параметрами кодування ffmpeg.", + "LabelEncodingWatcherDisabled": "Якщо у вас вимкнено спостереження за папкою, вам потрібно буде повторно відсканувати цю аудіокнигу.", "LabelEnd": "Кінець", "LabelEndOfChapter": "Кінець глави", "LabelEpisode": "Епізод", + "LabelEpisodeNotLinkedToRssFeed": "Епізод не прив'язаний до RSS-каналу", + "LabelEpisodeNumber": "Епізод #{0}", "LabelEpisodeTitle": "Назва епізоду", "LabelEpisodeType": "Тип епізоду", + "LabelEpisodeUrlFromRssFeed": "URL епізоду з RSS-каналу", + "LabelEpisodes": "Епізодов", + "LabelEpisodic": "Епізодичний", "LabelExample": "Приклад", "LabelExpandSeries": "Розгорнути серії", + "LabelExpandSubSeries": "Розгорнути підсерії", "LabelExplicit": "Відверта", "LabelExplicitChecked": "Відверта (з прапорцем)", "LabelExplicitUnchecked": "Не відверта (без прапорця)", @@ -305,7 +344,9 @@ "LabelFetchingMetadata": "Отримання метаданих", "LabelFile": "Файл", "LabelFileBirthtime": "Дата створення", + "LabelFileBornDate": "Народився {0}", "LabelFileModified": "Дата змінення", + "LabelFileModifiedDate": "Змінено {0}", "LabelFilename": "Ім'я файлу", "LabelFilterByUser": "Фільтрувати за користувачем", "LabelFindEpisodes": "Знайти епізоди", @@ -319,6 +360,7 @@ "LabelFontScale": "Розмір шрифту", "LabelFontStrikethrough": "Закреслений", "LabelFormat": "Формат", + "LabelFull": "Повний", "LabelGenre": "Жанр", "LabelGenres": "Жанри", "LabelHardDeleteFile": "Остаточно видалити файл", @@ -361,6 +403,7 @@ "LabelLess": "Менше", "LabelLibrariesAccessibleToUser": "Бібліотеки, доступні користувачу", "LabelLibrary": "Бібліотека", + "LabelLibraryFilterSublistEmpty": "Ні {0}", "LabelLibraryItem": "Елемент бібліотеки", "LabelLibraryName": "Назва бібліотеки", "LabelLimit": "Обмеження", @@ -373,6 +416,10 @@ "LabelLowestPriority": "Найнижчий пріоритет", "LabelMatchExistingUsersBy": "Шукати наявних користувачів за", "LabelMatchExistingUsersByDescription": "Використовується для підключення наявних користувачів. Після підключення користувач отримає унікальний id від вашого сервісу SSO", + "LabelMaxEpisodesToDownload": "Максимальна кількість епізодів для завантаження. Використовуйте 0 для необмеженої кількості.", + "LabelMaxEpisodesToDownloadPerCheck": "Максимальна кількість нових епізодів для завантаження за перевірку", + "LabelMaxEpisodesToKeep": "Максимальна кількість епізодів для зберігання", + "LabelMaxEpisodesToKeepHelp": "Значення 0 не встановлює обмеження. Після автоматичного завантаження нового епізоду, буде видалено найстаріший епізод, якщо у вас більше ніж X епізодів. Видаляється лише 1 епізод за одне нове завантаження.", "LabelMediaPlayer": "Програвач медіа", "LabelMediaType": "Тип медіа", "LabelMetaTag": "Метатег", @@ -418,12 +465,14 @@ "LabelOpenIDGroupClaimDescription": "Ім'я OpenID claim, що містить список груп користувачів. Зазвичай їх називають групами. Якщо налаштовано, застосунок автоматично призначатиме ролі на основі членства користувача в групах, за умови, що ці групи названі в claim'і без урахування реєстру 'admin', 'user' або 'guest'. Claim мусить містити список, і якщо користувач належить до кількох груп, програма призначить йому роль, що відповідає найвищому рівню доступу. Якщо жодна група не збігається, у доступі буде відмовлено.", "LabelOpenRSSFeed": "Відкрити RSS-канал", "LabelOverwrite": "Перезаписати", + "LabelPaginationPageXOfY": "Сторінка {0} з {1}", "LabelPassword": "Пароль", "LabelPath": "Шлях", "LabelPermanent": "Постійний", "LabelPermissionsAccessAllLibraries": "Доступ до усіх бібліотек", "LabelPermissionsAccessAllTags": "Доступ до усіх міток", "LabelPermissionsAccessExplicitContent": "Доступ до відвертого вмісту", + "LabelPermissionsCreateEreader": "Можна створити читалку", "LabelPermissionsDelete": "Може видаляти", "LabelPermissionsDownload": "Може завантажувати", "LabelPermissionsUpdate": "Може оновлювати", @@ -431,6 +480,7 @@ "LabelPersonalYearReview": "Ваші підсумки року ({0})", "LabelPhotoPathURL": "Шлях/URL фото", "LabelPlayMethod": "Метод відтворення", + "LabelPlayerChapterNumberMarker": "{0} з {1}", "LabelPlaylists": "Списки відтворення", "LabelPodcast": "Подкаст", "LabelPodcastSearchRegion": "Регіон пошуку подкасту", @@ -442,8 +492,12 @@ "LabelPrimaryEbook": "Основна електронна книга", "LabelProgress": "Прогрес", "LabelProvider": "Джерело", + "LabelProviderAuthorizationValue": "Значення заголовка авторизації", "LabelPubDate": "Дата публікації", "LabelPublishYear": "Рік публікації", + "LabelPublishedDate": "Опубліковано {0}", + "LabelPublishedDecade": "Десятиліття публікації", + "LabelPublishedDecades": "Опубліковані десятиліття", "LabelPublisher": "Видавець", "LabelPublishers": "Видавці", "LabelRSSFeedCustomOwnerEmail": "Користувацька електронна адреса власника", @@ -463,21 +517,28 @@ "LabelRedo": "Повторити", "LabelRegion": "Регіон", "LabelReleaseDate": "Дата публікації", + "LabelRemoveAllMetadataAbs": "Видалити всі файли metadata.abs", + "LabelRemoveAllMetadataJson": "Видалити всі файли metadata.json", "LabelRemoveCover": "Видалити обкладинку", + "LabelRemoveMetadataFile": "Видалити файли метаданих у папках елементів бібліотеки", + "LabelRemoveMetadataFileHelp": "Видалити всі файли metadata.json та metadata.abs у ваших папках {0}.", "LabelRowsPerPage": "Рядків на сторінку", "LabelSearchTerm": "Пошуковий запит", "LabelSearchTitle": "Пошук за назвою", "LabelSearchTitleOrASIN": "Пошук назви або ASIN", "LabelSeason": "Сезон", + "LabelSeasonNumber": "Сезон #{0}", "LabelSelectAll": "Вибрати все", "LabelSelectAllEpisodes": "Вибрати всі серії", "LabelSelectEpisodesShowing": "Обрати показані епізоди: {0}", "LabelSelectUsers": "Вибрати користувачів", "LabelSendEbookToDevice": "Надіслати електронну книгу на...", "LabelSequence": "Послідовність", + "LabelSerial": "Серійний", "LabelSeries": "Серії", "LabelSeriesName": "Назва серії", "LabelSeriesProgress": "Прогрес серії", + "LabelServerLogLevel": "Рівень журналу сервера", "LabelServerYearReview": "Підсумки року сервера ({0})", "LabelSetEbookAsPrimary": "Зробити основною", "LabelSetEbookAsSupplementary": "Зробити додатковою", @@ -502,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Серії, що містять одну книгу, будуть приховані зі сторінки серій та полиць головної сторінки.", "LabelSettingsHomePageBookshelfView": "Полиці на головній сторінці", "LabelSettingsLibraryBookshelfView": "Показувати полиці у бібліотеці", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Відсоток виконання більше ніж", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Час, що залишився, менше ніж (секунди)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Позначити медіа-елемент як завершений, коли", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропускати попередні книги у Продовжити серії", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Полиця Продовжити серії на головній сторінці показує найпершу непочату книгу з тих серій, у яких ви завершили хоча б одну книгу та не маєте книг у процесі. Якщо увімкнути це налаштування, то серії продовжуватимуться з останньої завершеної книги, а не з першої непочатої.", "LabelSettingsParseSubtitles": "Дістати підзаголовки", @@ -566,6 +630,7 @@ "LabelTimeDurationXMinutes": "{0} хвилини", "LabelTimeDurationXSeconds": "{0} секунди", "LabelTimeInMinutes": "Час у хвилинах", + "LabelTimeLeft": "{0} залишилось", "LabelTimeListened": "Часу прослухано", "LabelTimeListenedToday": "Сьогодні прослухано", "LabelTimeRemaining": "Лишилося: {0}", @@ -573,6 +638,7 @@ "LabelTitle": "Назва", "LabelToolsEmbedMetadata": "Вбудувати метадані", "LabelToolsEmbedMetadataDescription": "Вбудувати метадані в аудіофайли, включно з обкладинками та главами.", + "LabelToolsM4bEncoder": "Кодувальник M4B", "LabelToolsMakeM4b": "Створити M4B-файл аудіокниги", "LabelToolsMakeM4bDescription": "Створити .M4B-аудіокнигу з вбудованими метаданими, обкладинкою та главами.", "LabelToolsSplitM4b": "Розділити M4B на MP3", @@ -585,10 +651,12 @@ "LabelTracksMultiTrack": "Декілька доріжок", "LabelTracksNone": "Доріжки відсутні", "LabelTracksSingleTrack": "Одна доріжка", + "LabelTrailer": "Трейлер", "LabelType": "Тип", "LabelUnabridged": "Повна", "LabelUndo": "Скасувати", "LabelUnknown": "Невідомо", + "LabelUnknownPublishDate": "Невідома дата публікації", "LabelUpdateCover": "Оновити обкладинку", "LabelUpdateCoverHelp": "Дозволити перезапис наявних обкладинок обраних книг після віднайдення", "LabelUpdateDetails": "Оновити подробиці", @@ -597,8 +665,10 @@ "LabelUploaderDragAndDrop": "Перетягніть файли або теки", "LabelUploaderDropFiles": "Перетягніть файли", "LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію", + "LabelUseAdvancedOptions": "Використовувати розширені налаштування", "LabelUseChapterTrack": "Прогрес глави", "LabelUseFullTrack": "Використовувати доріжку повністю", + "LabelUseZeroForUnlimited": "Використовуйте 0 для необмеженої кількості", "LabelUser": "Користувач", "LabelUsername": "Ім’я користувача", "LabelValue": "Значення", @@ -637,19 +707,27 @@ "MessageCheckingCron": "Перевірка планувальника...", "MessageConfirmCloseFeed": "Ви дійсно бажаєте закрити цей канал?", "MessageConfirmDeleteBackup": "Ви дійсно бажаєте видалити резервну копію за {0}?", + "MessageConfirmDeleteDevice": "Ви впевнені, що хочете видалити пристрій для читання \"{0}\"?", "MessageConfirmDeleteFile": "Файл буде видалено з вашої файлової системи. Ви впевнені?", "MessageConfirmDeleteLibrary": "Ви дійсно бажаєте назавжди видалити бібліотеку \"{0}\"?", "MessageConfirmDeleteLibraryItem": "Елемент бібліотеки буде видалено з бази даних та вашої файлової системи. Ви впевнені?", "MessageConfirmDeleteLibraryItems": "З бази даних та вашої файлової системи будуть видалені елементи бібліотеки: {0}. Ви впевнені?", + "MessageConfirmDeleteMetadataProvider": "Ви впевнені, що хочете видалити користувацького постачальника метаданих \"{0}\"?", + "MessageConfirmDeleteNotification": "Ви впевнені, що хочете видалити це сповіщення?", "MessageConfirmDeleteSession": "Ви дійсно бажаєте видалити цей сеанс?", + "MessageConfirmEmbedMetadataInAudioFiles": "Ви впевнені, що хочете вставити метадані в {0} аудіофайлів?", "MessageConfirmForceReScan": "Ви дійсно бажаєте примусово пересканувати?", "MessageConfirmMarkAllEpisodesFinished": "Ви дійсно бажаєте позначити усі епізоди завершеними?", "MessageConfirmMarkAllEpisodesNotFinished": "Ви дійсно бажаєте позначити усі епізоди незавершеними?", + "MessageConfirmMarkItemFinished": "Ви впевнені, що хочете позначити \"{0}\" як завершене?", + "MessageConfirmMarkItemNotFinished": "Ви впевнені, що хочете позначити \"{0}\" як незавершене?", "MessageConfirmMarkSeriesFinished": "Ви дійсно бажаєте позначити усі книги серії завершеними?", "MessageConfirmMarkSeriesNotFinished": "Ви дійсно бажаєте позначити всі книги серії незавершеними?", + "MessageConfirmNotificationTestTrigger": "Активувати це сповіщення з тестовими даними?", "MessageConfirmPurgeCache": "Очищення кешу видалить усю теку /metadata/cache.

    Ви дійсно бажаєте видалити теку кешу?", "MessageConfirmPurgeItemsCache": "Очищення кешу елементів видалить усю теку /metadata/cache/items.
    Ви певні?", "MessageConfirmQuickEmbed": "Увага! Швидке вбудування не створює резервних копій ваших аудіо. Переконайтеся, що маєте копію ваших файлів.

    Продовжити?", + "MessageConfirmQuickMatchEpisodes": "При виявленні співпадінь інформація про епізоди швидкого пошуку буде перезаписана. Будуть оновлені тільки несуперечливі епізоди. Ви впевнені?", "MessageConfirmReScanLibraryItems": "Ви дійсно бажаєте пересканувати елементи: {0}?", "MessageConfirmRemoveAllChapters": "Ви дійсно бажаєте видалити усі глави?", "MessageConfirmRemoveAuthor": "Ви дійсно бажаєте видалити автора \"{0}\"?", @@ -657,6 +735,7 @@ "MessageConfirmRemoveEpisode": "Ви дійсно бажаєте видалити епізод \"{0}\"?", "MessageConfirmRemoveEpisodes": "Ви дійсно бажаєте видалити епізодів: {0}?", "MessageConfirmRemoveListeningSessions": "Ви дійсно бажаєте видалити сеанси прослуховування: {0}?", + "MessageConfirmRemoveMetadataFiles": "Ви впевнені, що хочете видалити всі файли metadata.{0} у папках елементів вашої бібліотеки?", "MessageConfirmRemoveNarrator": "Ви дійсно бажаєте видалити читця \"{0}\"?", "MessageConfirmRemovePlaylist": "Ви дійсно бажаєте видалити список відтворення \"{0}\"?", "MessageConfirmRenameGenre": "Ви дійсно бажаєте замінити жанр \"{0}\" на \"{1}\" для усіх елементів?", @@ -665,11 +744,14 @@ "MessageConfirmRenameTag": "Ви дійсно бажаєте замінити мітку \"{0}\" на \"{1}\" для усіх елементів?", "MessageConfirmRenameTagMergeNote": "Примітка: така мітка вже існує, тож їх буде об'єднано.", "MessageConfirmRenameTagWarning": "Увага! Вже існує схожа мітка у іншому регістрі \"{0}\".", + "MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?", "MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?", + "MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?", "MessageDownloadingEpisode": "Завантаження епізоду", "MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку", "MessageEmbedFailed": "Не вдалося вбудувати!", "MessageEmbedFinished": "Вбудовано!", + "MessageEmbedQueue": "В черзі на вбудовування метаданих ({0} в черзі)", "MessageEpisodesQueuedForDownload": "Епізодів у черзі завантаження: {0}", "MessageEreaderDevices": "Аби гарантувати отримання електронних книг, вам може знадобитися додати вказану вище адресу електронної пошти як правильного відправника на кожному з пристроїв зі списку нижче.", "MessageFeedURLWillBe": "URL-адреса каналу буде {0}", @@ -700,6 +782,7 @@ "MessageNoCollections": "Добірки відсутні", "MessageNoCoversFound": "Обкладинок не знайдено", "MessageNoDescription": "Без опису", + "MessageNoDevices": "Немає пристроїв", "MessageNoDownloadsInProgress": "Немає активних завантажень", "MessageNoDownloadsQueued": "Немає завантажень у черзі", "MessageNoEpisodeMatchesFound": "Відповідних епізодів не знайдено", @@ -713,6 +796,7 @@ "MessageNoLogs": "Журнал порожній", "MessageNoMediaProgress": "Прогрес відсутній", "MessageNoNotifications": "Сповіщення відсутні", + "MessageNoPodcastFeed": "Невірний подкаст: Немає каналу", "MessageNoPodcastsFound": "Подкастів не знайдено", "MessageNoResults": "Немає результатів", "MessageNoSearchResultsFor": "Немає результатів пошуку для \"{0}\"", @@ -727,7 +811,12 @@ "MessagePauseChapter": "Призупинити відтворення глави", "MessagePlayChapter": "Слухати початок глави", "MessagePlaylistCreateFromCollection": "Створити список відтворення з добірки", + "MessagePleaseWait": "Будь ласка, зачекайте...", "MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку", + "MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS фіду", + "MessageQuickEmbedInProgress": "Швидке вбудовування в процесі", + "MessageQuickEmbedQueue": "В черзі на швидке вбудовування ({0} в черзі)", + "MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів", "MessageQuickMatchDescription": "Заповнити відсутні подробиці та обкладинку першим результатом пошуку '{0}'. Не перезаписує подробиці, якщо не увімкнено параметр \"Надавати перевагу віднайденим метаданим\".", "MessageRemoveChapter": "Видалити главу", "MessageRemoveEpisodes": "Видалити епізодів: {0}", @@ -745,6 +834,14 @@ "MessageShareExpiresIn": "Сплине за {0}", "MessageShareURLWillBe": "Поширюваний URL - {0}", "MessageStartPlaybackAtTime": "Почати відтворення \"{0}\" з {1}?", + "MessageTaskAudioFileNotWritable": "Аудіофайл \"{0}\" недоступний для запису", + "MessageTaskCanceledByUser": "Задача скасована користувачем", + "MessageTaskDownloadingEpisodeDescription": "Завантаження епізоду \"{0}\"", + "MessageTaskEmbeddingMetadata": "Вбудовування метаданих", + "MessageTaskEmbeddingMetadataDescription": "Вбудовування метаданих у аудіокнигу \"{0}\"", + "MessageTaskEncodingM4b": "Кодування M4B", + "MessageTaskEncodingM4bDescription": "Кодування аудіокниги \"{0}\" в один файл m4b", + "MessageTaskFailed": "Неуспішно", "MessageThinking": "Думаю…", "MessageUploaderItemFailed": "Не вдалося завантажити", "MessageUploaderItemSuccess": "Успішно завантажено!", From cc42aa32efda563ea7c530e53eb780e959e56232 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Wed, 6 Nov 2024 07:36:43 +0000 Subject: [PATCH 046/762] Translated using Weblate (Slovenian) Currently translated at 100.0% (1071 of 1071 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 9966f7d9..e3320ea8 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -625,7 +625,7 @@ "LabelTheme": "Tema", "LabelThemeDark": "Temna", "LabelThemeLight": "Svetla", - "LabelTimeBase": "Odvisna od časa", + "LabelTimeBase": "Osnovni čas", "LabelTimeDurationXHours": "{0} ur", "LabelTimeDurationXMinutes": "{0} minut", "LabelTimeDurationXSeconds": "{0} sekund", From 023ceed2869d0db65fb1052e57ed21dc48dc07e5 Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Thu, 7 Nov 2024 09:09:11 +0000 Subject: [PATCH 047/762] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 124 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index 6005954d..668ccd33 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -842,6 +842,33 @@ "MessageTaskEncodingM4b": "Кодування M4B", "MessageTaskEncodingM4bDescription": "Кодування аудіокниги \"{0}\" в один файл m4b", "MessageTaskFailed": "Неуспішно", + "MessageTaskFailedToBackupAudioFile": "Не вдалося створити резервну копію аудіофайлу \"{0}\"", + "MessageTaskFailedToCreateCacheDirectory": "Не вдалося створити каталог кешу", + "MessageTaskFailedToEmbedMetadataInFile": "Не вдалося вбудувати метадані у файл \"{0}\"", + "MessageTaskFailedToMergeAudioFiles": "Не вдалося об’єднати аудіофайли", + "MessageTaskFailedToMoveM4bFile": "Не вдалося перемістити файл m4b", + "MessageTaskFailedToWriteMetadataFile": "Не вдалося записати файл метаданих", + "MessageTaskMatchingBooksInLibrary": "Відповідність книг у бібліотеці \"{0}\"", + "MessageTaskNoFilesToScan": "Немає файлів для сканування", + "MessageTaskOpmlImport": "Імпорт OPML", + "MessageTaskOpmlImportDescription": "Створення подкастів з {0} RSS-стрічок", + "MessageTaskOpmlImportFeed": "Канал імпорту OPML", + "MessageTaskOpmlImportFeedDescription": "Імпорт RSS-каналу \"{0}\"", + "MessageTaskOpmlImportFeedFailed": "Не вдалося отримати подкаст-стрічку", + "MessageTaskOpmlImportFeedPodcastDescription": "Створення подкасту \"{0}\"", + "MessageTaskOpmlImportFeedPodcastExists": "Подкаст вже існує за цим шляхом", + "MessageTaskOpmlImportFeedPodcastFailed": "Не вдалося створити подкаст", + "MessageTaskOpmlImportFinished": "Додано {0} подкастів", + "MessageTaskOpmlParseFailed": "Не вдалося розібрати файл OPML", + "MessageTaskOpmlParseFastFail": "Невірний файл OPML: не знайдено тег або тег ", + "MessageTaskOpmlParseNoneFound": "У файлі OPML не знайдено жодного канала", + "MessageTaskScanItemsAdded": "{0} додано", + "MessageTaskScanItemsMissing": "{0} відсутній", + "MessageTaskScanItemsUpdated": "{0} оновлено", + "MessageTaskScanNoChangesNeeded": "Змін не потрібно", + "MessageTaskScanningFileChanges": "Сканування змін файлів у \"{0}\"", + "MessageTaskScanningLibrary": "Сканування бібліотеки \"{0}\"", + "MessageTaskTargetDirectoryNotWritable": "Цільовий каталог недоступний для запису", "MessageThinking": "Думаю…", "MessageUploaderItemFailed": "Не вдалося завантажити", "MessageUploaderItemSuccess": "Успішно завантажено!", @@ -859,6 +886,10 @@ "NoteUploaderFoldersWithMediaFiles": "Теки з медіафайлами буде оброблено як окремі елементи бібліотеки.", "NoteUploaderOnlyAudioFiles": "Якщо завантажувати лише аудіофайли, то кожен файл буде оброблено як окрему книгу.", "NoteUploaderUnsupportedFiles": "Непідтримувані файли пропущено. Під час вибору або перетягування теки, файли, що знаходяться поза текою, пропускаються.", + "NotificationOnBackupCompletedDescription": "Запускається після завершення резервного копіювання", + "NotificationOnBackupFailedDescription": "Срабатывает при збої резервного копіювання", + "NotificationOnEpisodeDownloadedDescription": "Запускається при автоматичному завантаженні епізоду подкасту", + "NotificationOnTestDescription": "Подія для тестування системи сповіщень", "PlaceholderNewCollection": "Нова назва добірки", "PlaceholderNewFolderPath": "Новий шлях до теки", "PlaceholderNewPlaylist": "Нова назва списку", @@ -883,17 +914,29 @@ "StatsTotalDuration": "Загальною довжиною…", "StatsYearInReview": "ОГЛЯД РОКУ", "ToastAccountUpdateSuccess": "Профіль оновлено", + "ToastAppriseUrlRequired": "Необхідно ввести URL для Apprise", + "ToastAsinRequired": "ASIN є обов'язковим", "ToastAuthorImageRemoveSuccess": "Фото автора видалено", + "ToastAuthorNotFound": "Автор \"{0}\" не знайдений", + "ToastAuthorRemoveSuccess": "Автор видалений", + "ToastAuthorSearchNotFound": "Автор не знайдений", "ToastAuthorUpdateMerged": "Автора об'єднано", "ToastAuthorUpdateSuccess": "Автора оновлено", "ToastAuthorUpdateSuccessNoImageFound": "Автора оновлено (фото не знайдено)", + "ToastBackupAppliedSuccess": "Резервна копія застосована", "ToastBackupCreateFailed": "Не вдалося створити резервну копію", "ToastBackupCreateSuccess": "Резервну копію створено", "ToastBackupDeleteFailed": "Не вдалося видалити резервну копію", "ToastBackupDeleteSuccess": "Резервну копію видалено", + "ToastBackupInvalidMaxKeep": "Невірна кількість резервних копій для зберігання", + "ToastBackupInvalidMaxSize": "Невірний максимальний розмір резервної копії", "ToastBackupRestoreFailed": "Не вдалося відновити резервну копію", "ToastBackupUploadFailed": "Не вдалося завантажити резервну копію", "ToastBackupUploadSuccess": "Резервну копію завантажено", + "ToastBatchDeleteFailed": "Помилка при пакетному видаленні", + "ToastBatchDeleteSuccess": "Пакетне видалення успішне", + "ToastBatchQuickMatchFailed": "Не вдалося виконати пакетне швидке співпадіння!", + "ToastBatchQuickMatchStarted": "Пакетне швидке співпадіння {0} книг розпочато!", "ToastBatchUpdateFailed": "Не вдалося оновити обрані", "ToastBatchUpdateSuccess": "Обрані успішно оновлено", "ToastBookmarkCreateFailed": "Не вдалося створити закладку", @@ -904,19 +947,43 @@ "ToastCachePurgeSuccess": "Кеш очищено", "ToastChaptersHaveErrors": "Глави містять помилки", "ToastChaptersMustHaveTitles": "Глави повинні мати назви", + "ToastChaptersRemoved": "Розділи видалені", + "ToastChaptersUpdated": "Розділи оновлені", + "ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції", + "ToastCollectionItemsAddSuccess": "Елемент(и) успішно додано до колекції", "ToastCollectionItemsRemoveSuccess": "Елемент(и) видалено з добірки", "ToastCollectionRemoveSuccess": "Добірку видалено", "ToastCollectionUpdateSuccess": "Добірку оновлено", + "ToastCoverUpdateFailed": "Не вдалося оновити обкладинку", "ToastDeleteFileFailed": "Не вдалося видалити файл", "ToastDeleteFileSuccess": "Файл видалено", + "ToastDeviceAddFailed": "Не вдалося додати пристрій", + "ToastDeviceNameAlreadyExists": "Пристрій для електронних книг з таким ім'ям вже існує", + "ToastDeviceTestEmailFailed": "Не вдалося надіслати тестовий електронний лист", + "ToastDeviceTestEmailSuccess": "Тестовий електронний лист надіслано", + "ToastEmailSettingsUpdateSuccess": "Налаштування електронної пошти оновлено", + "ToastEncodeCancelFailed": "Не вдалося скасувати кодування", + "ToastEncodeCancelSucces": "Кодування скасовано", + "ToastEpisodeDownloadQueueClearFailed": "Не вдалося очистити чергу", + "ToastEpisodeDownloadQueueClearSuccess": "Чергу на завантаження епізодів очищено", + "ToastEpisodeUpdateSuccess": "{0} епізодів оновлено", "ToastErrorCannotShare": "Не можна типово поширити на цей пристрій", "ToastFailedToLoadData": "Не вдалося завантажити дані", + "ToastFailedToMatch": "Не вдалося знайти відповідність", + "ToastFailedToShare": "Не вдалося поділитися", + "ToastFailedToUpdate": "Не вдалося оновити", + "ToastInvalidImageUrl": "Невірний URL зображення", + "ToastInvalidMaxEpisodesToDownload": "Невірна кількість епізодів для завантаження", + "ToastInvalidUrl": "Невірний URL", "ToastItemCoverUpdateSuccess": "Обкладинку елемента оновлено", + "ToastItemDeletedFailed": "Не вдалося видалити елемент", + "ToastItemDeletedSuccess": "Видалений елемент", "ToastItemDetailsUpdateSuccess": "Подробиці про елемент оновлено", "ToastItemMarkedAsFinishedFailed": "Не вдалося позначити як завершене", "ToastItemMarkedAsFinishedSuccess": "Елемент позначено як завершений", "ToastItemMarkedAsNotFinishedFailed": "Не вдалося позначити незавершеним", "ToastItemMarkedAsNotFinishedSuccess": "Елемент позначено незавершеним", + "ToastItemUpdateSuccess": "Елемент оновлено", "ToastLibraryCreateFailed": "Не вдалося створити бібліотеку", "ToastLibraryCreateSuccess": "Бібліотеку \"{0}\" створено", "ToastLibraryDeleteFailed": "Не вдалося видалити бібліотеку", @@ -924,28 +991,83 @@ "ToastLibraryScanFailedToStart": "Не вдалося розпочати сканування", "ToastLibraryScanStarted": "Почалося сканування бібліотеки", "ToastLibraryUpdateSuccess": "Бібліотеку \"{0}\" оновлено", + "ToastMatchAllAuthorsFailed": "Не вдалось знайти відповідності з усіма авторами", + "ToastMetadataFilesRemovedError": "Помилка при видаленні metadata.{0} файли", + "ToastMetadataFilesRemovedNoneFound": "У бібліотеці не знайдено metadata.{0} файлів", + "ToastMetadataFilesRemovedNoneRemoved": "Не видалено metadata.{0} файлів", + "ToastMetadataFilesRemovedSuccess": "{0} metadata.{1} файлів видалено", + "ToastMustHaveAtLeastOnePath": "Повинен бути хоча б один шлях", + "ToastNameEmailRequired": "Ім'я та електронна пошта обов'язкові", + "ToastNameRequired": "Ім'я обов'язкове", + "ToastNewEpisodesFound": "{0} нових епізодів знайдено", + "ToastNewUserCreatedFailed": "Не вдалося створити акаунт: \"{0}\"", + "ToastNewUserCreatedSuccess": "Новий акаунт створено", + "ToastNewUserLibraryError": "Потрібно вибрати хоча б одну бібліотеку", + "ToastNewUserPasswordError": "Пароль обов'язковий, лише користувач з правами root може мати порожній пароль", + "ToastNewUserTagError": "Потрібно вибрати хоча б один тег", + "ToastNewUserUsernameError": "Введіть ім'я користувача", + "ToastNoNewEpisodesFound": "Нових епізодів не знайдено", + "ToastNoUpdatesNecessary": "Оновлення не потрібні", + "ToastNotificationCreateFailed": "Не вдалося створити сповіщення", + "ToastNotificationDeleteFailed": "Не вдалося видалити сповіщення", + "ToastNotificationFailedMaximum": "Максимальна кількість невдалих спроб повинна бути >= 0", + "ToastNotificationQueueMaximum": "Максимальна кількість сповіщень у черзі повинна бути >= 0", + "ToastNotificationSettingsUpdateSuccess": "Налаштування сповіщень оновлено", + "ToastNotificationTestTriggerFailed": "Не вдалося ініціювати тестове сповіщення", + "ToastNotificationTestTriggerSuccess": "Спрацьовувало сповіщення про тестування", + "ToastNotificationUpdateSuccess": "Сповіщення оновлено", "ToastPlaylistCreateFailed": "Не вдалося створити список", "ToastPlaylistCreateSuccess": "Список відтворення створено", "ToastPlaylistRemoveSuccess": "Список відтворення видалено", "ToastPlaylistUpdateSuccess": "Список відтворення оновлено", "ToastPodcastCreateFailed": "Не вдалося створити подкаст", "ToastPodcastCreateSuccess": "Подкаст успішно створено", + "ToastPodcastGetFeedFailed": "Не вдалося отримати фід подкасту", + "ToastPodcastNoEpisodesInFeed": "У RSS-каналі не знайдено епізодів", + "ToastPodcastNoRssFeed": "Подкаст не має RSS-каналу", + "ToastProgressIsNotBeingSynced": "Прогрес не синхронізується, перезапустіть відтворення", + "ToastProviderCreatedFailed": "Не вдалося додати постачальника", + "ToastProviderCreatedSuccess": "Новий постачальник доданий", + "ToastProviderNameAndUrlRequired": "Ім'я та URL обов'язкові", + "ToastProviderRemoveSuccess": "Постачальник видалений", "ToastRSSFeedCloseFailed": "Не вдалося закрити RSS-канал", "ToastRSSFeedCloseSuccess": "RSS-канал закрито", + "ToastRemoveFailed": "Не вдалося видалити", "ToastRemoveItemFromCollectionFailed": "Не вдалося видалити елемент із добірки", "ToastRemoveItemFromCollectionSuccess": "Елемент видалено з добірки", + "ToastRemoveItemsWithIssuesFailed": "Не вдалося видалити елементи бібліотеки з проблемами", + "ToastRemoveItemsWithIssuesSuccess": "Видалено елементи бібліотеки з проблемами", + "ToastRenameFailed": "Не вдалося перейменувати", + "ToastRescanFailed": "Не вдалося повторно сканувати для {0}", + "ToastRescanRemoved": "Повторне сканування завершено, елемент був видалений", + "ToastRescanUpToDate": "Повторне сканування завершено, елемент актуальний", + "ToastRescanUpdated": "Повторне сканування завершено, елемент оновлено", + "ToastScanFailed": "Не вдалося сканувати елемент бібліотеки", + "ToastSelectAtLeastOneUser": "Виберіть хоча б одного користувача", "ToastSendEbookToDeviceFailed": "Не вдалося надіслати електронну книгу на пристрій", "ToastSendEbookToDeviceSuccess": "Електронну книгу надіслано на пристрій \"{0}\"", "ToastSeriesUpdateFailed": "Не вдалося оновити серію", "ToastSeriesUpdateSuccess": "Серію успішно оновлено", "ToastServerSettingsUpdateSuccess": "Налаштування сервера оновлено", + "ToastSessionCloseFailed": "Не вдалося закрити сесію", "ToastSessionDeleteFailed": "Не вдалося видалити сесію", "ToastSessionDeleteSuccess": "Сесію видалено", + "ToastSleepTimerDone": "Час сну завершено... зЗзЗз", + "ToastSlugMustChange": "Slug містить недопустимі символи", + "ToastSlugRequired": "Slug обов'язковий", "ToastSocketConnected": "Сокет під'єднано", "ToastSocketDisconnected": "Сокет від'єднано", "ToastSocketFailedToConnect": "Не вдалося під'єднатися до сокета", "ToastSortingPrefixesEmptyError": "Мусить мати хоча б 1 префікс сортування", "ToastSortingPrefixesUpdateSuccess": "Префікси сортування оновлено ({0})", + "ToastTitleRequired": "Заголовок обов'язковий", + "ToastUnknownError": "Невідома помилка", + "ToastUnlinkOpenIdFailed": "Не вдалося відв'язати користувача від OpenID", + "ToastUnlinkOpenIdSuccess": "Користувача відв'язано від OpenID", "ToastUserDeleteFailed": "Не вдалося видалити користувача", - "ToastUserDeleteSuccess": "Користувача видалено" + "ToastUserDeleteSuccess": "Користувача видалено", + "ToastUserPasswordChangeSuccess": "Пароль успішно змінено", + "ToastUserPasswordMismatch": "Паролі не збігаються", + "ToastUserPasswordMustChange": "Новий пароль не може співпадати з попереднім", + "ToastUserRootRequireName": "Потрібно ввести ім'я користувача root" } From 876fcf3296e5e3c8348966068d8351fe7fbd14e3 Mon Sep 17 00:00:00 2001 From: Languages add-on Date: Fri, 8 Nov 2024 02:12:28 +0100 Subject: [PATCH 048/762] Added translation using Weblate (Arabic) --- client/strings/ar.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 client/strings/ar.json diff --git a/client/strings/ar.json b/client/strings/ar.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/client/strings/ar.json @@ -0,0 +1 @@ +{} From ec4c4a4d5aaf245f312ca9a3b4da096e4350bd00 Mon Sep 17 00:00:00 2001 From: kuci-JK Date: Sun, 10 Nov 2024 21:32:17 +0000 Subject: [PATCH 049/762] Translated using Weblate (Czech) Currently translated at 83.4% (894 of 1071 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, 2 insertions(+) diff --git a/client/strings/cs.json b/client/strings/cs.json index 7a898a37..40b8553f 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Aktualizovat notifikaci", "HeaderNotifications": "Oznámení", "HeaderOpenIDConnectAuthentication": "Ověřování pomocí OpenID Connect", + "HeaderOpenListeningSessions": "Otevřené relace přehrávače", "HeaderOpenRSSFeed": "Otevřít RSS kanál", "HeaderOtherFiles": "Ostatní soubory", "HeaderPasswordAuthentication": "Autentizace heslem", @@ -258,6 +259,7 @@ "LabelByAuthor": "od {0}", "LabelChangePassword": "Změnit heslo", "LabelChannels": "Kanály", + "LabelChapterCount": "{0} Kapitol", "LabelChapterTitle": "Název kapitoly", "LabelChapters": "Kapitoly", "LabelChaptersFound": "Kapitoly nalezeny", From 12c2071358f222d882e6e2da6854aa9a41851f53 Mon Sep 17 00:00:00 2001 From: Pavel Vachek Date: Sun, 10 Nov 2024 21:30:41 +0000 Subject: [PATCH 050/762] Translated using Weblate (Czech) Currently translated at 83.4% (894 of 1071 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 40b8553f..0dd6367c 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -67,7 +67,7 @@ "ButtonQueueAddItem": "Přidat do fronty", "ButtonQueueRemoveItem": "Odstranit z fronty", "ButtonQuickEmbed": "Rychle Zapsat", - "ButtonQuickEmbedMetadata": "Rychle Zapsat Metadata", + "ButtonQuickEmbedMetadata": "Rychle zapsat Metadata", "ButtonQuickMatch": "Rychlé přiřazení", "ButtonReScan": "Znovu prohledat", "ButtonRead": "Číst", From bb6377fb22dac62aea9e30a51a7c52dfabaeb6d3 Mon Sep 17 00:00:00 2001 From: Nicholas W Date: Tue, 12 Nov 2024 04:15:18 +0100 Subject: [PATCH 051/762] Deleted translation using Weblate (English (United States)) --- client/strings/en_US.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 client/strings/en_US.json diff --git a/client/strings/en_US.json b/client/strings/en_US.json deleted file mode 100644 index 0967ef42..00000000 --- a/client/strings/en_US.json +++ /dev/null @@ -1 +0,0 @@ -{} From 3f0347253ef748a0c2c288e6a9750c21802f4d47 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Mon, 11 Nov 2024 10:46:15 +0000 Subject: [PATCH 052/762] Translated using Weblate (Spanish) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index 5ad3111a..d9e24723 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Notificación de actualización", "HeaderNotifications": "Notificaciones", "HeaderOpenIDConnectAuthentication": "Autenticación OpenID Connect", + "HeaderOpenListeningSessions": "Sesiones públicas de escucha", "HeaderOpenRSSFeed": "Abrir fuente RSS", "HeaderOtherFiles": "Otros Archivos", "HeaderPasswordAuthentication": "Autenticación por contraseña", @@ -815,6 +816,7 @@ "MessagePodcastSearchField": "Introduzca el término de búsqueda o la URL de la fuente RSS", "MessageQuickEmbedInProgress": "Integración rápida en proceso", "MessageQuickEmbedQueue": "En cola para inserción rápida ({0} en cola)", + "MessageQuickMatchAllEpisodes": "Combina rápidamente todos los episodios", "MessageQuickMatchDescription": "Rellenar detalles de elementos vacíos y portada con los primeros resultados de '{0}'. No sobrescribe los detalles a menos que la opción \"Preferir Metadatos Encontrados\" del servidor esté habilitada.", "MessageRemoveChapter": "Remover capítulos", "MessageRemoveEpisodes": "Remover {0} episodio(s)", @@ -933,6 +935,8 @@ "ToastBackupUploadSuccess": "Respaldo cargado", "ToastBatchDeleteFailed": "Error al eliminar por lotes", "ToastBatchDeleteSuccess": "Borrado por lotes correcto", + "ToastBatchQuickMatchFailed": "¡Error en la sincronización rápida por lotes!", + "ToastBatchQuickMatchStarted": "¡Se inició el lote de búsqueda rápida de {0} libros!", "ToastBatchUpdateFailed": "Subida masiva fallida", "ToastBatchUpdateSuccess": "Subida masiva exitosa", "ToastBookmarkCreateFailed": "Error al crear marcador", @@ -944,6 +948,7 @@ "ToastChaptersHaveErrors": "Los capítulos tienen errores", "ToastChaptersMustHaveTitles": "Los capítulos tienen que tener un título", "ToastChaptersRemoved": "Capítulos eliminados", + "ToastChaptersUpdated": "Capítulos actualizados", "ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)", "ToastCollectionItemsAddSuccess": "Artículo(s) añadido(s) a la colección correctamente", "ToastCollectionItemsRemoveSuccess": "Elementos(s) removidos de la colección", @@ -961,11 +966,14 @@ "ToastEncodeCancelSucces": "Codificación cancelada", "ToastEpisodeDownloadQueueClearFailed": "No se pudo borrar la cola", "ToastEpisodeDownloadQueueClearSuccess": "Se borró la cola de descargas de los episodios", + "ToastEpisodeUpdateSuccess": "{0} episodio(s) actualizado(s)", "ToastErrorCannotShare": "No se puede compartir de forma nativa en este dispositivo", "ToastFailedToLoadData": "Error al cargar data", + "ToastFailedToMatch": "Error al emparejar", "ToastFailedToShare": "Error al compartir", "ToastFailedToUpdate": "Error al actualizar", "ToastInvalidImageUrl": "URL de la imagen no válida", + "ToastInvalidMaxEpisodesToDownload": "Número máximo de episodios para descargar no válidos", "ToastInvalidUrl": "URL no válida", "ToastItemCoverUpdateSuccess": "Portada del elemento actualizada", "ToastItemDeletedFailed": "Error al eliminar el elemento", @@ -984,14 +992,21 @@ "ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca", "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada", "ToastMatchAllAuthorsFailed": "No coincide con todos los autores", + "ToastMetadataFilesRemovedError": "Error al eliminar metadatos de {0} archivo(s)", + "ToastMetadataFilesRemovedNoneFound": "No hay metadatos.{0} archivo(s) encontrado(s) en la biblioteca", + "ToastMetadataFilesRemovedNoneRemoved": "Sin metadatos.{0} archivo(s) eliminado(s)", + "ToastMetadataFilesRemovedSuccess": "{0} metadatos.{1} archivos eliminados", + "ToastMustHaveAtLeastOnePath": "Debe tener al menos una ruta", "ToastNameEmailRequired": "Son obligatorios el nombre y el correo electrónico", "ToastNameRequired": "Nombre obligatorio", + "ToastNewEpisodesFound": "{0} nuevo(s) episodio(s) encontrado(s)", "ToastNewUserCreatedFailed": "Error al crear la cuenta: \"{0}\"", "ToastNewUserCreatedSuccess": "Nueva cuenta creada", "ToastNewUserLibraryError": "Debes seleccionar al menos una biblioteca", "ToastNewUserPasswordError": "Debes tener una contraseña, solo el usuario root puede estar sin contraseña", "ToastNewUserTagError": "Debes seleccionar al menos una etiqueta", "ToastNewUserUsernameError": "Introduce un nombre de usuario", + "ToastNoNewEpisodesFound": "No se encontraron nuevos episodios", "ToastNoUpdatesNecessary": "No es necesario actualizar", "ToastNotificationCreateFailed": "Error al crear notificación", "ToastNotificationDeleteFailed": "Error al borrar la notificación", @@ -1010,6 +1025,7 @@ "ToastPodcastGetFeedFailed": "No se puede obtener el podcast", "ToastPodcastNoEpisodesInFeed": "No se han encontrado episodios en el feed del RSS", "ToastPodcastNoRssFeed": "El podcast no tiene feed RSS", + "ToastProgressIsNotBeingSynced": "El progreso no se sincroniza, reinicia la reproducción", "ToastProviderCreatedFailed": "Error al añadir el proveedor", "ToastProviderCreatedSuccess": "Nuevo proveedor añadido", "ToastProviderNameAndUrlRequired": "Nombre y Url obligatorios", @@ -1036,6 +1052,7 @@ "ToastSessionCloseFailed": "Error al cerrar la sesión", "ToastSessionDeleteFailed": "Error al eliminar sesión", "ToastSessionDeleteSuccess": "Sesión eliminada", + "ToastSleepTimerDone": "Temporizador de apagado automático activado... zZzzZz", "ToastSlugMustChange": "El slug contiene caracteres no válidos", "ToastSlugRequired": "Slug obligatorio", "ToastSocketConnected": "Socket conectado", From 2dd30c7a26d632bfaf0d3acb8c8fe1d59d97fcab Mon Sep 17 00:00:00 2001 From: Tamanegii Date: Mon, 11 Nov 2024 11:52:00 +0000 Subject: [PATCH 053/762] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 71.3% (764 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hant/ --- client/strings/zh-tw.json | 54 +++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/client/strings/zh-tw.json b/client/strings/zh-tw.json index f6c4b6c8..080d1bac 100644 --- a/client/strings/zh-tw.json +++ b/client/strings/zh-tw.json @@ -1,5 +1,5 @@ { - "ButtonAdd": "增加", + "ButtonAdd": "添加", "ButtonAddChapters": "新增章節", "ButtonAddDevice": "新增設備", "ButtonAddLibrary": "新增庫", @@ -17,7 +17,7 @@ "ButtonCheckAndDownloadNewEpisodes": "檢查並下載新劇集", "ButtonChooseAFolder": "選擇資料夾", "ButtonChooseFiles": "選擇檔案", - "ButtonClearFilter": "清除過濾器", + "ButtonClearFilter": "清楚過濾器", "ButtonCloseFeed": "關閉源", "ButtonCloseSession": "關閉開放會話", "ButtonCollections": "收藏", @@ -35,6 +35,8 @@ "ButtonHide": "隱藏", "ButtonHome": "首頁", "ButtonIssues": "問題", + "ButtonJumpBackward": "向後跳轉", + "ButtonJumpForward": "向前跳轉", "ButtonLatest": "最新", "ButtonLibrary": "媒體庫", "ButtonLogout": "登出", @@ -53,6 +55,7 @@ "ButtonPlay": "播放", "ButtonPlaying": "正在播放", "ButtonPlaylists": "播放列表", + "ButtonPrevious": "上一個", "ButtonPreviousChapter": "過去的章節", "ButtonPurgeAllCache": "清理所有快取", "ButtonPurgeItemsCache": "清理項目快取", @@ -76,7 +79,7 @@ "ButtonSaveTracklist": "保存音軌列表", "ButtonScan": "掃描", "ButtonScanLibrary": "掃描庫", - "ButtonSearch": "查找", + "ButtonSearch": "搜索", "ButtonSelectFolderPath": "選擇資料夾路徑", "ButtonSeries": "系列", "ButtonSetChaptersFromTracks": "將音軌設定為章節", @@ -97,7 +100,7 @@ "ErrorUploadFetchMetadataAPI": "獲取元數據時出錯", "ErrorUploadFetchMetadataNoResults": "無法獲取元數據 - 嘗試更新標題和/或作者", "ErrorUploadLacksTitle": "必須有標題", - "HeaderAccount": "帳號", + "HeaderAccount": "賬號", "HeaderAdvanced": "高級", "HeaderAppriseNotificationSettings": "測試通知設定", "HeaderAudioTracks": "音軌", @@ -111,6 +114,7 @@ "HeaderCollectionItems": "收藏項目", "HeaderCover": "封面", "HeaderCurrentDownloads": "當前下載", + "HeaderCustomMessageOnLogin": "登錄時的自定義信息", "HeaderCustomMetadataProviders": "自訂 Metadata 提供者", "HeaderDetails": "詳情", "HeaderDownloadQueue": "下載佇列", @@ -144,7 +148,7 @@ "HeaderNewLibrary": "新建媒體庫", "HeaderNotifications": "通知", "HeaderOpenIDConnectAuthentication": "OpenID 連接身份驗證", - "HeaderOpenRSSFeed": "打開 RSS 源", + "HeaderOpenRSSFeed": "打開 Rss 源", "HeaderOtherFiles": "其他檔案", "HeaderPasswordAuthentication": "密碼認證", "HeaderPermissions": "權限", @@ -168,7 +172,7 @@ "HeaderSettingsExperimental": "實驗功能", "HeaderSettingsGeneral": "通用", "HeaderSettingsScanner": "掃描", - "HeaderSleepTimer": "睡眠計時", + "HeaderSleepTimer": "睡眠定時", "HeaderStatsLargestItems": "最大的項目", "HeaderStatsLongestItems": "項目時長(小時)", "HeaderStatsMinutesListeningChart": "收聽分鐘數(最近7天)", @@ -182,8 +186,12 @@ "HeaderUpdateDetails": "更新詳情", "HeaderUpdateLibrary": "更新媒體庫", "HeaderUsers": "使用者", + "HeaderYearReview": "{0} 年回顧", "HeaderYourStats": "你的統計數據", "LabelAbridged": "概要", + "LabelAbridgedChecked": "刪節版(已勾選)", + "LabelAbridgedUnchecked": "未刪節版(未勾選)", + "LabelAccessibleBy": "可訪問", "LabelAccountType": "帳號類型", "LabelAccountTypeAdmin": "管理員", "LabelAccountTypeGuest": "來賓", @@ -260,26 +268,32 @@ "LabelDownload": "下載", "LabelDownloadNEpisodes": "下載 {0} 集", "LabelDuration": "持續時間", + "LabelDurationComparisonExactMatch": "(完全匹配)", + "LabelDurationComparisonLonger": "({0} 更長)", + "LabelDurationComparisonShorter": "({0} 更短)", "LabelDurationFound": "找到持續時間:", "LabelEbook": "電子書", "LabelEbooks": "電子書", "LabelEdit": "編輯", "LabelEmail": "郵箱", "LabelEmailSettingsFromAddress": "發件人位址", + "LabelEmailSettingsRejectUnauthorized": "拒絕未經授權的證書", + "LabelEmailSettingsRejectUnauthorizedHelp": "停用 SSL 證書驗證可能會使您的連接暴露於安全風險中,例如中間人攻擊。僅在您了解其含義並信任您所連接的郵件伺服器的情況下才停用此選項。", "LabelEmailSettingsSecure": "安全", "LabelEmailSettingsSecureHelp": "如果選是, 則連接將在連接到伺服器時使用TLS. 如果選否, 則若伺服器支援STARTTLS擴展, 則使用TLS. 在大多數情況下, 如果連接到465埠, 請將該值設定為是. 對於587或25埠, 請保持為否. (來自nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "測試位址", "LabelEmbeddedCover": "嵌入封面", "LabelEnable": "啟用", "LabelEnd": "結束", + "LabelEndOfChapter": "章節結束", "LabelEpisode": "劇集", "LabelEpisodeTitle": "劇集標題", "LabelEpisodeType": "劇集類型", "LabelExample": "示例", "LabelExplicit": "信息準確", - "LabelFeedURL": "源 URL", + "LabelFeedURL": "源鏈接", "LabelFetchingMetadata": "正在獲取元數據", - "LabelFile": "檔案", + "LabelFile": "文件", "LabelFileBirthtime": "檔案創建時間", "LabelFileModified": "檔案修改時間", "LabelFilename": "檔名", @@ -288,6 +302,7 @@ "LabelFinished": "已聽完", "LabelFolder": "資料夾", "LabelFolders": "資料夾", + "LabelFontBoldness": "字體粗細", "LabelFontFamily": "字體系列", "LabelFontItalic": "斜體", "LabelFontScale": "字體比例", @@ -353,7 +368,7 @@ "LabelMobileRedirectURIs": "允許移動應用重定向 URI", "LabelMobileRedirectURIsDescription": "這是移動應用程序的有效重定向 URI 白名單. 預設值為 audiobookshelf://oauth,您可以刪除它或加入其他 URI 以進行第三方應用集成. 使用星號 (*) 作為唯一條目允許任何 URI.", "LabelMore": "更多", - "LabelMoreInfo": "更多..", + "LabelMoreInfo": "更多信息", "LabelName": "名稱", "LabelNarrator": "講述者", "LabelNarrators": "講述者", @@ -399,7 +414,7 @@ "LabelPodcasts": "播客", "LabelPort": "埠", "LabelPrefixesToIgnore": "忽略的前綴 (不區分大小寫)", - "LabelPreventIndexing": "防止 iTunes 和 Google 播客目錄對你的源進行索引", + "LabelPreventIndexing": "防止您的訂閱源被 iTunes 和 Google 播客目錄索引", "LabelPrimaryEbook": "主電子書", "LabelProgress": "進度", "LabelProvider": "供應商", @@ -412,6 +427,7 @@ "LabelRSSFeedPreventIndexing": "防止索引", "LabelRSSFeedSlug": "RSS 源段", "LabelRSSFeedURL": "RSS 源 URL", + "LabelRandomly": "隨機", "LabelRead": "閱讀", "LabelReadAgain": "再次閱讀", "LabelReadEbookWithoutProgress": "閱讀電子書而不保存進度", @@ -635,20 +651,20 @@ "MessageNoFoldersAvailable": "沒有可用資料夾", "MessageNoGenres": "無流派", "MessageNoIssues": "無問題", - "MessageNoItems": "無項目", - "MessageNoItemsFound": "未找到任何項目", - "MessageNoListeningSessions": "無收聽會話", + "MessageNoItems": "沒有項目", + "MessageNoItemsFound": "沒有找到任何項目", + "MessageNoListeningSessions": "沒有收聽會話", "MessageNoLogs": "無日誌", "MessageNoMediaProgress": "無媒體進度", "MessageNoNotifications": "無通知", - "MessageNoPodcastsFound": "未找到播客", + "MessageNoPodcastsFound": "沒有找到播客", "MessageNoResults": "無結果", "MessageNoSearchResultsFor": "沒有搜尋到結果 \"{0}\"", "MessageNoSeries": "無系列", "MessageNoTags": "無標籤", "MessageNoTasksRunning": "沒有正在運行的任務", "MessageNoUpdatesWereNecessary": "無需更新", - "MessageNoUserPlaylists": "你沒有播放列表", + "MessageNoUserPlaylists": "您沒有播放列表", "MessageNotYetImplemented": "尚未實施", "MessageOr": "或", "MessagePauseChapter": "暫停章節播放", @@ -660,7 +676,7 @@ "MessageRemoveEpisodes": "移除 {0} 劇集", "MessageRemoveFromPlayerQueue": "從播放佇列中移除", "MessageRemoveUserWarning": "是否確實要永久刪除使用者 \"{0}\"?", - "MessageReportBugsAndContribute": "報告錯誤、請求功能和貢獻在", + "MessageReportBugsAndContribute": "報告錯誤、請求功能和做出貢獻", "MessageResetChaptersConfirm": "你確定要重置章節並撤消你所做的更改嗎?", "MessageRestoreBackupConfirm": "你確定要恢復創建的這個備份", "MessageRestoreBackupWarning": "恢復備份將覆蓋位於 /config 的整個資料庫並覆蓋 /metadata/items & /metadata/authors 中的圖像.

    備份不會修改媒體庫資料夾中的任何檔案. 如果您已啟用伺服器設定將封面和元數據存儲在庫資料夾中,則不會備份或覆蓋這些內容.

    將自動刷新使用伺服器的所有客戶端.", @@ -681,8 +697,8 @@ "NoteChangeRootPassword": "Root 是唯一可以擁有空密碼的使用者", "NoteChapterEditorTimes": "注意: 第一章開始時間必須保持在 0:00, 最後一章開始時間不能超過有聲書持續時間.", "NoteFolderPicker": "注意: 將不顯示已映射的資料夾", - "NoteRSSFeedPodcastAppsHttps": "警告: 大多數播客應用程序都需要 RSS 源 URL 使用 HTTPS", - "NoteRSSFeedPodcastAppsPubDate": "警告: 您的一集或多集沒有發布日期. 一些播客應用程序要求這樣做.", + "NoteRSSFeedPodcastAppsHttps": "警告:大多數播客應用程式要求 RSS 訂閱源 URL 使用 HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "警告:您的一個或多個劇集沒有發布日期。某些播客應用程式要求提供此資訊。", "NoteUploaderFoldersWithMediaFiles": "包含媒體檔案的資料夾將作為單獨的媒體庫項目處理.", "NoteUploaderOnlyAudioFiles": "如果只上傳音頻檔, 則每個音頻檔將作為單獨的有聲書處理.", "NoteUploaderUnsupportedFiles": "不支援的檔案將被忽略. 選擇或刪除資料夾時, 將忽略不在項目資料夾中的其他檔案.", @@ -705,7 +721,7 @@ "ToastBackupUploadSuccess": "備份已上傳", "ToastBatchUpdateFailed": "批量更新失敗", "ToastBatchUpdateSuccess": "批量更新成功", - "ToastBookmarkCreateFailed": "創建書籤失敗", + "ToastBookmarkCreateFailed": "創建書簽失敗", "ToastBookmarkCreateSuccess": "書籤已新增", "ToastBookmarkRemoveSuccess": "書籤已刪除", "ToastBookmarkUpdateSuccess": "書籤已更新", From 92d083164f0a15728578dd97152444cd1458e31e Mon Sep 17 00:00:00 2001 From: thehijacker Date: Mon, 11 Nov 2024 18:35:36 +0000 Subject: [PATCH 054/762] Translated using Weblate (Slovenian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index e3320ea8..0e04164d 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -136,7 +136,7 @@ "HeaderEmailSettings": "Nastavitve e-pošte", "HeaderEpisodes": "Epizode", "HeaderEreaderDevices": "E-bralniki", - "HeaderEreaderSettings": "Nastavitve ebralnika", + "HeaderEreaderSettings": "Nastavitve e-bralnika", "HeaderFiles": "Datoteke", "HeaderFindChapters": "Najdi poglavja", "HeaderIgnoredFiles": "Prezrte datoteke", @@ -366,7 +366,7 @@ "LabelHardDeleteFile": "Trdo brisanje datoteke", "LabelHasEbook": "Ima e-knjigo", "LabelHasSupplementaryEbook": "Ima dodatno e-knjigo", - "LabelHideSubtitles": "Skrij podnapise", + "LabelHideSubtitles": "Skrij podnaslove", "LabelHighestPriority": "Najvišja prioriteta", "LabelHost": "Gostitelj", "LabelHour": "Ura", @@ -568,8 +568,8 @@ "LabelSettingsLibraryMarkAsFinishedWhen": "Označi medijski element kot končan, ko", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči prejšnje knjige v nadaljevanju serije", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polica z domačo stranjo Nadaljuj serijo prikazuje prvo nezačeto knjigo v seriji, ki ima vsaj eno dokončano knjigo in ni nobene knjige v teku. Če omogočite to nastavitev, se bo serija nadaljevala od najbolj dokončane knjige namesto od prve nezačete knjige.", - "LabelSettingsParseSubtitles": "Uporabi podnapise", - "LabelSettingsParseSubtitlesHelp": "Izvleci podnapise iz imen map zvočnih knjig.
    Podnapis mora biti ločen z \" - \"
    npr. \"Naslov knjige – tu podnapis\" ima podnapis \"tu podnapis\"", + "LabelSettingsParseSubtitles": "Razčleni podnaslove", + "LabelSettingsParseSubtitlesHelp": "Izvleci padnaslove iz imen map zvočnih knjig.
    Podnaslov mora biti ločen z \" - \"
    npr. \"Naslov knjige – tu podnaslove\" ima podnaslov \"tu podnaslov\"", "LabelSettingsPreferMatchedMetadata": "Prednost imajo ujemajoči se metapodatki", "LabelSettingsPreferMatchedMetadataHelp": "Pri uporabi hitrega ujemanja bodo ujemajoči se podatki preglasili podrobnosti artikla. Hitro ujemanje bo privzeto izpolnil samo manjkajoče podrobnosti.", "LabelSettingsSkipMatchingBooksWithASIN": "Preskoči ujemajoče se knjige, ki že imajo ASIN", @@ -588,7 +588,7 @@ "LabelShareURL": "Deli URL", "LabelShowAll": "Prikaži vse", "LabelShowSeconds": "Prikaži sekunde", - "LabelShowSubtitles": "Prikaži podnapise", + "LabelShowSubtitles": "Prikaži podnaslove", "LabelSize": "Velikost", "LabelSleepTimer": "Časovnik za spanje", "LabelSlug": "Slug", @@ -611,7 +611,7 @@ "LabelStatsOverallDays": "Skupaj dnevi", "LabelStatsOverallHours": "Skupaj ur", "LabelStatsWeekListening": "Tednov poslušanja", - "LabelSubtitle": "Podnapis", + "LabelSubtitle": "Podnaslov", "LabelSupportedFileTypes": "Podprte vrste datotek", "LabelTag": "Oznaka", "LabelTags": "Oznake", From f941ea650032ecfc5a7dfcc7dbb06d329f5e2676 Mon Sep 17 00:00:00 2001 From: burghy86 Date: Tue, 12 Nov 2024 19:43:54 +0000 Subject: [PATCH 055/762] Translated using Weblate (Italian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/ --- client/strings/it.json | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/client/strings/it.json b/client/strings/it.json index 1450b972..42e83fe0 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Aggiornamento della notifica", "HeaderNotifications": "Notifiche", "HeaderOpenIDConnectAuthentication": "Autenticazione OpenID Connect", + "HeaderOpenListeningSessions": "Apri sessioni di ascolto", "HeaderOpenRSSFeed": "Apri il flusso RSS", "HeaderOtherFiles": "Altri File", "HeaderPasswordAuthentication": "Autenticazione della password", @@ -180,6 +181,7 @@ "HeaderRemoveEpisodes": "Rimuovi {0} Episodi", "HeaderSavedMediaProgress": "Progressi salvati", "HeaderSchedule": "Schedula", + "HeaderScheduleEpisodeDownloads": "Imposta il download automatico degli episodi", "HeaderScheduleLibraryScans": "Schedula la scansione della libreria", "HeaderSession": "Sessione", "HeaderSetBackupSchedule": "Imposta programmazione Backup", @@ -225,6 +227,7 @@ "LabelAllUsersExcludingGuests": "Tutti gli Utenti Esclusi gli ospiti", "LabelAllUsersIncludingGuests": "Tutti gli Utenti Inclusi gli ospiti", "LabelAlreadyInYourLibrary": "Già esistente nella libreria", + "LabelApiToken": "API Token", "LabelAppend": "Appese", "LabelAudioBitrate": "Audio Bitrate (es. 128k)", "LabelAudioChannels": "Canali Audio (1 o 2)", @@ -250,15 +253,18 @@ "LabelBackupsNumberToKeep": "Numero di backup da mantenere", "LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.", "LabelBitrate": "Velocità di trasmissione", + "LabelBonus": "Bonus", "LabelBooks": "Libri", "LabelButtonText": "Buttone Testo", "LabelByAuthor": "da {0}", "LabelChangePassword": "Cambia Password", "LabelChannels": "Canali", + "LabelChapterCount": "{0} Capitoli", "LabelChapterTitle": "Titoli dei Capitoli", "LabelChapters": "Capitoli", "LabelChaptersFound": "Capitoli Trovati", "LabelClickForMoreInfo": "Click per altre Info", + "LabelClickToUseCurrentValue": "Clicca per usare il valore corrente", "LabelClosePlayer": "Chiudi player", "LabelCodec": "Codec", "LabelCollapseSeries": "Comprimi Serie", @@ -320,9 +326,13 @@ "LabelEnd": "Fine", "LabelEndOfChapter": "Fine Capitolo", "LabelEpisode": "Episodio", + "LabelEpisodeNotLinkedToRssFeed": "Episode non linkati nel RSS feed", + "LabelEpisodeNumber": "Episodio #{0}", "LabelEpisodeTitle": "Titolo Episodio", "LabelEpisodeType": "Tipo Episodio", + "LabelEpisodeUrlFromRssFeed": "URL dell'episodio dal RSS feed", "LabelEpisodes": "Episodi", + "LabelEpisodic": "Episodico", "LabelExample": "Esempio", "LabelExpandSeries": "Espandi Serie", "LabelExpandSubSeries": "Espandi Sub Serie", @@ -350,6 +360,7 @@ "LabelFontScale": "Dimensione font", "LabelFontStrikethrough": "Barrato", "LabelFormat": "Formato", + "LabelFull": "Pieno", "LabelGenre": "Genere", "LabelGenres": "Generi", "LabelHardDeleteFile": "Elimina Definitivamente", @@ -405,6 +416,10 @@ "LabelLowestPriority": "Priorità Minima", "LabelMatchExistingUsersBy": "Abbina gli utenti esistenti per", "LabelMatchExistingUsersByDescription": "Utilizzato per connettere gli utenti esistenti. Una volta connessi, gli utenti verranno abbinati a un ID univoco dal tuo provider SSO", + "LabelMaxEpisodesToDownload": "Max # di episodi da scaricare. Usa 0 per illimitati.", + "LabelMaxEpisodesToDownloadPerCheck": "Massimo # di nuovi episodi da scaricare per il controllo", + "LabelMaxEpisodesToKeep": "Massimo # di episodi da tenere", + "LabelMaxEpisodesToKeepHelp": "Il valore 0 non imposta alcun limite massimo. Dopo che un nuovo episodio è stato scaricato automaticamente, questo eliminerà l'episodio più vecchio se hai più di X episodi. Questo eliminerà solo 1 episodio per ogni nuovo download.", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Tipo Media", "LabelMetaTag": "Meta Tag", @@ -450,12 +465,14 @@ "LabelOpenIDGroupClaimDescription": "Nome dell'attestazione OpenID che contiene un elenco dei gruppi dell'utente. Comunemente indicato come gruppo. se configurato, l'applicazione assegnerà automaticamente i ruoli in base alle appartenenze ai gruppi dell'utente, a condizione che tali gruppi siano denominati \"admin\", \"utente\" o \"ospite\" senza distinzione tra maiuscole e minuscole nell'attestazione. L'attestazione deve contenere un elenco e, se un utente appartiene a più gruppi, l'applicazione assegnerà il ruolo corrispondente al livello di accesso più alto. Se nessun gruppo corrisponde, l'accesso verrà negato.", "LabelOpenRSSFeed": "Apri RSS Feed", "LabelOverwrite": "Sovrascrivi", + "LabelPaginationPageXOfY": "Pagina {0} di {1}", "LabelPassword": "Password", "LabelPath": "Percorso", "LabelPermanent": "Permanente", "LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie", "LabelPermissionsAccessAllTags": "Può accedere a tutti i tag", "LabelPermissionsAccessExplicitContent": "Può accedere a contenuti espliciti", + "LabelPermissionsCreateEreader": "Può creare un e-reader", "LabelPermissionsDelete": "Può Cancellare", "LabelPermissionsDownload": "Può Scaricare", "LabelPermissionsUpdate": "Può Aggiornare", @@ -500,18 +517,24 @@ "LabelRedo": "Rifai", "LabelRegion": "Regione", "LabelReleaseDate": "Data Release", + "LabelRemoveAllMetadataAbs": "Remuovi tutti i metadata.abs files", + "LabelRemoveAllMetadataJson": "Rimuovi tutti i metadata.json files", "LabelRemoveCover": "Rimuovi cover", + "LabelRemoveMetadataFile": "Rimuovi i file metadata nella cartella della libreria", + "LabelRemoveMetadataFileHelp": "Rimuovi tutti i file metadata.json e i file metadata.abs nelle tue {0} cartelle.", "LabelRowsPerPage": "Righe per pagina", "LabelSearchTerm": "Ricerca", "LabelSearchTitle": "Cerca Titolo", "LabelSearchTitleOrASIN": "Cerca titolo o ASIN", "LabelSeason": "Stagione", + "LabelSeasonNumber": "Stagione #{0}", "LabelSelectAll": "Seleziona tutto", "LabelSelectAllEpisodes": "Seleziona tutti gli Episodi", "LabelSelectEpisodesShowing": "Selezionati {0} episodi da visualizzare", "LabelSelectUsers": "Selezione Utenti", "LabelSendEbookToDevice": "Invia il libro a...", "LabelSequence": "Sequenza", + "LabelSerial": "Seriale", "LabelSeries": "Serie", "LabelSeriesName": "Nome Serie", "LabelSeriesProgress": "Cominciato", @@ -540,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Le serie che hanno un solo libro saranno nascoste dalla pagina della serie e dagli scaffali della home page.", "LabelSettingsHomePageBookshelfView": "Home page con sfondo legno", "LabelSettingsLibraryBookshelfView": "Libreria con sfondo legno", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "La percentuale di completamento è maggiore di", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Il tempo rimanente è inferiore a (secondi)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Contrassegna l'elemento multimediale come terminato quando", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Salta i libri precedenti nella serie Continua", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Lo scaffale della home page Continua serie mostra il primo libro non iniziato della serie che ha almeno un libro finito e nessun libro in corso. Abilitando questa impostazione le serie continueranno dal libro completato più lontano invece che dal primo libro non iniziato.", "LabelSettingsParseSubtitles": "Analizza sottotitoli", @@ -604,6 +630,7 @@ "LabelTimeDurationXMinutes": "{0} minuti", "LabelTimeDurationXSeconds": "{0} secondi", "LabelTimeInMinutes": "Tempo in minuti", + "LabelTimeLeft": "{0} sinistra", "LabelTimeListened": "Tempo di Ascolto", "LabelTimeListenedToday": "Tempo di Ascolto Oggi", "LabelTimeRemaining": "{0} rimanente", @@ -624,6 +651,7 @@ "LabelTracksMultiTrack": "Multi-traccia", "LabelTracksNone": "Nessuna traccia", "LabelTracksSingleTrack": "Traccia-singola", + "LabelTrailer": "Trailer", "LabelType": "Tipo", "LabelUnabridged": "Integrale", "LabelUndo": "Annulla", @@ -640,6 +668,7 @@ "LabelUseAdvancedOptions": "Usa le opzioni avanzate", "LabelUseChapterTrack": "Usa il Capitolo della Traccia", "LabelUseFullTrack": "Usa la traccia totale", + "LabelUseZeroForUnlimited": "Usa 0 per illimitato", "LabelUser": "Utente", "LabelUsername": "Nome utente", "LabelValue": "Valore", @@ -698,6 +727,7 @@ "MessageConfirmPurgeCache": "L'eliminazione della cache eliminerà l'intera directory dei /metadata/cache.

    Sei sicuro di voler rimuovere la directory della cache?", "MessageConfirmPurgeItemsCache": "L'eliminazione della cache degli elementi eliminerà l'intera directory /metadata/cache/oggetti.
    Sei sicuro?", "MessageConfirmQuickEmbed": "Attenzione! L'incorporamento rapido non eseguirà il backup dei file audio. Assicurati di avere un backup dei tuoi file audio.

    Vuoi Continuare?", + "MessageConfirmQuickMatchEpisodes": "Gli episodi di corrispondenza rapida sovrascriveranno i dettagli se viene trovata una corrispondenza. Saranno aggiornati solo gli episodi non corrispondenti. Sei sicuro?", "MessageConfirmReScanLibraryItems": "Sei sicuro di voler ripetere la scansione? {0} oggetti?", "MessageConfirmRemoveAllChapters": "Sei sicuro di voler rimuovere tutti i capitoli?", "MessageConfirmRemoveAuthor": "Sei sicuro di voler rimuovere l'autore? \"{0}\"?", @@ -705,6 +735,7 @@ "MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?", "MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?", "MessageConfirmRemoveListeningSessions": "Sei sicuro di voler rimuovere {0} sessioni di Ascolto?", + "MessageConfirmRemoveMetadataFiles": "Vuoi davvero rimuovere tutti i metadati.{0} file nelle cartelle degli elementi della tua libreria?", "MessageConfirmRemoveNarrator": "Sei sicuro di voler rimuovere il narratore \"{0}\"?", "MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?", "MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?", @@ -785,6 +816,7 @@ "MessagePodcastSearchField": "Inserisci il termine di ricerca o l'URL del feed RSS", "MessageQuickEmbedInProgress": "Incorporamento rapido in corso", "MessageQuickEmbedQueue": "In coda per incorporamento rapido ({0} in coda)", + "MessageQuickMatchAllEpisodes": "Associamento veloce di Tutti gli episodi", "MessageQuickMatchDescription": "Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \"Preferisci metadati corrispondenti\".", "MessageRemoveChapter": "Rimuovi Capitolo", "MessageRemoveEpisodes": "rimuovi {0} episodio(i)", @@ -883,6 +915,7 @@ "StatsYearInReview": "ANNO IN RASSEGNA", "ToastAccountUpdateSuccess": "Account Aggiornato", "ToastAppriseUrlRequired": "È necessario immettere un indirizzo Apprise", + "ToastAsinRequired": "L'ASIN è obbligatorio", "ToastAuthorImageRemoveSuccess": "Immagine Autore Rimossa", "ToastAuthorNotFound": "Autore\"{0}\" non trovato", "ToastAuthorRemoveSuccess": "Autore rimosso", @@ -902,6 +935,8 @@ "ToastBackupUploadSuccess": "Backup caricato", "ToastBatchDeleteFailed": "Eliminazione batch non riuscita", "ToastBatchDeleteSuccess": "Eliminazione batch riuscita", + "ToastBatchQuickMatchFailed": "Batch Quick Match non riuscito!", + "ToastBatchQuickMatchStarted": "Avviata la ricerca rapida in batch di {0} libri!", "ToastBatchUpdateFailed": "Batch di aggiornamento fallito", "ToastBatchUpdateSuccess": "Batch di aggiornamento finito", "ToastBookmarkCreateFailed": "Creazione segnalibro fallita", @@ -913,6 +948,7 @@ "ToastChaptersHaveErrors": "I capitoli contengono errori", "ToastChaptersMustHaveTitles": "I capitoli devono avere titoli", "ToastChaptersRemoved": "Capitoli rimossi", + "ToastChaptersUpdated": "Capitoli aggiornati", "ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito", "ToastCollectionItemsAddSuccess": "L'aggiunta dell'elemento(i) alla raccolta è riuscito", "ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta", @@ -930,11 +966,14 @@ "ToastEncodeCancelSucces": "Codifica annullata", "ToastEpisodeDownloadQueueClearFailed": "Impossibile cancellare la coda", "ToastEpisodeDownloadQueueClearSuccess": "Coda di download degli episodi cancellata", + "ToastEpisodeUpdateSuccess": "{0} episodi aggiornati", "ToastErrorCannotShare": "Impossibile condividere in modo nativo su questo dispositivo", "ToastFailedToLoadData": "Impossibile caricare i dati", + "ToastFailedToMatch": "Impossibile abbinare", "ToastFailedToShare": "Impossibile condividere", "ToastFailedToUpdate": "Non aggiornato", "ToastInvalidImageUrl": "URL dell'immagine non valido", + "ToastInvalidMaxEpisodesToDownload": "Numero massimo di episodi non valido da scaricare", "ToastInvalidUrl": "URL non valido", "ToastItemCoverUpdateSuccess": "Cover aggiornata", "ToastItemDeletedFailed": "Impossibile eliminare l'elemento", @@ -953,14 +992,21 @@ "ToastLibraryScanStarted": "Scansione Libreria iniziata", "ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata", "ToastMatchAllAuthorsFailed": "Tutti gli autori non sono potuti essere classificati", + "ToastMetadataFilesRemovedError": "Errore durante la rimozione dei metadati. {0} file", + "ToastMetadataFilesRemovedNoneFound": "Nessun metadato. {0} file trovati nella libreria", + "ToastMetadataFilesRemovedNoneRemoved": "Nessun metadato. {0} file rimossi", + "ToastMetadataFilesRemovedSuccess": "{0} metadati.{1} file rimossi", + "ToastMustHaveAtLeastOnePath": "Deve avere almeno un percorso", "ToastNameEmailRequired": "Nome ed email sono obbligatori", "ToastNameRequired": "Il nome è obbligatorio", + "ToastNewEpisodesFound": "{0} nuovi episodi trovati", "ToastNewUserCreatedFailed": "Impossibile creare l'account: \"{0}\"", "ToastNewUserCreatedSuccess": "Nuovo account creato", "ToastNewUserLibraryError": "È necessario selezionare almeno una libreria", "ToastNewUserPasswordError": "Deve avere una password, solo l'utente root può avere una password vuota", "ToastNewUserTagError": "Devi selezionare almeno un tag", "ToastNewUserUsernameError": "Inserisci un nome utente", + "ToastNoNewEpisodesFound": "Nessun nuovo episodio trovato", "ToastNoUpdatesNecessary": "Nessun aggiornamento necessario", "ToastNotificationCreateFailed": "Impossibile creare la notifica", "ToastNotificationDeleteFailed": "Impossibile eliminare la notifica", @@ -979,6 +1025,7 @@ "ToastPodcastGetFeedFailed": "Impossibile ottenere il feed del podcast", "ToastPodcastNoEpisodesInFeed": "Nessun episodio trovato nel feed RSS", "ToastPodcastNoRssFeed": "Il podcast non ha un feed RSS", + "ToastProgressIsNotBeingSynced": "L'avanzamento non è sincronizzato, riavviare la riproduzione", "ToastProviderCreatedFailed": "Impossibile aggiungere il provider", "ToastProviderCreatedSuccess": "Aggiunto nuovo provider", "ToastProviderNameAndUrlRequired": "Nome e URL richiesti", @@ -1005,6 +1052,7 @@ "ToastSessionCloseFailed": "Disconnessione Fallita", "ToastSessionDeleteFailed": "Errore eliminazione sessione", "ToastSessionDeleteSuccess": "Sessione cancellata", + "ToastSleepTimerDone": "Timer di spegnimento eseguito... zZzzZz", "ToastSlugMustChange": "Lo slug contiene caratteri non validi", "ToastSlugRequired": "È richiesto lo slug", "ToastSocketConnected": "Socket connesso", From 997afc1b2f6bd0055f75e47c16db95d119c2e617 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Tue, 12 Nov 2024 06:13:00 +0000 Subject: [PATCH 056/762] Translated using Weblate (Slovenian) Currently translated at 100.0% (1071 of 1071 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 0e04164d..bbcf8055 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -407,7 +407,7 @@ "LabelLibraryItem": "Element knjižnice", "LabelLibraryName": "Ime knjižnice", "LabelLimit": "Omejitev", - "LabelLineSpacing": "Razmik med vrsticami", + "LabelLineSpacing": "Vrstični razmak", "LabelListenAgain": "Poslušaj znova", "LabelLogLevelDebug": "Odpravljanje napak", "LabelLogLevelInfo": "Info", From 45f8b06d569df68a6cf7b14ae4db72e1fff34ac9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 15 Nov 2024 08:30:54 -0600 Subject: [PATCH 057/762] Fix:CBC Radio podcast RSS feeds not accepting our user-agent string #3322 --- server/utils/podcastUtils.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 92679903..ac96c8d0 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -228,6 +228,13 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`) + let userAgent = 'audiobookshelf (+https://audiobookshelf.org; like iTMS)' + // Workaround for CBC RSS feeds rejecting our user agent string + // See: https://github.com/advplyr/audiobookshelf/issues/3322 + if (feedUrl.startsWith('https://www.cbc.ca')) { + userAgent = 'audiobookshelf (+https://audiobookshelf.org; like iTMS) - CBC' + } + return axios({ url: feedUrl, method: 'GET', @@ -235,7 +242,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { responseType: 'arraybuffer', headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8', - 'User-Agent': 'audiobookshelf (+https://audiobookshelf.org; like iTMS)' + 'User-Agent': userAgent }, httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl), httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl) From 5ccf5d7150bb94319987070ba10652b93cdbcab2 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 16 Nov 2024 06:26:32 +0200 Subject: [PATCH 058/762] Use a simpler database fetch in fullUpdateFromOld --- server/models/LibraryItem.js | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 17c3b125..e867a96a 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -237,35 +237,7 @@ class LibraryItem extends Model { * @returns {Promise} true if updates were made */ static async fullUpdateFromOld(oldLibraryItem) { - const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, { - include: [ - { - model: this.sequelize.models.book, - include: [ - { - model: this.sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['id', 'sequence'] - } - } - ] - }, - { - model: this.sequelize.models.podcast, - include: [ - { - model: this.sequelize.models.podcastEpisode - } - ] - } - ] - }) + const libraryItemExpanded = await this.getExpandedById(oldLibraryItem.id) if (!libraryItemExpanded) return false let hasUpdates = false From d5fbc1d45592414a5684a89bc40940a42020a020 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 17 Nov 2024 12:22:15 -0700 Subject: [PATCH 059/762] Add: statement about workflows passing --- .github/pull_request_template.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f41e46cc..0cd521a5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,13 +1,20 @@ ## Brief summary - + + +## Which issue is fixed? + + ## In-depth Description From 2b7e3f0efe6fae0d6138cb95ac72224f81b31bfc Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Nov 2024 15:45:21 -0600 Subject: [PATCH 060/762] Update uuid migration to v2.17.0 and for all tables still using UUIDv4 --- server/migrations/changelog.md | 12 +-- server/migrations/v2.16.3-uuid-replacement.js | 50 ---------- server/migrations/v2.17.0-uuid-replacement.js | 98 +++++++++++++++++++ server/models/Feed.js | 2 +- server/models/MediaItemShare.js | 2 +- server/models/MediaProgress.js | 2 +- server/models/PlaybackSession.js | 2 +- server/models/PlaylistMediaItem.js | 2 +- 8 files changed, 109 insertions(+), 61 deletions(-) delete mode 100644 server/migrations/v2.16.3-uuid-replacement.js create mode 100644 server/migrations/v2.17.0-uuid-replacement.js diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index bffd4682..8960ade2 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,9 +2,9 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------- | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | -| v2.16.3 | v2.16.3-uuid-replacement | Changes `mediaId` column in `libraryItem` table to match the primary key type of `books` and `podcasts` | +| Server Version | Migration Script Name | Description | +| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | diff --git a/server/migrations/v2.16.3-uuid-replacement.js b/server/migrations/v2.16.3-uuid-replacement.js deleted file mode 100644 index 66bf21ac..00000000 --- a/server/migrations/v2.16.3-uuid-replacement.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @typedef MigrationContext - * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. - * @property {import('../Logger')} logger - a Logger object. - * - * @typedef MigrationOptions - * @property {MigrationContext} context - an object containing the migration context. - */ - -/** - * This upward migration script changes the `mediaId` column in the `libraryItems` table to be a UUID and match other tables. - * - * @param {MigrationOptions} options - an object containing the migration context. - * @returns {Promise} - A promise that resolves when the migration is complete. - */ -async function up({ context: { queryInterface, logger } }) { - // Upwards migration script - logger.info('[2.16.3 migration] UPGRADE BEGIN: 2.16.3-uuid-replacement') - - // Change mediaId column to using the query interface - logger.info('[2.16.3 migration] Changing mediaId column to UUID') - await queryInterface.changeColumn('libraryItems', 'mediaId', { - type: 'UUID' - }) - - // Completed migration - logger.info('[2.16.3 migration] UPGRADE END: 2.16.3-uuid-replacement') -} - -/** - * This downward migration script changes the `mediaId` column in the `libraryItems` table to be a UUIDV4 again. - * - * @param {MigrationOptions} options - an object containing the migration context. - * @returns {Promise} - A promise that resolves when the migration is complete. - */ -async function down({ context: { queryInterface, logger } }) { - // Downward migration script - logger.info('[2.16.3 migration] DOWNGRADE BEGIN: 2.16.3-uuid-replacement') - - // Change mediaId column to using the query interface - logger.info('[2.16.3 migration] Changing mediaId column to UUIDV4') - await queryInterface.changeColumn('libraryItems', 'mediaId', { - type: 'UUIDV4' - }) - - // Completed migration - logger.info('[2.16.3 migration] DOWNGRADE END: 2.16.3-uuid-replacement') -} - -module.exports = { up, down } diff --git a/server/migrations/v2.17.0-uuid-replacement.js b/server/migrations/v2.17.0-uuid-replacement.js new file mode 100644 index 00000000..6460b795 --- /dev/null +++ b/server/migrations/v2.17.0-uuid-replacement.js @@ -0,0 +1,98 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script changes table columns with data type UUIDv4 to UUID to match associated models. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.17.0 migration] UPGRADE BEGIN: 2.17.0-uuid-replacement') + + logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUID') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing feeds.entityId column to UUID') + await queryInterface.changeColumn('feeds', 'entityId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID') + await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUID') + await queryInterface.changeColumn('playbackSessions', 'mediaItemId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUID') + await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUID') + await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', { + type: 'UUID' + }) + + // Completed migration + logger.info('[2.17.0 migration] UPGRADE END: 2.17.0-uuid-replacement') +} + +/** + * This downward migration script changes table columns data type back to UUIDv4. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.17.0 migration] DOWNGRADE BEGIN: 2.17.0-uuid-replacement') + + logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUIDV4') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing feeds.entityId column to UUIDV4') + await queryInterface.changeColumn('feeds', 'entityId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('playbackSessions', 'mediaItemId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', { + type: 'UUIDV4' + }) + + // Completed migration + logger.info('[2.17.0 migration] DOWNGRADE END: 2.17.0-uuid-replacement') +} + +module.exports = { up, down } diff --git a/server/models/Feed.js b/server/models/Feed.js index 72321da9..4f51e66d 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -274,7 +274,7 @@ class Feed extends Model { }, slug: DataTypes.STRING, entityType: DataTypes.STRING, - entityId: DataTypes.UUIDV4, + entityId: DataTypes.UUID, entityUpdatedAt: DataTypes.DATE, serverAddress: DataTypes.STRING, feedURL: DataTypes.STRING, diff --git a/server/models/MediaItemShare.js b/server/models/MediaItemShare.js index ffdc3ddd..38b8dbbf 100644 --- a/server/models/MediaItemShare.js +++ b/server/models/MediaItemShare.js @@ -109,7 +109,7 @@ class MediaItemShare extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + mediaItemId: DataTypes.UUID, mediaItemType: DataTypes.STRING, slug: DataTypes.STRING, pash: DataTypes.STRING, diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index d6a527f7..80204ef5 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -93,7 +93,7 @@ class MediaProgress extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + mediaItemId: DataTypes.UUID, mediaItemType: DataTypes.STRING, duration: DataTypes.FLOAT, currentTime: DataTypes.FLOAT, diff --git a/server/models/PlaybackSession.js b/server/models/PlaybackSession.js index c7c6323a..196fbda6 100644 --- a/server/models/PlaybackSession.js +++ b/server/models/PlaybackSession.js @@ -179,7 +179,7 @@ class PlaybackSession extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + mediaItemId: DataTypes.UUID, mediaItemType: DataTypes.STRING, displayTitle: DataTypes.STRING, displayAuthor: DataTypes.STRING, diff --git a/server/models/PlaylistMediaItem.js b/server/models/PlaylistMediaItem.js index 25e7b8c5..1c53bea1 100644 --- a/server/models/PlaylistMediaItem.js +++ b/server/models/PlaylistMediaItem.js @@ -45,7 +45,7 @@ class PlaylistMediaItem extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + mediaItemId: DataTypes.UUID, mediaItemType: DataTypes.STRING, order: DataTypes.INTEGER }, From 75eef8d722f0f84c0ebbc5a5d714baf3602baf56 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Nov 2024 16:00:44 -0600 Subject: [PATCH 061/762] Fix:Book library sort by publishedYear #3620 - Updated sort to cast publishedYear to INTEGER --- server/utils/queries/libraryItemsBookFilters.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index e8b424ed..b2784f5d 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -259,7 +259,7 @@ module.exports = { } else if (sortBy === 'media.duration') { return [['duration', dir]] } else if (sortBy === 'media.metadata.publishedYear') { - return [['publishedYear', dir]] + return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]] } else if (sortBy === 'media.metadata.authorNameLF') { return [[Sequelize.literal('author_name COLLATE NOCASE'), dir]] } else if (sortBy === 'media.metadata.authorName') { From 9940f1d6dbd12773b2a41059b81dc12228ea8457 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Fri, 15 Nov 2024 08:28:00 +0000 Subject: [PATCH 062/762] Translated using Weblate (Spanish) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/strings/es.json b/client/strings/es.json index d9e24723..06aa1b8b 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -71,8 +71,8 @@ "ButtonQuickMatch": "Encontrar Rápido", "ButtonReScan": "Re-Escanear", "ButtonRead": "Leer", - "ButtonReadLess": "Lea menos", - "ButtonReadMore": "Lea mas", + "ButtonReadLess": "Leer menos", + "ButtonReadMore": "Leer más", "ButtonRefresh": "Refrecar", "ButtonRemove": "Remover", "ButtonRemoveAll": "Remover Todos", @@ -220,7 +220,7 @@ "LabelAddToPlaylist": "Añadido a la lista de reproducción", "LabelAddToPlaylistBatch": "Se Añadieron {0} Artículos a la Lista de Reproducción", "LabelAddedAt": "Añadido", - "LabelAddedDate": "Añadido {0}", + "LabelAddedDate": "{0} Añadido", "LabelAdminUsersOnly": "Solamente usuarios administradores", "LabelAll": "Todos", "LabelAllUsers": "Todos los Usuarios", @@ -681,8 +681,8 @@ "LabelWeekdaysToRun": "Correr en Días de la Semana", "LabelXBooks": "{0} libros", "LabelXItems": "{0} elementos", - "LabelYearReviewHide": "Ocultar Year in Review", - "LabelYearReviewShow": "Ver Year in Review", + "LabelYearReviewHide": "Ocultar Resumen del año", + "LabelYearReviewShow": "Resumen del año", "LabelYourAudiobookDuration": "Duración de tu Audiolibro", "LabelYourBookmarks": "Tus Marcadores", "LabelYourPlaylists": "Tus Listas", @@ -779,7 +779,7 @@ "MessageNoBackups": "Sin Respaldos", "MessageNoBookmarks": "Sin marcadores", "MessageNoChapters": "Sin capítulos", - "MessageNoCollections": "Sin Colecciones", + "MessageNoCollections": "Sin colecciones", "MessageNoCoversFound": "Ninguna Portada Encontrada", "MessageNoDescription": "Sin Descripción", "MessageNoDevices": "Sin dispositivos", From 26ef33a4b6188bda0a480e290c95f8c98b903000 Mon Sep 17 00:00:00 2001 From: biuklija Date: Thu, 14 Nov 2024 12:02:48 +0000 Subject: [PATCH 063/762] Translated using Weblate (Croatian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index d7d0fde5..502973c4 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -813,7 +813,7 @@ "MessagePlaylistCreateFromCollection": "Stvori popis za izvođenje od zbirke", "MessagePleaseWait": "Molimo pričekajte...", "MessagePodcastHasNoRSSFeedForMatching": "Podcast nema adresu RSS izvora za prepoznavanje", - "MessagePodcastSearchField": "Unesite upit za pretragu ili URL RSS izvora", + "MessagePodcastSearchField": "Upišite izraz za pretraživanje ili URL RSS izvora", "MessageQuickEmbedInProgress": "Brzo ugrađivanje u tijeku", "MessageQuickEmbedQueue": "Dodano u red za brzo ugrađivanje ({0} u redu izvođenja)", "MessageQuickMatchAllEpisodes": "Brzo prepoznavanje svih nastavaka", From 3e6a2d670ece6433a8fe4c198d96d09b4d969c8c Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Thu, 14 Nov 2024 20:19:09 +0000 Subject: [PATCH 064/762] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1071 of 1071 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 668ccd33..e4aecc90 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -813,7 +813,7 @@ "MessagePlaylistCreateFromCollection": "Створити список відтворення з добірки", "MessagePleaseWait": "Будь ласка, зачекайте...", "MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку", - "MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS фіду", + "MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS-стрічки", "MessageQuickEmbedInProgress": "Швидке вбудовування в процесі", "MessageQuickEmbedQueue": "В черзі на швидке вбудовування ({0} в черзі)", "MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів", From cf19dd23cf2d6dfcb5f7e1d8db6528694e3cfa45 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Fri, 15 Nov 2024 11:26:33 +0000 Subject: [PATCH 065/762] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 2830a710..072cbd39 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -71,7 +71,7 @@ "ButtonQuickMatch": "快速匹配", "ButtonReScan": "重新扫描", "ButtonRead": "读取", - "ButtonReadLess": "阅读更少", + "ButtonReadLess": "阅读较少", "ButtonReadMore": "阅读更多", "ButtonRefresh": "刷新", "ButtonRemove": "移除", @@ -220,7 +220,7 @@ "LabelAddToPlaylist": "添加到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", "LabelAddedAt": "添加于", - "LabelAddedDate": "添加 {0}", + "LabelAddedDate": "已添加 {0}", "LabelAdminUsersOnly": "仅限管理员用户", "LabelAll": "全部", "LabelAllUsers": "所有用户", From b5f0a6f4a6f9c43fdbe06bc6a3aeb68b2c07aec2 Mon Sep 17 00:00:00 2001 From: DR Date: Sat, 16 Nov 2024 21:01:30 +0000 Subject: [PATCH 066/762] Translated using Weblate (Hebrew) Currently translated at 70.5% (756 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/he/ --- client/strings/he.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/he.json b/client/strings/he.json index 9f7822b9..23b9fb72 100644 --- a/client/strings/he.json +++ b/client/strings/he.json @@ -8,10 +8,10 @@ "ButtonAddYourFirstLibrary": "הוסף את הספרייה הראשונה שלך", "ButtonApply": "החל", "ButtonApplyChapters": "החל פרקים", - "ButtonAuthors": "יוצרים", + "ButtonAuthors": "סופרים", "ButtonBack": "חזור", "ButtonBrowseForFolder": "עיין בתיקייה", - "ButtonCancel": "בטל", + "ButtonCancel": "ביטול", "ButtonCancelEncode": "בטל קידוד", "ButtonChangeRootPassword": "שנה סיסמת root", "ButtonCheckAndDownloadNewEpisodes": "בדוק והורד פרקים חדשים", From d25a21cd3241b5be585a70f7e973fb5dd44f90c3 Mon Sep 17 00:00:00 2001 From: Paulo Henrique Dos Santos Garcia Date: Sat, 16 Nov 2024 09:59:01 +0000 Subject: [PATCH 067/762] Translated using Weblate (Portuguese (Brazil)) Currently translated at 72.6% (778 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pt_BR/ --- client/strings/pt-br.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/strings/pt-br.json b/client/strings/pt-br.json index 68fad736..7df7c47d 100644 --- a/client/strings/pt-br.json +++ b/client/strings/pt-br.json @@ -258,12 +258,15 @@ "LabelDiscFromFilename": "Disco a partir do nome do arquivo", "LabelDiscFromMetadata": "Disco a partir dos metadados", "LabelDiscover": "Descobrir", + "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download de {0} Episódios", "LabelDuration": "Duração", "LabelDurationComparisonExactMatch": "(exato)", "LabelDurationComparisonLonger": "({0} maior)", "LabelDurationComparisonShorter": "({0} menor)", "LabelDurationFound": "Duração comprovada:", + "LabelEbook": "Ebook", + "LabelEbooks": "Ebooks", "LabelEdit": "Editar", "LabelEmailSettingsFromAddress": "Remetente", "LabelEmailSettingsRejectUnauthorized": "Rejeitar certificados não autorizados", From 4cfd18c81ac4a4b0859d112a9875f47ee4a49bf4 Mon Sep 17 00:00:00 2001 From: Mohamad Dahhan Date: Sat, 16 Nov 2024 00:46:34 +0000 Subject: [PATCH 068/762] Translated using Weblate (Arabic) Currently translated at 3.8% (41 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 44 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/client/strings/ar.json b/client/strings/ar.json index 0967ef42..673b0238 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -1 +1,43 @@ -{} +{ + "ButtonAdd": "إضافة", + "ButtonAddChapters": "إضافة الفصول", + "ButtonAddDevice": "إضافة جهاز", + "ButtonAddLibrary": "إضافة مكتبة", + "ButtonAddPodcasts": "إضافة بودكاست", + "ButtonAddUser": "إضافة مستخدم", + "ButtonAddYourFirstLibrary": "أضف مكتبتك الأولى", + "ButtonApply": "حفظ", + "ButtonApplyChapters": "حفظ الفصول", + "ButtonAuthors": "المؤلفون", + "ButtonBack": "الرجوع", + "ButtonBrowseForFolder": "البحث عن المجلد", + "ButtonCancel": "إلغاء", + "ButtonCancelEncode": "إلغاء الترميز", + "ButtonChangeRootPassword": "تغيير كلمة المرور الرئيسية", + "ButtonCheckAndDownloadNewEpisodes": "التحقق من الحلقات الجديدة وتنزيلها", + "ButtonChooseAFolder": "اختر المجلد", + "ButtonChooseFiles": "اختر الملفات", + "ButtonClearFilter": "تصفية الفرز", + "ButtonCloseFeed": "إغلاق", + "ButtonCloseSession": "إغلاق الجلسة المفتوحة", + "ButtonCollections": "المجموعات", + "ButtonConfigureScanner": "إعدادات الماسح الضوئي", + "ButtonCreate": "إنشاء", + "ButtonCreateBackup": "إنشاء نسخة احتياطية", + "ButtonDelete": "حذف", + "ButtonDownloadQueue": "قائمة", + "ButtonEdit": "تعديل", + "ButtonEditChapters": "تعديل الفصول", + "ButtonEditPodcast": "تعديل البودكاست", + "ButtonEnable": "تفعيل", + "ButtonForceReScan": "فرض إعادة المسح", + "ButtonFullPath": "المسار الكامل", + "ButtonHide": "إخفاء", + "ButtonHome": "الرئيسية", + "ButtonIssues": "مشاكل", + "ButtonJumpBackward": "اقفز للخلف", + "ButtonJumpForward": "اقفز للأمام", + "ButtonLatest": "أحدث", + "ButtonLibrary": "المكتبة", + "ButtonLogout": "تسجيل الخروج" +} From 6786df6965df49c498603e34137b8c7a5dfb1321 Mon Sep 17 00:00:00 2001 From: Julio Cesar de jesus Date: Sat, 16 Nov 2024 23:05:38 +0000 Subject: [PATCH 069/762] Translated using Weblate (Spanish) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/es.json b/client/strings/es.json index 06aa1b8b..b45d2534 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -1,6 +1,6 @@ { - "ButtonAdd": "Agregar", - "ButtonAddChapters": "Agregar Capitulo", + "ButtonAdd": "Agregaro", + "ButtonAddChapters": "Agregar", "ButtonAddDevice": "Agregar Dispositivo", "ButtonAddLibrary": "Crear Biblioteca", "ButtonAddPodcasts": "Agregar Podcasts", From 10a7cd0987e0068f63094732639aa9f004f743a0 Mon Sep 17 00:00:00 2001 From: Julio Cesar de jesus Date: Sat, 16 Nov 2024 23:02:17 +0000 Subject: [PATCH 070/762] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1071 of 1071 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 e4aecc90..81cd13f4 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -928,7 +928,7 @@ "ToastBackupCreateSuccess": "Резервну копію створено", "ToastBackupDeleteFailed": "Не вдалося видалити резервну копію", "ToastBackupDeleteSuccess": "Резервну копію видалено", - "ToastBackupInvalidMaxKeep": "Невірна кількість резервних копій для зберігання", + "ToastBackupInvalidMaxKeep": "Профіль оновленоПрофіль оновлено", "ToastBackupInvalidMaxSize": "Невірний максимальний розмір резервної копії", "ToastBackupRestoreFailed": "Не вдалося відновити резервну копію", "ToastBackupUploadFailed": "Не вдалося завантажити резервну копію", From fe25d1dccda12521a637c91dc3865b4ecd0a0a6b Mon Sep 17 00:00:00 2001 From: Mohamad Dahhan Date: Sat, 16 Nov 2024 23:53:31 +0000 Subject: [PATCH 071/762] Translated using Weblate (Arabic) Currently translated at 11.9% (128 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 90 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/client/strings/ar.json b/client/strings/ar.json index 673b0238..c4891f19 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -30,6 +30,8 @@ "ButtonEditChapters": "تعديل الفصول", "ButtonEditPodcast": "تعديل البودكاست", "ButtonEnable": "تفعيل", + "ButtonFireAndFail": "النار والفشل", + "ButtonFireOnTest": "حادثة إطلاق النار", "ButtonForceReScan": "فرض إعادة المسح", "ButtonFullPath": "المسار الكامل", "ButtonHide": "إخفاء", @@ -39,5 +41,91 @@ "ButtonJumpForward": "اقفز للأمام", "ButtonLatest": "أحدث", "ButtonLibrary": "المكتبة", - "ButtonLogout": "تسجيل الخروج" + "ButtonLogout": "تسجيل الخروج", + "ButtonLookup": "البحث", + "ButtonManageTracks": "إدارة المقاطع", + "ButtonMapChapterTitles": "مطابقة عناوين الفصول", + "ButtonMatchAllAuthors": "مطابقة كل المؤلفون", + "ButtonMatchBooks": "مطابقة الكتب", + "ButtonNevermind": "لا تهتم", + "ButtonNext": "التالي", + "ButtonNextChapter": "الفصل التالي", + "ButtonNextItemInQueue": "العنصر التالي في قائمة الانتظار", + "ButtonOk": "نعم", + "ButtonOpenFeed": "فتح التغذية", + "ButtonOpenManager": "فتح الإدارة", + "ButtonPause": "تَوَقَّف", + "ButtonPlay": "تشغيل", + "ButtonPlayAll": "تشغيل الكل", + "ButtonPlaying": "مشغل الآن", + "ButtonPlaylists": "قوائم التشغيل", + "ButtonPrevious": "سابِق", + "ButtonPreviousChapter": "الفصل السابق", + "ButtonProbeAudioFile": "فحص ملف الصوت", + "ButtonPurgeAllCache": "مسح كافة ذاكرة التخزين المؤقتة", + "ButtonPurgeItemsCache": "مسح ذاكرة التخزين المؤقتة للعناصر", + "ButtonQueueAddItem": "أضف إلى قائمة الانتظار", + "ButtonQueueRemoveItem": "إزالة من قائمة الانتظار", + "ButtonQuickEmbed": "التضمين السريع", + "ButtonQuickEmbedMetadata": "إدراج سريع للبيانات الوصفية", + "ButtonQuickMatch": "مطابقة سريعة", + "ButtonReScan": "إعادة البحث", + "ButtonRead": "اقرأ", + "ButtonReadLess": "قلص", + "ButtonReadMore": "المزيد", + "ButtonRefresh": "تحديث", + "ButtonRemove": "إزالة", + "ButtonRemoveAll": "إزالة الكل", + "ButtonRemoveAllLibraryItems": "إزالة كافة عناصر المكتبة", + "ButtonRemoveFromContinueListening": "إزالة من متابعة الاستماع", + "ButtonRemoveFromContinueReading": "إزالة من متابعة القراءة", + "ButtonRemoveSeriesFromContinueSeries": "إزالة السلسلة من استمرار السلسلة", + "ButtonReset": "إعادة ضبط", + "ButtonResetToDefault": "إعادة ضبط إلى الوضع الافتراضي", + "ButtonRestore": "إستِعادة", + "ButtonSave": "حفظ", + "ButtonSaveAndClose": "حفظ و إغلاق", + "ButtonSaveTracklist": "حفظ قائمة التشغيل", + "ButtonScan": "تَحَقُق", + "ButtonScanLibrary": "تَحَقُق من المكتبة", + "ButtonSearch": "بحث", + "ButtonSelectFolderPath": "حدد مسار المجلد", + "ButtonSeries": "سلسلة", + "ButtonSetChaptersFromTracks": "تعيين الفصول من الملفات", + "ButtonShare": "نشر", + "ButtonShiftTimes": "أوقات العمل", + "ButtonShow": "عرض", + "ButtonStartM4BEncode": "ابدأ ترميز M4B", + "ButtonStartMetadataEmbed": "ابدأ تضمين البيانات الوصفية", + "ButtonStats": "الإحصائيات", + "ButtonSubmit": "تقديم", + "ButtonTest": "اختبار", + "ButtonUnlinkOpenId": "إلغاء ربط المعرف", + "ButtonUpload": "رفع", + "ButtonUploadBackup": "تحميل النسخة الاحتياطية", + "ButtonUploadCover": "ارفق الغلاف", + "ButtonUploadOPMLFile": "رفع ملف OPML", + "ButtonUserDelete": "حذف المستخدم {0}", + "ButtonUserEdit": "تعديل المستخدم {0}", + "ButtonViewAll": "عرض الكل", + "ButtonYes": "نعم", + "ErrorUploadFetchMetadataAPI": "خطأ في جلب البيانات الوصفية", + "ErrorUploadFetchMetadataNoResults": "لم يتم العثور على البيانات الوصفية - حاول تحديث العنوان و/أو المؤلف", + "ErrorUploadLacksTitle": "يجب أن يكون له عنوان", + "HeaderAccount": "الحساب", + "HeaderAddCustomMetadataProvider": "إضافة موفر بيانات تعريفية مخصص", + "HeaderAdvanced": "متقدم", + "HeaderAppriseNotificationSettings": "إعدادات الإشعارات", + "HeaderAudioTracks": "المسارات الصوتية", + "HeaderAudiobookTools": "أدوات إدارة ملفات الكتب الصوتية", + "HeaderAuthentication": "المصادقة", + "HeaderBackups": "النسخ الاحتياطية", + "HeaderChangePassword": "تغيير كلمة المرور", + "HeaderChapters": "الفصول", + "HeaderChooseAFolder": "اختيار المجلد", + "HeaderCollection": "مجموعة", + "HeaderCollectionItems": "عناصر المجموعة", + "HeaderCover": "الغلاف", + "HeaderCurrentDownloads": "التنزيلات الجارية", + "HeaderCustomMessageOnLogin": "رسالة مخصصة عند تسجيل الدخول" } From 2b0ba7d1e28f8cc0bcce153cbcde1a06927f93d9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Nov 2024 16:25:40 -0600 Subject: [PATCH 072/762] Version bump v2.17.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 f31266cb..49fd4fa3 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.16.2", + "version": "2.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.16.2", + "version": "2.17.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 2feb833b..f579b868 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.16.2", + "version": "2.17.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 6e3276ce..17d8403e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.16.2", + "version": "2.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.16.2", + "version": "2.17.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index d31f2022..d7aa3261 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.16.2", + "version": "2.17.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 778256ca165fb1248cdb5463146ac4e0561f2c82 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 18 Nov 2024 07:42:24 -0600 Subject: [PATCH 073/762] Fix:Server crash on new libraries when getting filter data #3623 --- server/utils/queries/libraryFilters.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 64ad07ee..be164eb2 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -510,7 +510,7 @@ module.exports = { // If nothing has changed, check if the number of podcasts in // library is still the same as prior check before updating cache creation time - if (podcastCountFromDatabase === Database.libraryFilterData[libraryId].podcastCount) { + if (podcastCountFromDatabase === Database.libraryFilterData[libraryId]?.podcastCount) { Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) Database.libraryFilterData[libraryId].loadedAt = Date.now() return cachedFilterData @@ -613,7 +613,7 @@ module.exports = { if (changedBooks + changedSeries + changedAuthors === 0) { // If nothing has changed, check if the number of authors, series, and books // matches the prior check before updating cache creation time - if (bookCountFromDatabase === Database.libraryFilterData[libraryId].bookCount && seriesCountFromDatabase === Database.libraryFilterData[libraryId].seriesCount && authorCountFromDatabase === Database.libraryFilterData[libraryId].authorCount) { + if (bookCountFromDatabase === Database.libraryFilterData[libraryId]?.bookCount && seriesCountFromDatabase === Database.libraryFilterData[libraryId]?.seriesCount && authorCountFromDatabase === Database.libraryFilterData[libraryId].authorCount) { Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) Database.libraryFilterData[libraryId].loadedAt = Date.now() return cachedFilterData From a5e38d14737ff8d43ed5b12f5f782978961b532c Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 18 Nov 2024 07:59:02 -0600 Subject: [PATCH 074/762] Fix:Error adding new series if a series has a null title #3622 --- client/components/widgets/SeriesInputWidget.vue | 2 -- server/objects/metadata/BookMetadata.js | 7 ++++++- server/utils/queries/libraryFilters.js | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/components/widgets/SeriesInputWidget.vue b/client/components/widgets/SeriesInputWidget.vue index e770eed3..1d8b64fe 100644 --- a/client/components/widgets/SeriesInputWidget.vue +++ b/client/components/widgets/SeriesInputWidget.vue @@ -71,8 +71,6 @@ export default { this.showSeriesForm = true }, submitSeriesForm() { - console.log('submit series form', this.value, this.selectedSeries) - if (!this.selectedSeries.name) { this.$toast.error('Must enter a series') return diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index 6d3dae43..c6192f11 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -29,7 +29,12 @@ class BookMetadata { this.subtitle = metadata.subtitle this.authors = metadata.authors?.map ? metadata.authors.map((a) => ({ ...a })) : [] this.narrators = metadata.narrators ? [...metadata.narrators].filter((n) => n) : [] - this.series = metadata.series?.map ? metadata.series.map((s) => ({ ...s })) : [] + this.series = metadata.series?.map + ? metadata.series.map((s) => ({ + ...s, + name: s.name || 'No Title' + })) + : [] this.genres = metadata.genres ? [...metadata.genres] : [] this.publishedYear = metadata.publishedYear || null this.publishedDate = metadata.publishedDate || null diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index be164eb2..bdddde75 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -662,7 +662,7 @@ module.exports = { }, attributes: ['id', 'name'] }) - series.forEach((s) => data.series.push({ id: s.id, name: s.name })) + series.forEach((s) => data.series.push({ id: s.id, name: s.name || 'No Title' })) const authors = await Database.authorModel.findAll({ where: { From 4adb15c11b209c045a88a43416d8dbd7b60a474f Mon Sep 17 00:00:00 2001 From: Clara Papke Date: Mon, 18 Nov 2024 09:33:40 +0000 Subject: [PATCH 075/762] Translated using Weblate (German) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index a427c288..6dff9338 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -71,8 +71,8 @@ "ButtonQuickMatch": "Schnellabgleich", "ButtonReScan": "Neu scannen", "ButtonRead": "Lesen", - "ButtonReadLess": "Weniger anzeigen", - "ButtonReadMore": "Mehr anzeigen", + "ButtonReadLess": "weniger Anzeigen", + "ButtonReadMore": "Mehr Anzeigen", "ButtonRefresh": "Neu Laden", "ButtonRemove": "Entfernen", "ButtonRemoveAll": "Alles entfernen", @@ -220,7 +220,7 @@ "LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen", "LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu", "LabelAddedAt": "Hinzugefügt am", - "LabelAddedDate": "Hinzugefügt {0}", + "LabelAddedDate": "{0} Hinzugefügt", "LabelAdminUsersOnly": "Nur Admin Benutzer", "LabelAll": "Alle", "LabelAllUsers": "Alle Benutzer", @@ -534,6 +534,7 @@ "LabelSelectUsers": "Benutzer auswählen", "LabelSendEbookToDevice": "E-Buch senden an …", "LabelSequence": "Reihenfolge", + "LabelSerial": "fortlaufend", "LabelSeries": "Serien", "LabelSeriesName": "Serienname", "LabelSeriesProgress": "Serienfortschritt", @@ -680,8 +681,8 @@ "LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelXBooks": "{0} Bücher", "LabelXItems": "{0} Medien", - "LabelYearReviewHide": "Verstecke Jahr in Übersicht", - "LabelYearReviewShow": "Zeige Jahr in Übersicht", + "LabelYearReviewHide": "Jahresrückblick verbergen", + "LabelYearReviewShow": "Jahresrückblick anzeigen", "LabelYourAudiobookDuration": "Laufzeit deines Mediums", "LabelYourBookmarks": "Lesezeichen", "LabelYourPlaylists": "Eigene Wiedergabelisten", From dd3467efa2071675e110296feaa1f773ae8977e4 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Mon, 18 Nov 2024 09:13:14 +0000 Subject: [PATCH 076/762] Translated using Weblate (Slovenian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index bbcf8055..366c8479 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -495,7 +495,7 @@ "LabelProviderAuthorizationValue": "Vrednost glave avtorizacije", "LabelPubDate": "Datum objave", "LabelPublishYear": "Leto izdaje", - "LabelPublishedDate": "Izdano {0}", + "LabelPublishedDate": "Objavljeno {0}", "LabelPublishedDecade": "Desetletje izdaje", "LabelPublishedDecades": "Desetletja izdaje", "LabelPublisher": "Izdajatelj", @@ -682,7 +682,7 @@ "LabelXBooks": "{0} knjig", "LabelXItems": "{0} elementov", "LabelYearReviewHide": "Skrij pregled leta", - "LabelYearReviewShow": "Poglej pregled leta", + "LabelYearReviewShow": "Poglej si pregled leta", "LabelYourAudiobookDuration": "Trajanje tvojih zvočnih knjig", "LabelYourBookmarks": "Tvoji zaznamki", "LabelYourPlaylists": "Tvoje seznami predvajanj", From 22f85d3af9815f4946eeeb2218d532cf5f543da8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 18 Nov 2024 08:02:46 -0600 Subject: [PATCH 077/762] Version bump v2.17.1 --- 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 49fd4fa3..c7d01fb4 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.0", + "version": "2.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.0", + "version": "2.17.1", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index f579b868..361130ea 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.0", + "version": "2.17.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 17d8403e..96d85ece 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.0", + "version": "2.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.0", + "version": "2.17.1", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index d7aa3261..ec153889 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.0", + "version": "2.17.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From ee6e2d2983f1a84f5b7fae4922b72757e8a751d4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 19 Nov 2024 16:48:05 -0600 Subject: [PATCH 078/762] Update:Persist podcast episode table sort and filter options in local storage #1321 --- client/components/tables/podcast/LazyEpisodesTable.vue | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/components/tables/podcast/LazyEpisodesTable.vue b/client/components/tables/podcast/LazyEpisodesTable.vue index 963cd7c9..0dae11b3 100644 --- a/client/components/tables/podcast/LazyEpisodesTable.vue +++ b/client/components/tables/podcast/LazyEpisodesTable.vue @@ -25,7 +25,6 @@
    -
    @@ -515,6 +514,10 @@ export default { } }, filterSortChanged() { + // Save filterKey and sortKey to local storage + localStorage.setItem('podcastEpisodesFilter', this.filterKey) + localStorage.setItem('podcastEpisodesSortBy', this.sortKey + (this.sortDesc ? '-desc' : '')) + this.init() }, refresh() { @@ -537,6 +540,11 @@ export default { } }, mounted() { + this.filterKey = localStorage.getItem('podcastEpisodesFilter') || 'incomplete' + const sortBy = localStorage.getItem('podcastEpisodesSortBy') || 'publishedAt-desc' + this.sortKey = sortBy.split('-')[0] + this.sortDesc = sortBy.split('-')[1] === 'desc' + this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) this.initListeners() this.init() From ff026a06bbfbd974032a58bfd32c67c53f0aebff Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 20 Nov 2024 16:48:09 -0600 Subject: [PATCH 079/762] Fix v2.17.0 migration to ensure mediaItemShares table exists --- server/migrations/v2.17.0-uuid-replacement.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/migrations/v2.17.0-uuid-replacement.js b/server/migrations/v2.17.0-uuid-replacement.js index 6460b795..4316cd76 100644 --- a/server/migrations/v2.17.0-uuid-replacement.js +++ b/server/migrations/v2.17.0-uuid-replacement.js @@ -27,10 +27,14 @@ async function up({ context: { queryInterface, logger } }) { type: 'UUID' }) - logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID') - await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { - type: 'UUID' - }) + if (await queryInterface.tableExists('mediaItemShares')) { + logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID') + await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { + type: 'UUID' + }) + } else { + logger.info('[2.17.0 migration] mediaItemShares table does not exist, skipping column change') + } logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUID') await queryInterface.changeColumn('playbackSessions', 'mediaItemId', { From fc5f35b3887044e057367331dbc384d12522db70 Mon Sep 17 00:00:00 2001 From: Harrison Rose Date: Thu, 21 Nov 2024 02:06:53 +0000 Subject: [PATCH 080/762] on iOS, do not restrict file types for upload --- client/pages/upload/index.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 0efa1456..8bc57de5 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -84,7 +84,7 @@
    - +
    @@ -127,6 +127,10 @@ export default { }) return extensions }, + isIOS() { + const ua = window.navigator.userAgent + return /iPad|iPhone|iPod/.test(ua) && !window.MSStream + }, streamLibraryItem() { return this.$store.state.streamLibraryItem }, From 268fb2ce9a29ff5acce81d030537141fca2a7bc1 Mon Sep 17 00:00:00 2001 From: Harrison Rose Date: Thu, 21 Nov 2024 04:43:03 +0000 Subject: [PATCH 081/762] on iOS, hide UI on upload page related to folder selection (since iOS Webkit does not support folder selection) --- client/pages/upload/index.vue | 8 ++++---- client/strings/en-us.json | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 8bc57de5..7c1b4767 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -34,12 +34,12 @@
    -

    {{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop }}

    +

    {{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop + (isIOS ? '' : ' ' + $strings.LabelUploaderDragAndDropOrFolders) }}

    {{ $strings.MessageOr }}

    {{ $strings.ButtonChooseFiles }} - {{ $strings.ButtonChooseAFolder }} + {{ $strings.ButtonChooseAFolder }}
    @@ -48,7 +48,7 @@

    - {{ $strings.NoteUploaderFoldersWithMediaFiles }} {{ $strings.NoteUploaderOnlyAudioFiles }} + {{ $strings.NoteUploaderFoldersWithMediaFiles }} {{ $strings.NoteUploaderOnlyAudioFiles }}

    @@ -85,7 +85,7 @@
- +
diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 8eb37550..e6392c0f 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -662,7 +662,8 @@ "LabelUpdateDetails": "Update Details", "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located", "LabelUpdatedAt": "Updated At", - "LabelUploaderDragAndDrop": "Drag & drop files or folders", + "LabelUploaderDragAndDrop": "Drag & drop files", + "LabelUploaderDragAndDropOrFolders": "or folders", "LabelUploaderDropFiles": "Drop files", "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", "LabelUseAdvancedOptions": "Use Advanced Options", From 784b761629af9212d34cdf36d01005c221b125f6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 21 Nov 2024 14:19:40 -0600 Subject: [PATCH 082/762] Fix:Unable to edit series sequence #3636 --- server/models/LibraryItem.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 5b96ad52..10395c49 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -479,7 +479,7 @@ class LibraryItem extends Model { { model: this.sequelize.models.series, through: { - attributes: ['sequence'] + attributes: ['id', 'sequence'] } } ], From 1d4e6993fc09a954b150eeaed69156559cc892c8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 21 Nov 2024 14:56:43 -0600 Subject: [PATCH 083/762] Upload page UI updates for mobile --- client/mixins/uploadHelpers.js | 32 ++++++++++++++++---------------- client/pages/upload/index.vue | 16 ++++++++-------- client/strings/en-us.json | 4 ++-- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/client/mixins/uploadHelpers.js b/client/mixins/uploadHelpers.js index 2d7a554f..994d36c6 100644 --- a/client/mixins/uploadHelpers.js +++ b/client/mixins/uploadHelpers.js @@ -28,10 +28,8 @@ export default { var validOtherFiles = [] var ignoredFiles = [] files.forEach((file) => { - // var filetype = this.checkFileType(file.name) if (!file.filetype) ignoredFiles.push(file) else { - // file.filetype = filetype if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file) else validOtherFiles.push(file) } @@ -165,7 +163,7 @@ export default { var firstBookPath = Path.dirname(firstBookFile.filepath) - var dirs = firstBookPath.split('/').filter(d => !!d && d !== '.') + var dirs = firstBookPath.split('/').filter((d) => !!d && d !== '.') if (dirs.length) { audiobook.title = dirs.pop() if (dirs.length > 1) { @@ -189,7 +187,7 @@ export default { var firstAudioFile = podcast.itemFiles[0] if (!firstAudioFile.filepath) return podcast // No path var firstPath = Path.dirname(firstAudioFile.filepath) - var dirs = firstPath.split('/').filter(d => !!d && d !== '.') + var dirs = firstPath.split('/').filter((d) => !!d && d !== '.') if (dirs.length) { podcast.title = dirs.length > 1 ? dirs[1] : dirs[0] } else { @@ -212,13 +210,15 @@ export default { } var ignoredFiles = itemData.ignoredFiles var index = 1 - var items = itemData.items.filter((ab) => { - if (!ab.itemFiles.length) { - if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles) - if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles) - } - return ab.itemFiles.length - }).map(ab => this.cleanItem(ab, mediaType, index++)) + var items = itemData.items + .filter((ab) => { + if (!ab.itemFiles.length) { + if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles) + if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles) + } + return ab.itemFiles.length + }) + .map((ab) => this.cleanItem(ab, mediaType, index++)) return { items, ignoredFiles @@ -259,7 +259,7 @@ export default { otherFiles.forEach((file) => { var dir = Path.dirname(file.filepath) - var findItem = Object.values(itemMap).find(b => dir.startsWith(b.path)) + var findItem = Object.values(itemMap).find((b) => dir.startsWith(b.path)) if (findItem) { findItem.otherFiles.push(file) } else { @@ -270,18 +270,18 @@ export default { var items = [] var index = 1 // If book media type and all files are audio files then treat each one as an audiobook - if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) { + if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some((f) => f.filetype !== 'audio')) { items = itemMap[''].itemFiles.map((audioFile) => { return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++) }) } else { - items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++)) + items = Object.values(itemMap).map((i) => this.cleanItem(i, mediaType, index++)) } return { items, ignoredFiles: ignoredFiles } - }, + } } -} \ No newline at end of file +} diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 7c1b4767..441ce88e 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -1,20 +1,20 @@ @@ -54,7 +71,7 @@ export default { return this.episode.description || '' }, media() { - return this.libraryItem ? this.libraryItem.media || {} : {} + return this.libraryItem?.media || {} }, mediaMetadata() { return this.media.metadata || {} @@ -65,6 +82,14 @@ export default { podcastAuthor() { return this.mediaMetadata.author }, + audioFileFilename() { + return this.episode.audioFile?.metadata?.filename || '' + }, + audioFileSize() { + const size = this.episode.audioFile?.metadata?.size || 0 + + return this.$bytesPretty(size) + }, bookCoverAspectRatio() { return this.$store.getters['libraries/getBookCoverAspectRatio'] } From fabdfd5517f805727e50cb25f718564ea68a23af Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Tue, 26 Nov 2024 04:04:44 +0000 Subject: [PATCH 095/762] Add player settings modal to PlayerUi --- client/components/player/PlayerUi.vue | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue index 92179580..d4fdb8f7 100644 --- a/client/components/player/PlayerUi.vue +++ b/client/components/player/PlayerUi.vue @@ -37,7 +37,7 @@ - @@ -64,6 +64,8 @@ + + @@ -96,6 +98,7 @@ export default { audioEl: null, seekLoading: false, showChaptersModal: false, + showPlayerSettingsModal: false, currentTime: 0, duration: 0 } @@ -315,6 +318,9 @@ export default { if (!this.chapters.length) return this.showChaptersModal = !this.showChaptersModal }, + showPlayerSettings() { + this.showPlayerSettingsModal = !this.showPlayerSettingsModal + }, init() { this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 From 53fdb5273ca215b2c257857ddcf660eb08cb6777 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Tue, 26 Nov 2024 04:04:55 +0000 Subject: [PATCH 096/762] Remove player settings modal from MediaPlayerContainer --- client/components/app/MediaPlayerContainer.vue | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/components/app/MediaPlayerContainer.vue b/client/components/app/MediaPlayerContainer.vue index 1a19f301..ed8971f7 100644 --- a/client/components/app/MediaPlayerContainer.vue +++ b/client/components/app/MediaPlayerContainer.vue @@ -53,7 +53,6 @@ @showBookmarks="showBookmarks" @showSleepTimer="showSleepTimerModal = true" @showPlayerQueueItems="showPlayerQueueItemsModal = true" - @showPlayerSettings="showPlayerSettingsModal = true" /> @@ -61,8 +60,6 @@ - - @@ -81,7 +78,6 @@ export default { currentTime: 0, showSleepTimerModal: false, showPlayerQueueItemsModal: false, - showPlayerSettingsModal: false, sleepTimerSet: false, sleepTimerRemaining: 0, sleepTimerType: null, From 2ba0f9157d1591e930e311943862278f65c91557 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 26 Nov 2024 17:03:01 -0600 Subject: [PATCH 097/762] Update share player to load user settings --- client/components/modals/PlayerSettingsModal.vue | 13 ++++++++++--- client/pages/share/_slug.vue | 5 ++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/client/components/modals/PlayerSettingsModal.vue b/client/components/modals/PlayerSettingsModal.vue index ec178d9c..88cb91e1 100644 --- a/client/components/modals/PlayerSettingsModal.vue +++ b/client/components/modals/PlayerSettingsModal.vue @@ -59,12 +59,19 @@ export default { setJumpBackwardAmount(val) { this.jumpBackwardAmount = val this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val }) + }, + settingsUpdated() { + this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') + this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') + this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') } }, mounted() { - this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') - this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') - this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') + this.settingsUpdated() + this.$eventBus.$on('user-settings', this.settingsUpdated) + }, + beforeDestroy() { + this.$eventBus.$off('user-settings', this.settingsUpdated) } } diff --git a/client/pages/share/_slug.vue b/client/pages/share/_slug.vue index cd990072..89e159c1 100644 --- a/client/pages/share/_slug.vue +++ b/client/pages/share/_slug.vue @@ -126,7 +126,8 @@ export default { if (!this.localAudioPlayer || !this.hasLoaded) return const currentTime = this.localAudioPlayer.getCurrentTime() const duration = this.localAudioPlayer.getDuration() - this.seek(Math.min(currentTime + 10, duration)) + const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10 + this.seek(Math.min(currentTime + jumpForwardAmount, duration)) }, jumpBackward() { if (!this.localAudioPlayer || !this.hasLoaded) return @@ -248,6 +249,8 @@ export default { } }, mounted() { + this.$store.dispatch('user/loadUserSettings') + this.resize() window.addEventListener('resize', this.resize) window.addEventListener('keydown', this.keyDown) From 718d8b599993c676762dae07bd09a73c65971490 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 26 Nov 2024 17:05:50 -0600 Subject: [PATCH 098/762] Update jump backward amount for share player --- client/pages/share/_slug.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/pages/share/_slug.vue b/client/pages/share/_slug.vue index 89e159c1..7ddb994c 100644 --- a/client/pages/share/_slug.vue +++ b/client/pages/share/_slug.vue @@ -132,7 +132,8 @@ export default { jumpBackward() { if (!this.localAudioPlayer || !this.hasLoaded) return const currentTime = this.localAudioPlayer.getCurrentTime() - this.seek(Math.max(currentTime - 10, 0)) + const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10 + this.seek(Math.max(currentTime - jumpBackwardAmount, 0)) }, setVolume(volume) { if (!this.localAudioPlayer || !this.hasLoaded) return From ef82e8b0d0760b40a1ab7ac94ceb4af94c046f13 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 27 Nov 2024 16:48:07 -0600 Subject: [PATCH 099/762] Fix:Server crash deleting user with sessions --- server/controllers/UserController.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index f895c0d0..0fb10513 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -368,6 +368,19 @@ class UserController { await playlist.destroy() } + // Set PlaybackSessions userId to null + const [sessionsUpdated] = await Database.playbackSessionModel.update( + { + userId: null + }, + { + where: { + userId: user.id + } + } + ) + Logger.info(`[UserController] Updated ${sessionsUpdated} playback sessions to remove user id`) + const userJson = user.toOldJSONForBrowser() await user.destroy() SocketAuthority.adminEmitter('user_removed', userJson) From 70f466d03c4c27d99070d764a2eddac0bdccc9f8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 28 Nov 2024 17:18:34 -0600 Subject: [PATCH 100/762] Add migration for v2.17.3 to fix dropped fk constraints --- server/migrations/changelog.md | 13 +- server/migrations/v2.17.3-fk-constraints.js | 219 ++++++++++++++++++++ 2 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 server/migrations/v2.17.3-fk-constraints.js diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 8960ade2..51e82600 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,9 +2,10 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | -| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| Server Version | Migration Script Name | Description | +| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration | diff --git a/server/migrations/v2.17.3-fk-constraints.js b/server/migrations/v2.17.3-fk-constraints.js new file mode 100644 index 00000000..a62307a3 --- /dev/null +++ b/server/migrations/v2.17.3-fk-constraints.js @@ -0,0 +1,219 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script changes foreign key constraints for the + * libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints') + + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + + // Disable foreign key constraints for the next sequence of operations + await execQuery(`PRAGMA foreign_keys = OFF;`) + + try { + await execQuery(`BEGIN TRANSACTION;`) + + logger.info('[2.17.3 migration] Updating libraryItems constraints') + const libraryItemsConstraints = [ + { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } + ] + await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints) + logger.info('[2.17.3 migration] Finished updating libraryItems constraints') + + logger.info('[2.17.3 migration] Updating feeds constraints') + const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'feeds', feedsConstraints) + logger.info('[2.17.3 migration] Finished updating feeds constraints') + + if (await queryInterface.tableExists('mediaItemShares')) { + logger.info('[2.17.3 migration] Updating mediaItemShares constraints') + const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints) + logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints') + } else { + logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change') + } + + logger.info('[2.17.3 migration] Updating playbackSessions constraints') + const playbackSessionsConstraints = [ + { field: 'deviceId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } + ] + await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints) + logger.info('[2.17.3 migration] Finished updating playbackSessions constraints') + + logger.info('[2.17.3 migration] Updating playlistMediaItems constraints') + const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints) + logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints') + + logger.info('[2.17.3 migration] Updating mediaProgresses constraints') + const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints) + logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints') + + await execQuery(`COMMIT;`) + } catch (error) { + logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error) + await execQuery(`ROLLBACK;`) + } + + await execQuery(`PRAGMA foreign_keys = ON;`) + + // Completed migration + logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints') +} + +/** + * This downward migration script is a no-op. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints') + + // This migration is a no-op + logger.info('[2.17.3 migration] No action required for downgrade') + + // Completed migration + logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints') +} + +/** + * @typedef ConstraintUpdateObj + * @property {string} field - The field to update + * @property {string} onDelete - The onDelete constraint + * @property {string} onUpdate - The onUpdate constraint + */ + +const formatFKsPragmaToSequelizeFK = (fk) => { + let onDelete = fk['on_delete'] + let onUpdate = fk['on_update'] + + if (fk.from === 'userId' || fk.from === 'libraryId' || fk.from === 'deviceId') { + onDelete = 'SET NULL' + onUpdate = 'CASCADE' + } + + return { + references: { + model: fk.table, + key: fk.to + }, + constraints: { + onDelete, + onUpdate + } + } +} + +/** + * Extends the Sequelize describeTable function to include the foreign keys constraints in sqlite dbs + * @param {import('sequelize').QueryInterface} queryInterface + * @param {String} tableName - The table name + * @param {ConstraintUpdateObj[]} constraints - constraints to update + */ +async function describeTableWithFKs(queryInterface, tableName, constraints) { + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + const quotedTableName = queryInterface.quoteIdentifier(tableName) + + const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`) + + const foreignKeysByColName = foreignKeys.reduce((prev, curr) => { + const fk = formatFKsPragmaToSequelizeFK(curr) + return { ...prev, [curr.from]: fk } + }, {}) + + const tableDescription = await queryInterface.describeTable(tableName) + + const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => { + let extendedAttributes = attributes + + if (foreignKeysByColName[col]) { + // Use the constraints from the constraints array if they exist, otherwise use the existing constraints + const onDelete = constraints.find((c) => c.field === col)?.onDelete || foreignKeysByColName[col].constraints.onDelete + const onUpdate = constraints.find((c) => c.field === col)?.onUpdate || foreignKeysByColName[col].constraints.onUpdate + + extendedAttributes = { + ...extendedAttributes, + references: foreignKeysByColName[col].references, + onDelete, + onUpdate + } + } + return { ...prev, [col]: extendedAttributes } + }, {}) + + return tableDescriptionWithFks +} + +/** + * @see https://www.sqlite.org/lang_altertable.html#otheralter + * @see https://sequelize.org/docs/v6/other-topics/query-interface/#changing-and-removing-columns-in-sqlite + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {string} tableName + * @param {ConstraintUpdateObj[]} constraints + */ +async function changeConstraints(queryInterface, tableName, constraints) { + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + const quotedTableName = queryInterface.quoteIdentifier(tableName) + + const backupTableName = `${tableName}_${Math.round(Math.random() * 100)}_backup` + const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName) + + try { + const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, constraints) + + const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks) + + // Create the backup table + await queryInterface.createTable(backupTableName, attributes) + + const attributeNames = Object.keys(attributes) + .map((attr) => queryInterface.quoteIdentifier(attr)) + .join(', ') + + // Copy all data from the target table to the backup table + await execQuery(`INSERT INTO ${quotedBackupTableName} SELECT ${attributeNames} FROM ${quotedTableName};`) + + // Drop the old (original) table + await queryInterface.dropTable(tableName) + + // Rename the backup table to the original table's name + await queryInterface.renameTable(backupTableName, tableName) + + // Validate that all foreign key constraints are correct + const result = await execQuery(`PRAGMA foreign_key_check(${quotedTableName});`, { + type: queryInterface.sequelize.Sequelize.QueryTypes.SELECT + }) + + // There are foreign key violations, exit + if (result.length) { + return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`) + } + + return Promise.resolve() + } catch (error) { + return Promise.reject(error) + } +} + +module.exports = { up, down } From 843dd0b1b28ec1e5f36b71eee58af7306e84a4ef Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 29 Nov 2024 04:13:00 +0200 Subject: [PATCH 101/762] Keep original socket.io server for non-subdir clients --- server/Server.js | 18 ++--- server/SocketAuthority.js | 148 ++++++++++++++++++++++---------------- 2 files changed, 90 insertions(+), 76 deletions(-) diff --git a/server/Server.js b/server/Server.js index ae9746d8..9153ab09 100644 --- a/server/Server.js +++ b/server/Server.js @@ -84,7 +84,6 @@ class Server { Logger.logManager = new LogManager() this.server = null - this.io = null } /** @@ -441,18 +440,11 @@ class Server { async stop() { Logger.info('=== Stopping Server ===') Watcher.close() - Logger.info('Watcher Closed') - - return new Promise((resolve) => { - SocketAuthority.close((err) => { - if (err) { - Logger.error('Failed to close server', err) - } else { - Logger.info('Server successfully closed') - } - resolve() - }) - }) + Logger.info('[Server] Watcher Closed') + await SocketAuthority.close() + Logger.info('[Server] Closing HTTP Server') + await new Promise((resolve) => this.server.close(resolve)) + Logger.info('[Server] HTTP Server Closed') } } module.exports = Server diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index a7182936..19c686d9 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -14,7 +14,7 @@ const Auth = require('./Auth') class SocketAuthority { constructor() { this.Server = null - this.io = null + this.socketIoServers = [] /** @type {Object.} */ this.clients = {} @@ -89,82 +89,104 @@ class SocketAuthority { * * @param {Function} callback */ - close(callback) { - Logger.info('[SocketAuthority] Shutting down') - // This will close all open socket connections, and also close the underlying http server - if (this.io) this.io.close(callback) - else callback() + async close() { + Logger.info('[SocketAuthority] closing...') + const closePromises = this.socketIoServers.map((io) => { + return new Promise((resolve) => { + Logger.info(`[SocketAuthority] Closing Socket.IO server: ${io.path}`) + io.close(() => { + Logger.info(`[SocketAuthority] Socket.IO server closed: ${io.path}`) + resolve() + }) + }) + }) + await Promise.all(closePromises) + Logger.info('[SocketAuthority] closed') + this.socketIoServers = [] } initialize(Server) { this.Server = Server - this.io = new SocketIO.Server(this.Server.server, { + const socketIoOptions = { cors: { origin: '*', methods: ['GET', 'POST'] - }, - path: `${global.RouterBasePath}/socket.io` - }) - - this.io.on('connection', (socket) => { - this.clients[socket.id] = { - id: socket.id, - socket, - connected_at: Date.now() } - socket.sheepClient = this.clients[socket.id] + } - Logger.info('[SocketAuthority] Socket Connected', socket.id) + const ioServer = new SocketIO.Server(Server.server, socketIoOptions) + ioServer.path = '/socket.io' + this.socketIoServers.push(ioServer) - // Required for associating a User with a socket - socket.on('auth', (token) => this.authenticateSocket(socket, token)) + if (global.RouterBasePath) { + // open a separate socket.io server for the router base path, keeping the original server open for legacy clients + const ioBasePath = `${global.RouterBasePath}/socket.io` + const ioBasePathServer = new SocketIO.Server(Server.server, { ...socketIoOptions, path: ioBasePath }) + ioBasePathServer.path = ioBasePath + this.socketIoServers.push(ioBasePathServer) + } - // Scanning - socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId)) - - // Logs - socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) - socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id)) - - // Sent automatically from socket.io clients - socket.on('disconnect', (reason) => { - Logger.removeSocketListener(socket.id) - - const _client = this.clients[socket.id] - if (!_client) { - Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`) - } else if (!_client.user) { - Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`) - delete this.clients[socket.id] - } else { - Logger.debug('[SocketAuthority] User Offline ' + _client.user.username) - this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) - - const disconnectTime = Date.now() - _client.connected_at - Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`) - delete this.clients[socket.id] + this.socketIoServers.forEach((io) => { + io.on('connection', (socket) => { + this.clients[socket.id] = { + id: socket.id, + socket, + connected_at: Date.now() } - }) + socket.sheepClient = this.clients[socket.id] - // - // Events for testing - // - socket.on('message_all_users', (payload) => { - // admin user can send a message to all authenticated users - // displays on the web app as a toast - const client = this.clients[socket.id] || {} - if (client.user?.isAdminOrUp) { - this.emitter('admin_message', payload.message || '') - } else { - Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`) - } - }) - socket.on('ping', () => { - const client = this.clients[socket.id] || {} - const user = client.user || {} - Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`) - socket.emit('pong') + Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id) + + // Required for associating a User with a socket + socket.on('auth', (token) => this.authenticateSocket(socket, token)) + + // Scanning + socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId)) + + // Logs + socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) + socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id)) + + // Sent automatically from socket.io clients + socket.on('disconnect', (reason) => { + Logger.removeSocketListener(socket.id) + + const _client = this.clients[socket.id] + if (!_client) { + Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`) + } else if (!_client.user) { + Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`) + delete this.clients[socket.id] + } else { + Logger.debug('[SocketAuthority] User Offline ' + _client.user.username) + this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) + + const disconnectTime = Date.now() - _client.connected_at + Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`) + delete this.clients[socket.id] + } + }) + + // + // Events for testing + // + socket.on('message_all_users', (payload) => { + // admin user can send a message to all authenticated users + // displays on the web app as a toast + const client = this.clients[socket.id] || {} + if (client.user?.isAdminOrUp) { + this.emitter('admin_message', payload.message || '') + } else { + Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`) + } + }) + socket.on('ping', () => { + const client = this.clients[socket.id] || {} + const user = client.user || {} + Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`) + socket.emit('pong') + }) }) }) } From 6d8720b404722ba328dfe5de95d43061dc1dffdb Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 29 Nov 2024 04:28:50 +0200 Subject: [PATCH 102/762] Subfolder support for OIDC auth --- client/pages/config/authentication.vue | 38 +++++- client/strings/en-us.json | 2 + server/Auth.js | 8 +- server/controllers/MiscController.js | 4 +- server/migrations/changelog.md | 13 +- ....3-use-subfolder-for-oidc-redirect-uris.js | 84 +++++++++++++ server/objects/settings/ServerSettings.js | 6 +- ...e-subfolder-for-oidc-redirect-uris.test.js | 116 ++++++++++++++++++ 8 files changed, 257 insertions(+), 14 deletions(-) create mode 100644 server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js create mode 100644 test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 1f934c88..ba4df4c3 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -64,6 +64,20 @@

+

+
+ +
+
+

{{ $strings.LabelWebRedirectURLsDescription }}

+

+ {{ webCallbackURL }} +
+ {{ mobileAppCallbackURL }} +

+
+
+
@@ -164,6 +178,27 @@ export default { value: 'username' } ] + }, + subfolderOptions() { + const options = [ + { + text: 'None', + value: '' + } + ] + if (this.$config.routerBasePath) { + options.push({ + text: this.$config.routerBasePath, + value: this.$config.routerBasePath + }) + } + return options + }, + webCallbackURL() { + return `https://${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback` + }, + mobileAppCallbackURL() { + return `https://${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect` } }, methods: { @@ -325,7 +360,8 @@ export default { }, init() { this.newAuthSettings = { - ...this.authSettings + ...this.authSettings, + authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs } this.enableLocalAuth = this.authMethods.includes('local') this.enableOpenIDAuth = this.authMethods.includes('openid') diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 0c077ed6..8a91686c 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "View player settings", "LabelViewQueue": "View player queue", "LabelVolume": "Volume", + "LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs", + "LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:", "LabelWeekdaysToRun": "Weekdays to run", "LabelXBooks": "{0} books", "LabelXItems": "{0} items", diff --git a/server/Auth.js b/server/Auth.js index b0046799..74b767f5 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -131,7 +131,7 @@ class Auth { { client: openIdClient, params: { - redirect_uri: '/auth/openid/callback', + redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, scope: 'openid profile email' } }, @@ -480,9 +480,9 @@ class Auth { // 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('/auth/openid/mobile-redirect', hostUrl).toString() + redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() } else { - redirectUri = new URL('/auth/openid/callback', hostUrl).toString() + 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`) @@ -733,7 +733,7 @@ class Auth { 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}/login` + 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 diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index cf901bea..2a87f2fe 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -679,9 +679,9 @@ class MiscController { continue } let updatedValue = settingsUpdate[key] - if (updatedValue === '') updatedValue = null + if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null let currentValue = currentAuthenticationSettings[key] - if (currentValue === '') currentValue = null + if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null if (updatedValue !== currentValue) { Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 8960ade2..8ba4fad0 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,9 +2,10 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | -| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| Server Version | Migration Script Name | Description | +| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------ | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| v2.17.3 | v2.17.3-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | diff --git a/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js b/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js new file mode 100644 index 00000000..d03783cd --- /dev/null +++ b/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js @@ -0,0 +1,84 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration adds an subfolder setting for OIDC redirect URIs. + * It updates existing OIDC setups to set this option to None (empty subfolder), so they continue to work as before. + * IF OIDC is not enabled, no action is taken (i.e. the subfolder is left undefined), + * so that future OIDC setups will use the default subfolder. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris') + + const serverSettings = await getServerSettings(queryInterface, logger) + if (serverSettings.authActiveAuthMethods?.includes('openid')) { + logger.info('[2.17.3 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings') + serverSettings.authOpenIDSubfolderForRedirectURLs = '' + await updateServerSettings(queryInterface, logger, serverSettings) + } else { + logger.info('[2.17.3 migration] OIDC is not enabled, no action required') + } + + logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris') +} + +/** + * This downward migration script removes the subfolder setting for OIDC redirect URIs. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ') + + // Remove the OIDC subfolder option from the server settings + const serverSettings = await getServerSettings(queryInterface, logger) + if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) { + logger.info('[2.17.3 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings') + delete serverSettings.authOpenIDSubfolderForRedirectURLs + await updateServerSettings(queryInterface, logger, serverSettings) + } else { + logger.info('[2.17.3 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required') + } + + logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ') +} + +async function getServerSettings(queryInterface, logger) { + const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";') + if (!result[0].length) { + logger.error('[2.17.3 migration] Server settings not found') + throw new Error('Server settings not found') + } + + let serverSettings = null + try { + serverSettings = JSON.parse(result[0][0].value) + } catch (error) { + logger.error('[2.17.3 migration] Error parsing server settings:', error) + throw error + } + + return serverSettings +} + +async function updateServerSettings(queryInterface, logger, serverSettings) { + await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', { + replacements: { + value: JSON.stringify(serverSettings) + } + }) +} + +module.exports = { up, down } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 8ecb8ff0..ff28027f 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -78,6 +78,7 @@ class ServerSettings { this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] this.authOpenIDGroupClaim = '' this.authOpenIDAdvancedPermsClaim = '' + this.authOpenIDSubfolderForRedirectURLs = undefined if (settings) { this.construct(settings) @@ -139,6 +140,7 @@ class ServerSettings { this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth'] this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || '' this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || '' + this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] @@ -240,7 +242,8 @@ class ServerSettings { authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client - authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim // Do not return to client + authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client + authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs } } @@ -286,6 +289,7 @@ class ServerSettings { authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client + authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs, authOpenIDSamplePermissions: User.getSampleAbsPermissions() } diff --git a/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js b/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js new file mode 100644 index 00000000..157b1ed4 --- /dev/null +++ b/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js @@ -0,0 +1,116 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up, down } = require('../../../server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris') +const { Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') + +describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { + let queryInterface, logger, context + + beforeEach(() => { + queryInterface = { + sequelize: { + query: sinon.stub() + } + } + logger = { + info: sinon.stub(), + error: sinon.stub() + } + context = { queryInterface, logger } + }) + + describe('up', () => { + it('should add authOpenIDSubfolderForRedirectURLs if OIDC is enabled', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authActiveAuthMethods: ['openid'] }) }]]) + queryInterface.sequelize.query.onSecondCall().resolves() + + await up({ context }) + + expect(logger.info.calledWith('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')).to.be.true + expect(queryInterface.sequelize.query.calledTwice).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect( + queryInterface.sequelize.query.calledWith('UPDATE settings SET value = :value WHERE key = "server-settings";', { + replacements: { + value: JSON.stringify({ authActiveAuthMethods: ['openid'], authOpenIDSubfolderForRedirectURLs: '' }) + } + }) + ).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + }) + + it('should not add authOpenIDSubfolderForRedirectURLs if OIDC is not enabled', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authActiveAuthMethods: [] }) }]]) + + await up({ context }) + + expect(logger.info.calledWith('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] OIDC is not enabled, no action required')).to.be.true + expect(queryInterface.sequelize.query.calledOnce).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + }) + + it('should throw an error if server settings cannot be parsed', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: 'invalid json' }]]) + + try { + await up({ context }) + } catch (error) { + expect(queryInterface.sequelize.query.calledOnce).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect(logger.error.calledWith('[2.17.3 migration] Error parsing server settings:')).to.be.true + expect(error).to.be.instanceOf(Error) + } + }) + + it('should throw an error if server settings are not found', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[]]) + + try { + await up({ context }) + } catch (error) { + expect(queryInterface.sequelize.query.calledOnce).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect(logger.error.calledWith('[2.17.3 migration] Server settings not found')).to.be.true + expect(error).to.be.instanceOf(Error) + } + }) + }) + + describe('down', () => { + it('should remove authOpenIDSubfolderForRedirectURLs if it exists', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authOpenIDSubfolderForRedirectURLs: '' }) }]]) + queryInterface.sequelize.query.onSecondCall().resolves() + + await down({ context }) + + expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')).to.be.true + expect(queryInterface.sequelize.query.calledTwice).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect( + queryInterface.sequelize.query.calledWith('UPDATE settings SET value = :value WHERE key = "server-settings";', { + replacements: { + value: JSON.stringify({}) + } + }) + ).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + }) + + it('should not remove authOpenIDSubfolderForRedirectURLs if it does not exist', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({}) }]]) + + await down({ context }) + + expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')).to.be.true + expect(queryInterface.sequelize.query.calledOnce).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + }) + }) +}) From 8c3ba675836c4e5bc916dfe7d60249b02a842468 Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 29 Nov 2024 05:48:04 +0200 Subject: [PATCH 103/762] Fix label order --- 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 8a91686c..75069cd3 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -679,8 +679,8 @@ "LabelViewPlayerSettings": "View player settings", "LabelViewQueue": "View player queue", "LabelVolume": "Volume", - "LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs", "LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:", + "LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs", "LabelWeekdaysToRun": "Weekdays to run", "LabelXBooks": "{0} books", "LabelXItems": "{0} items", From 9917f2d358c803665cc1bb5750f3f64a1b89577b Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 29 Nov 2024 09:01:03 +0200 Subject: [PATCH 104/762] Change migration to v2.17.4 --- server/migrations/changelog.md | 2 +- ...4-use-subfolder-for-oidc-redirect-uris.js} | 20 ++++++------ ...-subfolder-for-oidc-redirect-uris.test.js} | 32 +++++++++---------- 3 files changed, 27 insertions(+), 27 deletions(-) rename server/migrations/{v2.17.3-use-subfolder-for-oidc-redirect-uris.js => v2.17.4-use-subfolder-for-oidc-redirect-uris.js} (82%) rename test/server/migrations/{v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js => v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js} (73%) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 8ba4fad0..67c09d53 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -8,4 +8,4 @@ Please add a record of every database migration that you create to this file. Th | v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | | v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | | v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | -| v2.17.3 | v2.17.3-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | +| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | diff --git a/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js b/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js similarity index 82% rename from server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js rename to server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js index d03783cd..03797e35 100644 --- a/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js +++ b/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js @@ -18,18 +18,18 @@ */ async function up({ context: { queryInterface, logger } }) { // Upwards migration script - logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris') + logger.info('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris') const serverSettings = await getServerSettings(queryInterface, logger) if (serverSettings.authActiveAuthMethods?.includes('openid')) { - logger.info('[2.17.3 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings') + logger.info('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings') serverSettings.authOpenIDSubfolderForRedirectURLs = '' await updateServerSettings(queryInterface, logger, serverSettings) } else { - logger.info('[2.17.3 migration] OIDC is not enabled, no action required') + logger.info('[2.17.4 migration] OIDC is not enabled, no action required') } - logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris') + logger.info('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris') } /** @@ -40,25 +40,25 @@ async function up({ context: { queryInterface, logger } }) { */ async function down({ context: { queryInterface, logger } }) { // Downward migration script - logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ') + logger.info('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ') // Remove the OIDC subfolder option from the server settings const serverSettings = await getServerSettings(queryInterface, logger) if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) { - logger.info('[2.17.3 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings') + logger.info('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings') delete serverSettings.authOpenIDSubfolderForRedirectURLs await updateServerSettings(queryInterface, logger, serverSettings) } else { - logger.info('[2.17.3 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required') + logger.info('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required') } - logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ') + logger.info('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ') } async function getServerSettings(queryInterface, logger) { const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";') if (!result[0].length) { - logger.error('[2.17.3 migration] Server settings not found') + logger.error('[2.17.4 migration] Server settings not found') throw new Error('Server settings not found') } @@ -66,7 +66,7 @@ async function getServerSettings(queryInterface, logger) { try { serverSettings = JSON.parse(result[0][0].value) } catch (error) { - logger.error('[2.17.3 migration] Error parsing server settings:', error) + logger.error('[2.17.4 migration] Error parsing server settings:', error) throw error } diff --git a/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js b/test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js similarity index 73% rename from test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js rename to test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js index 157b1ed4..1662d5f9 100644 --- a/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js +++ b/test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js @@ -1,10 +1,10 @@ const { expect } = require('chai') const sinon = require('sinon') -const { up, down } = require('../../../server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris') +const { up, down } = require('../../../server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris') const { Sequelize } = require('sequelize') const Logger = require('../../../server/Logger') -describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { +describe('Migration v2.17.4-use-subfolder-for-oidc-redirect-uris', () => { let queryInterface, logger, context beforeEach(() => { @@ -27,8 +27,8 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { await up({ context }) - expect(logger.info.calledWith('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')).to.be.true expect(queryInterface.sequelize.query.calledTwice).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true expect( @@ -38,7 +38,7 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { } }) ).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true }) it('should not add authOpenIDSubfolderForRedirectURLs if OIDC is not enabled', async () => { @@ -46,11 +46,11 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { await up({ context }) - expect(logger.info.calledWith('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] OIDC is not enabled, no action required')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] OIDC is not enabled, no action required')).to.be.true expect(queryInterface.sequelize.query.calledOnce).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true }) it('should throw an error if server settings cannot be parsed', async () => { @@ -61,7 +61,7 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { } catch (error) { expect(queryInterface.sequelize.query.calledOnce).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true - expect(logger.error.calledWith('[2.17.3 migration] Error parsing server settings:')).to.be.true + expect(logger.error.calledWith('[2.17.4 migration] Error parsing server settings:')).to.be.true expect(error).to.be.instanceOf(Error) } }) @@ -74,7 +74,7 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { } catch (error) { expect(queryInterface.sequelize.query.calledOnce).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true - expect(logger.error.calledWith('[2.17.3 migration] Server settings not found')).to.be.true + expect(logger.error.calledWith('[2.17.4 migration] Server settings not found')).to.be.true expect(error).to.be.instanceOf(Error) } }) @@ -87,8 +87,8 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { await down({ context }) - expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')).to.be.true expect(queryInterface.sequelize.query.calledTwice).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true expect( @@ -98,7 +98,7 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { } }) ).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true }) it('should not remove authOpenIDSubfolderForRedirectURLs if it does not exist', async () => { @@ -106,11 +106,11 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { await down({ context }) - expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')).to.be.true expect(queryInterface.sequelize.query.calledOnce).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true }) }) }) From 4b52f31d58216875f9429f1ace2314bb4061d19f Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 30 Nov 2024 15:48:20 -0600 Subject: [PATCH 105/762] Update v2.17.3 migration file to first check if constraints need to be updated, add unit test --- server/migrations/v2.17.3-fk-constraints.js | 116 ++++++--- .../migrations/v2.17.3-fk-constraints.test.js | 230 ++++++++++++++++++ 2 files changed, 308 insertions(+), 38 deletions(-) create mode 100644 test/server/migrations/v2.17.3-fk-constraints.test.js diff --git a/server/migrations/v2.17.3-fk-constraints.js b/server/migrations/v2.17.3-fk-constraints.js index a62307a3..5f8a5c9a 100644 --- a/server/migrations/v2.17.3-fk-constraints.js +++ b/server/migrations/v2.17.3-fk-constraints.js @@ -31,19 +31,28 @@ async function up({ context: { queryInterface, logger } }) { { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, { field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } ] - await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints) - logger.info('[2.17.3 migration] Finished updating libraryItems constraints') + if (await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints)) { + logger.info('[2.17.3 migration] Finished updating libraryItems constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for libraryItems constraints') + } logger.info('[2.17.3 migration] Updating feeds constraints') const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] - await changeConstraints(queryInterface, 'feeds', feedsConstraints) - logger.info('[2.17.3 migration] Finished updating feeds constraints') + if (await changeConstraints(queryInterface, 'feeds', feedsConstraints)) { + logger.info('[2.17.3 migration] Finished updating feeds constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for feeds constraints') + } if (await queryInterface.tableExists('mediaItemShares')) { logger.info('[2.17.3 migration] Updating mediaItemShares constraints') const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] - await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints) - logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints') + if (await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints)) { + logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for mediaItemShares constraints') + } } else { logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change') } @@ -54,18 +63,27 @@ async function up({ context: { queryInterface, logger } }) { { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, { field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } ] - await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints) - logger.info('[2.17.3 migration] Finished updating playbackSessions constraints') + if (await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints)) { + logger.info('[2.17.3 migration] Finished updating playbackSessions constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for playbackSessions constraints') + } logger.info('[2.17.3 migration] Updating playlistMediaItems constraints') const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] - await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints) - logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints') + if (await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints)) { + logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for playlistMediaItems constraints') + } logger.info('[2.17.3 migration] Updating mediaProgresses constraints') const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] - await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints) - logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints') + if (await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints)) { + logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for mediaProgresses constraints') + } await execQuery(`COMMIT;`) } catch (error) { @@ -103,59 +121,75 @@ async function down({ context: { queryInterface, logger } }) { * @property {string} onUpdate - The onUpdate constraint */ +/** + * @typedef SequelizeFKObj + * @property {{ model: string, key: string }} references + * @property {string} onDelete + * @property {string} onUpdate + */ + +/** + * @param {Object} fk - The foreign key object from PRAGMA foreign_key_list + * @returns {SequelizeFKObj} - The foreign key object formatted for Sequelize + */ const formatFKsPragmaToSequelizeFK = (fk) => { - let onDelete = fk['on_delete'] - let onUpdate = fk['on_update'] - - if (fk.from === 'userId' || fk.from === 'libraryId' || fk.from === 'deviceId') { - onDelete = 'SET NULL' - onUpdate = 'CASCADE' - } - return { references: { model: fk.table, key: fk.to }, - constraints: { - onDelete, - onUpdate - } + onDelete: fk['on_delete'], + onUpdate: fk['on_update'] } } /** - * Extends the Sequelize describeTable function to include the foreign keys constraints in sqlite dbs + * * @param {import('sequelize').QueryInterface} queryInterface - * @param {String} tableName - The table name - * @param {ConstraintUpdateObj[]} constraints - constraints to update + * @param {string} tableName + * @param {ConstraintUpdateObj[]} constraints + * @returns {Promise|null>} */ -async function describeTableWithFKs(queryInterface, tableName, constraints) { +async function getUpdatedForeignKeys(queryInterface, tableName, constraints) { const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) const quotedTableName = queryInterface.quoteIdentifier(tableName) const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`) + let hasUpdates = false const foreignKeysByColName = foreignKeys.reduce((prev, curr) => { const fk = formatFKsPragmaToSequelizeFK(curr) + + const constraint = constraints.find((c) => c.field === curr.from) + if (constraint && (constraint.onDelete !== fk.onDelete || constraint.onUpdate !== fk.onUpdate)) { + fk.onDelete = constraint.onDelete + fk.onUpdate = constraint.onUpdate + hasUpdates = true + } + return { ...prev, [curr.from]: fk } }, {}) + return hasUpdates ? foreignKeysByColName : null +} + +/** + * Extends the Sequelize describeTable function to include the updated foreign key constraints + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {String} tableName + * @param {Record} updatedForeignKeys + */ +async function describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) { const tableDescription = await queryInterface.describeTable(tableName) const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => { let extendedAttributes = attributes - if (foreignKeysByColName[col]) { - // Use the constraints from the constraints array if they exist, otherwise use the existing constraints - const onDelete = constraints.find((c) => c.field === col)?.onDelete || foreignKeysByColName[col].constraints.onDelete - const onUpdate = constraints.find((c) => c.field === col)?.onUpdate || foreignKeysByColName[col].constraints.onUpdate - + if (updatedForeignKeys[col]) { extendedAttributes = { ...extendedAttributes, - references: foreignKeysByColName[col].references, - onDelete, - onUpdate + ...updatedForeignKeys[col] } } return { ...prev, [col]: extendedAttributes } @@ -171,8 +205,14 @@ async function describeTableWithFKs(queryInterface, tableName, constraints) { * @param {import('sequelize').QueryInterface} queryInterface * @param {string} tableName * @param {ConstraintUpdateObj[]} constraints + * @returns {Promise} - Return false if no changes are needed, true otherwise */ async function changeConstraints(queryInterface, tableName, constraints) { + const updatedForeignKeys = await getUpdatedForeignKeys(queryInterface, tableName, constraints) + if (!updatedForeignKeys) { + return false + } + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) const quotedTableName = queryInterface.quoteIdentifier(tableName) @@ -180,7 +220,7 @@ async function changeConstraints(queryInterface, tableName, constraints) { const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName) try { - const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, constraints) + const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks) @@ -210,7 +250,7 @@ async function changeConstraints(queryInterface, tableName, constraints) { return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`) } - return Promise.resolve() + return true } catch (error) { return Promise.reject(error) } diff --git a/test/server/migrations/v2.17.3-fk-constraints.test.js b/test/server/migrations/v2.17.3-fk-constraints.test.js new file mode 100644 index 00000000..33be43ce --- /dev/null +++ b/test/server/migrations/v2.17.3-fk-constraints.test.js @@ -0,0 +1,230 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up } = require('../../../server/migrations/v2.17.3-fk-constraints') +const { Sequelize, QueryInterface } = require('sequelize') +const Logger = require('../../../server/Logger') + +describe('migration-v2.17.3-fk-constraints', () => { + let sequelize + /** @type {QueryInterface} */ + let queryInterface + let loggerInfoStub + + beforeEach(() => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + loggerInfoStub = sinon.stub(Logger, 'info') + }) + + afterEach(() => { + sinon.restore() + }) + + describe('up', () => { + beforeEach(async () => { + // Create associated tables: Users, libraries, libraryFolders, playlists, devices + await queryInterface.sequelize.query('CREATE TABLE `users` (`id` UUID PRIMARY KEY);') + await queryInterface.sequelize.query('CREATE TABLE `libraries` (`id` UUID PRIMARY KEY);') + await queryInterface.sequelize.query('CREATE TABLE `libraryFolders` (`id` UUID PRIMARY KEY);') + await queryInterface.sequelize.query('CREATE TABLE `playlists` (`id` UUID PRIMARY KEY);') + await queryInterface.sequelize.query('CREATE TABLE `devices` (`id` UUID PRIMARY KEY);') + }) + + afterEach(async () => { + await queryInterface.dropAllTables() + }) + + it('should fix table foreign key constraints', async () => { + // Create tables with missing foreign key constraints: libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, mediaProgresses + await queryInterface.sequelize.query('CREATE TABLE `libraryItems` (`id` UUID UNIQUE PRIMARY KEY, `libraryId` UUID REFERENCES `libraries` (`id`), `libraryFolderId` UUID REFERENCES `libraryFolders` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `feeds` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `mediaItemShares` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `playbackSessions` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`), `deviceId` UUID REFERENCES `devices` (`id`), `libraryId` UUID REFERENCES `libraries` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `playlistMediaItems` (`id` UUID UNIQUE PRIMARY KEY, `playlistId` UUID REFERENCES `playlists` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `mediaProgresses` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));') + + // + // Validate that foreign key constraints are missing + // + let libraryItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(libraryItems);`) + expect(libraryItemsForeignKeys).to.have.deep.members([ + { id: 0, seq: 0, table: 'libraryFolders', from: 'libraryFolderId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }, + { id: 1, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' } + ]) + + let feedsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(feeds);`) + expect(feedsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }]) + + let mediaItemSharesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaItemShares);`) + expect(mediaItemSharesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }]) + + let playbackSessionForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playbackSessions);`) + expect(playbackSessionForeignKeys).to.deep.equal([ + { id: 0, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }, + { id: 1, seq: 0, table: 'devices', from: 'deviceId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }, + { id: 2, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' } + ]) + + let playlistMediaItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playlistMediaItems);`) + expect(playlistMediaItemsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'playlists', from: 'playlistId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }]) + + let mediaProgressesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaProgresses);`) + expect(mediaProgressesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }]) + + // + // Insert test data into tables + // + await queryInterface.bulkInsert('users', [{ id: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('libraries', [{ id: 'a41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('libraryFolders', [{ id: 'b41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('playlists', [{ id: 'f41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('devices', [{ id: 'g41a40e3-f516-40f5-810d-757ab668ebba' }]) + + await queryInterface.bulkInsert('libraryItems', [{ id: 'c1a96857-48a8-43b6-8966-abc909c55b0f', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba', libraryFolderId: 'b41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('feeds', [{ id: 'd1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('mediaItemShares', [{ id: 'h1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('playbackSessions', [{ id: 'f1a96857-48a8-43b6-8966-abc909c55b0x', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f', deviceId: 'g41a40e3-f516-40f5-810d-757ab668ebba', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('playlistMediaItems', [{ id: 'i1a96857-48a8-43b6-8966-abc909c55b0f', playlistId: 'f41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('mediaProgresses', [{ id: 'j1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + + // + // Query data before migration + // + const libraryItems = await queryInterface.sequelize.query('SELECT * FROM libraryItems;') + const feeds = await queryInterface.sequelize.query('SELECT * FROM feeds;') + const mediaItemShares = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;') + const playbackSessions = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;') + const playlistMediaItems = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;') + const mediaProgresses = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;') + + // + // Run migration + // + await up({ context: { queryInterface, logger: Logger } }) + + // + // Validate that foreign key constraints are updated + // + libraryItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(libraryItems);`) + expect(libraryItemsForeignKeys).to.have.deep.members([ + { id: 0, seq: 0, table: 'libraryFolders', from: 'libraryFolderId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }, + { id: 1, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' } + ]) + + feedsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(feeds);`) + expect(feedsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }]) + + mediaItemSharesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaItemShares);`) + expect(mediaItemSharesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }]) + + playbackSessionForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playbackSessions);`) + expect(playbackSessionForeignKeys).to.deep.equal([ + { id: 0, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }, + { id: 1, seq: 0, table: 'devices', from: 'deviceId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }, + { id: 2, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' } + ]) + + playlistMediaItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playlistMediaItems);`) + expect(playlistMediaItemsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'playlists', from: 'playlistId', to: 'id', on_update: 'CASCADE', on_delete: 'CASCADE', match: 'NONE' }]) + + mediaProgressesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaProgresses);`) + expect(mediaProgressesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'CASCADE', match: 'NONE' }]) + + // + // Validate that data is not changed + // + const libraryItemsAfter = await queryInterface.sequelize.query('SELECT * FROM libraryItems;') + expect(libraryItemsAfter).to.deep.equal(libraryItems) + + const feedsAfter = await queryInterface.sequelize.query('SELECT * FROM feeds;') + expect(feedsAfter).to.deep.equal(feeds) + + const mediaItemSharesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;') + expect(mediaItemSharesAfter).to.deep.equal(mediaItemShares) + + const playbackSessionsAfter = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;') + expect(playbackSessionsAfter).to.deep.equal(playbackSessions) + + const playlistMediaItemsAfter = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;') + expect(playlistMediaItemsAfter).to.deep.equal(playlistMediaItems) + + const mediaProgressesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;') + expect(mediaProgressesAfter).to.deep.equal(mediaProgresses) + }) + + it('should keep correct table foreign key constraints', async () => { + // Create tables with correct foreign key constraints: libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, mediaProgresses + await queryInterface.sequelize.query('CREATE TABLE `libraryItems` (`id` UUID PRIMARY KEY, `libraryId` UUID REFERENCES `libraries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `libraryFolderId` UUID REFERENCES `libraryFolders` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `feeds` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `mediaItemShares` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `playbackSessions` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `deviceId` UUID REFERENCES `devices` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `libraryId` UUID REFERENCES `libraries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `playlistMediaItems` (`id` UUID PRIMARY KEY, `playlistId` UUID REFERENCES `playlists` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `mediaProgresses` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);') + + // + // Insert test data into tables + // + await queryInterface.bulkInsert('users', [{ id: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('libraries', [{ id: 'a41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('libraryFolders', [{ id: 'b41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('playlists', [{ id: 'f41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('devices', [{ id: 'g41a40e3-f516-40f5-810d-757ab668ebba' }]) + + await queryInterface.bulkInsert('libraryItems', [{ id: 'c1a96857-48a8-43b6-8966-abc909c55b0f', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba', libraryFolderId: 'b41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('feeds', [{ id: 'd1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('mediaItemShares', [{ id: 'h1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('playbackSessions', [{ id: 'f1a96857-48a8-43b6-8966-abc909c55b0x', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f', deviceId: 'g41a40e3-f516-40f5-810d-757ab668ebba', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('playlistMediaItems', [{ id: 'i1a96857-48a8-43b6-8966-abc909c55b0f', playlistId: 'f41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('mediaProgresses', [{ id: 'j1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + + // + // Query data before migration + // + const libraryItems = await queryInterface.sequelize.query('SELECT * FROM libraryItems;') + const feeds = await queryInterface.sequelize.query('SELECT * FROM feeds;') + const mediaItemShares = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;') + const playbackSessions = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;') + const playlistMediaItems = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;') + const mediaProgresses = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;') + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(14) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints'))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.17.3 migration] Updating libraryItems constraints'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.17.3 migration] No changes needed for libraryItems constraints'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.17.3 migration] Updating feeds constraints'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.17.3 migration] No changes needed for feeds constraints'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.17.3 migration] Updating mediaItemShares constraints'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.17.3 migration] No changes needed for mediaItemShares constraints'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.17.3 migration] Updating playbackSessions constraints'))).to.be.true + expect(loggerInfoStub.getCall(8).calledWith(sinon.match('[2.17.3 migration] No changes needed for playbackSessions constraints'))).to.be.true + expect(loggerInfoStub.getCall(9).calledWith(sinon.match('[2.17.3 migration] Updating playlistMediaItems constraints'))).to.be.true + expect(loggerInfoStub.getCall(10).calledWith(sinon.match('[2.17.3 migration] No changes needed for playlistMediaItems constraints'))).to.be.true + expect(loggerInfoStub.getCall(11).calledWith(sinon.match('[2.17.3 migration] Updating mediaProgresses constraints'))).to.be.true + expect(loggerInfoStub.getCall(12).calledWith(sinon.match('[2.17.3 migration] No changes needed for mediaProgresses constraints'))).to.be.true + expect(loggerInfoStub.getCall(13).calledWith(sinon.match('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints'))).to.be.true + + // + // Validate that data is not changed + // + const libraryItemsAfter = await queryInterface.sequelize.query('SELECT * FROM libraryItems;') + expect(libraryItemsAfter).to.deep.equal(libraryItems) + + const feedsAfter = await queryInterface.sequelize.query('SELECT * FROM feeds;') + expect(feedsAfter).to.deep.equal(feeds) + + const mediaItemSharesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;') + expect(mediaItemSharesAfter).to.deep.equal(mediaItemShares) + + const playbackSessionsAfter = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;') + expect(playbackSessionsAfter).to.deep.equal(playbackSessions) + + const playlistMediaItemsAfter = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;') + expect(playlistMediaItemsAfter).to.deep.equal(playlistMediaItems) + + const mediaProgressesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;') + expect(mediaProgressesAfter).to.deep.equal(mediaProgresses) + }) + }) +}) From 60ba0163af004d590efeb0b0f3fc4c513b973662 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Fri, 22 Nov 2024 19:03:08 +0000 Subject: [PATCH 106/762] Translated using Weblate (German) Currently translated at 99.9% (1071 of 1072 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 6dff9338..030f8f1b 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird", "LabelUpdatedAt": "Aktualisiert am", "LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern", + "LabelUploaderDragAndDropFilesOnly": "Dateien per Drag & Drop hierher ziehen", "LabelUploaderDropFiles": "Dateien löschen", "LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie", "LabelUseAdvancedOptions": "Nutze Erweiterte Optionen", From d2c28fc69cf9a52ee41bcc9ffea05500382cf4f4 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Fri, 22 Nov 2024 09:14:11 +0000 Subject: [PATCH 107/762] Translated using Weblate (Spanish) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/es.json b/client/strings/es.json index b45d2534..76a62c16 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados", "LabelUpdatedAt": "Actualizado En", "LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas", + "LabelUploaderDragAndDropFilesOnly": "Arrastrar y soltar archivos", "LabelUploaderDropFiles": "Suelte los Archivos", "LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente", "LabelUseAdvancedOptions": "Usar opciones avanzadas", From 0449fb5ef92e7093929cef3951390b907ef80c18 Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Sat, 23 Nov 2024 11:40:05 +0000 Subject: [PATCH 108/762] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index 81cd13f4..448bbf4c 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення", "LabelUpdatedAt": "Оновлення", "LabelUploaderDragAndDrop": "Перетягніть файли або теки", + "LabelUploaderDragAndDropFilesOnly": "Перетягніть і скиньте файли", "LabelUploaderDropFiles": "Перетягніть файли", "LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію", "LabelUseAdvancedOptions": "Використовувати розширені налаштування", From 7278ad4ee759661091c1b650ab7cac38a3742184 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Fri, 22 Nov 2024 06:05:51 +0000 Subject: [PATCH 109/762] Translated using Weblate (Slovenian) Currently translated at 100.0% (1072 of 1072 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 366c8479..02c1fb13 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje", "LabelUpdatedAt": "Posodobljeno ob", "LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape", + "LabelUploaderDragAndDropFilesOnly": "Povleci in spusti datoteke", "LabelUploaderDropFiles": "Spusti datoteke", "LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo", "LabelUseAdvancedOptions": "Uporabi napredne možnosti", From 293e53029766c3324421816cf00954bf073da895 Mon Sep 17 00:00:00 2001 From: biuklija Date: Sun, 24 Nov 2024 08:57:39 +0000 Subject: [PATCH 110/762] Translated using Weblate (Croatian) Currently translated at 100.0% (1072 of 1072 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 502973c4..a7f2562b 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju", "LabelUpdatedAt": "Ažurirano", "LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape", + "LabelUploaderDragAndDropFilesOnly": "Pritisni i prevuci datoteke", "LabelUploaderDropFiles": "Ispusti datoteke", "LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal", "LabelUseAdvancedOptions": "Koristi se naprednim opcijama", From ddcbfd450013c3a9f2310579a3543f34ad811a37 Mon Sep 17 00:00:00 2001 From: Soaibuzzaman Date: Mon, 25 Nov 2024 08:40:51 +0000 Subject: [PATCH 111/762] Translated using Weblate (Bengali) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bn/ --- client/strings/bn.json | 82 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/client/strings/bn.json b/client/strings/bn.json index b705a802..16f8a447 100644 --- a/client/strings/bn.json +++ b/client/strings/bn.json @@ -66,6 +66,7 @@ "ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন", "ButtonQueueAddItem": "সারিতে যোগ করুন", "ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন", + "ButtonQuickEmbed": "দ্রুত এম্বেড করুন", "ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন", "ButtonQuickMatch": "দ্রুত ম্যাচ", "ButtonReScan": "পুনরায় স্ক্যান", @@ -162,6 +163,7 @@ "HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন", "HeaderNotifications": "বিজ্ঞপ্তি", "HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ", + "HeaderOpenListeningSessions": "শোনার সেশন খুলুন", "HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন", "HeaderOtherFiles": "অন্যান্য ফাইল", "HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ", @@ -179,6 +181,7 @@ "HeaderRemoveEpisodes": "{0}টি পর্ব সরান", "HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি", "HeaderSchedule": "সময়সূচী", + "HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন", "HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী", "HeaderSession": "সেশন", "HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন", @@ -224,7 +227,11 @@ "LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী", "LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী", "LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে", + "LabelApiToken": "API টোকেন", "LabelAppend": "সংযোজন", + "LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)", + "LabelAudioChannels": "অডিও চ্যানেল (১ বা ২)", + "LabelAudioCodec": "অডিও কোডেক", "LabelAuthor": "লেখক", "LabelAuthorFirstLast": "লেখক (প্রথম শেষ)", "LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)", @@ -237,6 +244,7 @@ "LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন", "LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন", "LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান", + "LabelBackupAudioFiles": "অডিও ফাইলগুলো ব্যাকআপ", "LabelBackupLocation": "ব্যাকআপ অবস্থান", "LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন", "LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত", @@ -245,15 +253,18 @@ "LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন", "LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।", "LabelBitrate": "বিটরেট", + "LabelBonus": "উপরিলাভ", "LabelBooks": "বইগুলো", "LabelButtonText": "ঘর পাঠ্য", "LabelByAuthor": "দ্বারা {0}", "LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন", "LabelChannels": "চ্যানেল", + "LabelChapterCount": "{0} অধ্যায়", "LabelChapterTitle": "অধ্যায়ের শিরোনাম", "LabelChapters": "অধ্যায়", "LabelChaptersFound": "অধ্যায় পাওয়া গেছে", "LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন", + "LabelClickToUseCurrentValue": "বর্তমান মান ব্যবহার করতে ক্লিক করুন", "LabelClosePlayer": "প্লেয়ার বন্ধ করুন", "LabelCodec": "কোডেক", "LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন", @@ -303,12 +314,25 @@ "LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা", "LabelEmbeddedCover": "এম্বেডেড কভার", "LabelEnable": "সক্ষম করুন", + "LabelEncodingBackupLocation": "আপনার আসল অডিও ফাইলগুলোর একটি ব্যাকআপ এখানে সংরক্ষণ করা হবে:", + "LabelEncodingChaptersNotEmbedded": "মাল্টি-ট্র্যাক অডিওবুকগুলোতে অধ্যায় এম্বেড করা হয় না।", + "LabelEncodingClearItemCache": "পর্যায়ক্রমে আইটেম ক্যাশে পরিষ্কার করতে ভুলবেন না।", + "LabelEncodingFinishedM4B": "সমাপ্ত হওয়া M4B-গুলো আপনার অডিওবুক ফোল্ডারে এখানে রাখা হবে:", + "LabelEncodingInfoEmbedded": "আপনার অডিওবুক ফোল্ডারের ভিতরে অডিও ট্র্যাকগুলোতে মেটাডেটা এমবেড করা হবে।", + "LabelEncodingStartedNavigation": "একবার টাস্ক শুরু হলে আপনি এই পৃষ্ঠা থেকে অন্যত্র যেতে পারেন।", + "LabelEncodingTimeWarning": "এনকোডিং ৩০ মিনিট পর্যন্ত সময় নিতে পারে।", + "LabelEncodingWarningAdvancedSettings": "সতর্কতা: এই সেটিংস আপডেট করবেন না, যদি না আপনি ffmpeg এনকোডিং বিকল্পগুলোর সাথে পরিচিত হন।", + "LabelEncodingWatcherDisabled": "আপনার যদি পর্যবেক্ষক অক্ষম থাকে তবে আপনাকে পরে এই অডিওবুকটি পুনরায় স্ক্যান করতে হবে।", "LabelEnd": "সমাপ্ত", "LabelEndOfChapter": "অধ্যায়ের সমাপ্তি", "LabelEpisode": "পর্ব", + "LabelEpisodeNotLinkedToRssFeed": "পর্বটি আরএসএস ফিডের সাথে সংযুক্ত করা হয়নি", + "LabelEpisodeNumber": "পর্ব #{0}", "LabelEpisodeTitle": "পর্বের শিরোনাম", "LabelEpisodeType": "পর্বের ধরন", + "LabelEpisodeUrlFromRssFeed": "আরএসএস ফিড থেকে পর্ব URL", "LabelEpisodes": "পর্বগুলো", + "LabelEpisodic": "প্রাসঙ্গিক", "LabelExample": "উদাহরণ", "LabelExpandSeries": "সিরিজ প্রসারিত করুন", "LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন", @@ -336,6 +360,7 @@ "LabelFontScale": "ফন্ট স্কেল", "LabelFontStrikethrough": "অবচ্ছেদন রেখা", "LabelFormat": "ফরম্যাট", + "LabelFull": "পূর্ণ", "LabelGenre": "ঘরানা", "LabelGenres": "ঘরানাগুলো", "LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন", @@ -391,6 +416,10 @@ "LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার", "LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন", "LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে", + "LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।", + "LabelMaxEpisodesToDownloadPerCheck": "প্রতি কিস্তিতে সর্বাধিক # টি নতুন পর্ব ডাউনলোড করা হবে", + "LabelMaxEpisodesToKeep": "সর্বোচ্চ # টি পর্ব রাখা হবে", + "LabelMaxEpisodesToKeepHelp": "০ কোন সর্বোচ্চ সীমা সেট করে না। একটি নতুন পর্ব স্বয়ংক্রিয়-ডাউনলোড হওয়ার পরে আপনার যদি X-এর বেশি পর্ব থাকে তবে এটি সবচেয়ে পুরানো পর্বটি মুছে ফেলবে। এটি প্রতি নতুন ডাউনলোডের জন্য শুধুমাত্র ১ টি পর্ব মুছে ফেলবে।", "LabelMediaPlayer": "মিডিয়া প্লেয়ার", "LabelMediaType": "মিডিয়ার ধরন", "LabelMetaTag": "মেটা ট্যাগ", @@ -436,12 +465,14 @@ "LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত গ্রুপ হিসাবে উল্লেখ করা হয়। কনফিগার করা থাকলে, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।", "LabelOpenRSSFeed": "আরএসএস ফিড খুলুন", "LabelOverwrite": "পুনঃলিখিত", + "LabelPaginationPageXOfY": "{1} টির মধ্যে {0} পৃষ্ঠা", "LabelPassword": "পাসওয়ার্ড", "LabelPath": "পথ", "LabelPermanent": "স্থায়ী", "LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে", "LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে", "LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে", + "LabelPermissionsCreateEreader": "ইরিডার তৈরি করতে পারেন", "LabelPermissionsDelete": "মুছে দিতে পারবে", "LabelPermissionsDownload": "ডাউনলোড করতে পারবে", "LabelPermissionsUpdate": "আপডেট করতে পারবে", @@ -465,6 +496,8 @@ "LabelPubDate": "প্রকাশের তারিখ", "LabelPublishYear": "প্রকাশের বছর", "LabelPublishedDate": "প্রকাশিত {0}", + "LabelPublishedDecade": "প্রকাশনার দশক", + "LabelPublishedDecades": "প্রকাশনার দশকগুলো", "LabelPublisher": "প্রকাশক", "LabelPublishers": "প্রকাশকরা", "LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল", @@ -484,21 +517,28 @@ "LabelRedo": "পুনরায় করুন", "LabelRegion": "অঞ্চল", "LabelReleaseDate": "উন্মোচনের তারিখ", + "LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান", + "LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান", "LabelRemoveCover": "কভার সরান", + "LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান", + "LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।", "LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি", "LabelSearchTerm": "অনুসন্ধান শব্দ", "LabelSearchTitle": "অনুসন্ধান শিরোনাম", "LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN", "LabelSeason": "সেশন", + "LabelSeasonNumber": "মরসুম #{0}", "LabelSelectAll": "সব নির্বাচন করুন", "LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন", "LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন", "LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন", "LabelSendEbookToDevice": "ই-বই পাঠান...", "LabelSequence": "ক্রম", + "LabelSerial": "ধারাবাহিক", "LabelSeries": "সিরিজ", "LabelSeriesName": "সিরিজের নাম", "LabelSeriesProgress": "সিরিজের অগ্রগতি", + "LabelServerLogLevel": "সার্ভার লগ লেভেল", "LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})", "LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন", "LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন", @@ -523,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।", "LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন", "LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "শতকরা সম্পূর্ণ এর চেয়ে বেশি", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "বাকি সময় (সেকেন্ড) এর চেয়ে কম", + "LabelSettingsLibraryMarkAsFinishedWhen": "মিডিয়া আইটেমকে সমাপ্ত হিসাবে চিহ্নিত করুন যখন", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।", "LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন", @@ -587,6 +630,7 @@ "LabelTimeDurationXMinutes": "{0} মিনিট", "LabelTimeDurationXSeconds": "{0} সেকেন্ড", "LabelTimeInMinutes": "মিনিটে সময়", + "LabelTimeLeft": "{0} বাকি", "LabelTimeListened": "সময় শোনা হয়েছে", "LabelTimeListenedToday": "আজ শোনার সময়", "LabelTimeRemaining": "{0}টি অবশিষ্ট", @@ -594,6 +638,7 @@ "LabelTitle": "শিরোনাম", "LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন", "LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।", + "LabelToolsM4bEncoder": "M4B এনকোডার", "LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন", "LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।", "LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন", @@ -606,6 +651,7 @@ "LabelTracksMultiTrack": "মাল্টি-ট্র্যাক", "LabelTracksNone": "কোন ট্র্যাক নেই", "LabelTracksSingleTrack": "একক-ট্র্যাক", + "LabelTrailer": "আনুগমিক", "LabelType": "টাইপ", "LabelUnabridged": "অসংলগ্ন", "LabelUndo": "পূর্বাবস্থা", @@ -617,10 +663,13 @@ "LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন", "LabelUpdatedAt": "আপডেট করা হয়েছে", "LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন", + "LabelUploaderDragAndDropFilesOnly": "ফাইল টেনে আনুন", "LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন", "LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন", + "LabelUseAdvancedOptions": "উন্নত বিকল্প ব্যবহার করুন", "LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন", "LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন", + "LabelUseZeroForUnlimited": "অসীমের জন্য 0 ব্যবহার করুন", "LabelUser": "ব্যবহারকারী", "LabelUsername": "ব্যবহারকারীর নাম", "LabelValue": "মান", @@ -667,6 +716,7 @@ "MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?", "MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?", "MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?", + "MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?", "MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?", "MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?", "MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?", @@ -678,6 +728,7 @@ "MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক /metadata/cache-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।

আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?", "MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক /metadata/cache/items-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।
আপনি কি নিশ্চিত?", "MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে।

আপনি কি চালিয়ে যেতে চান?", + "MessageConfirmQuickMatchEpisodes": "একটি মিল পাওয়া গেলে দ্রুত ম্যাচিং পর্বগুলি বিস্তারিত ওভাররাইট করবে। শুধুমাত্র অতুলনীয় পর্ব আপডেট করা হবে। আপনি কি নিশ্চিত?", "MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?", "MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?", "MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?", @@ -685,6 +736,7 @@ "MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?", "MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?", "MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?", + "MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?", "MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?", "MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?", "MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?", @@ -700,6 +752,7 @@ "MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন", "MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!", "MessageEmbedFinished": "এম্বেড করা শেষ!", + "MessageEmbedQueue": "মেটাডেটা এম্বেডের জন্য সারিবদ্ধ ({0} সারিতে)", "MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ", "MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।", "MessageFeedURLWillBe": "ফিড URL হবে {0}", @@ -744,6 +797,7 @@ "MessageNoLogs": "কোনও লগ নেই", "MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই", "MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই", + "MessageNoPodcastFeed": "অবৈধ পডকাস্ট: কোনো ফিড নেই", "MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি", "MessageNoResults": "কোন ফলাফল নেই", "MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই", @@ -760,6 +814,10 @@ "MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন", "MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।", "MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই", + "MessagePodcastSearchField": "অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন", + "MessageQuickEmbedInProgress": "দ্রুত এম্বেড করা হচ্ছে", + "MessageQuickEmbedQueue": "দ্রুত এম্বেড করার জন্য সারিবদ্ধ ({0} সারিতে)", + "MessageQuickMatchAllEpisodes": "দ্রুত ম্যাচ সব পর্ব", "MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।", "MessageRemoveChapter": "অধ্যায় সরান", "MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান", @@ -802,6 +860,9 @@ "MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান", "MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ", "MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে", + "MessageTaskOpmlParseFailed": "OPML ফাইল পার্স করতে ব্যর্থ হয়েছে", + "MessageTaskOpmlParseFastFail": "অবৈধ OPML ফাইল ট্যাগ পাওয়া যায়নি বা একটি ট্যাগ পাওয়া যায়নি", + "MessageTaskOpmlParseNoneFound": "OPML ফাইলে কোনো ফিড পাওয়া যায়নি", "MessageTaskScanItemsAdded": "{0}টি করা হয়েছে", "MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত", "MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে", @@ -826,6 +887,10 @@ "NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।", "NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।", "NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।", + "NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে", + "NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে", + "NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে", + "NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট", "PlaceholderNewCollection": "নতুন সংগ্রহের নাম", "PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ", "PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম", @@ -851,6 +916,7 @@ "StatsYearInReview": "বাৎসরিক পর্যালোচনা", "ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে", "ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে", + "ToastAsinRequired": "ASIN প্রয়োজন", "ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে", "ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি", "ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে", @@ -870,6 +936,8 @@ "ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে", "ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে", "ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে", + "ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!", + "ToastBatchQuickMatchStarted": "{0}টি বইয়ের ব্যাচ কুইক ম্যাচ শুরু হয়েছে!", "ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে", "ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য", "ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ", @@ -881,6 +949,7 @@ "ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে", "ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে", "ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে", + "ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে", "ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে", "ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে", "ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে", @@ -898,11 +967,14 @@ "ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে", "ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে", "ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে", + "ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে", "ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না", "ToastFailedToLoadData": "ডেটা লোড করা যায়নি", + "ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে", "ToastFailedToShare": "শেয়ার করতে ব্যর্থ", "ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে", "ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল", + "ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব", "ToastInvalidUrl": "অকার্যকর ইউআরএল", "ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে", "ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ", @@ -920,14 +992,22 @@ "ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ", "ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে", "ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে", + "ToastMatchAllAuthorsFailed": "সমস্ত লেখকের সাথে মিলতে ব্যর্থ হয়েছে", + "ToastMetadataFilesRemovedError": "মেটাডেটা সরানোর সময় ত্রুটি {0} ফাইল", + "ToastMetadataFilesRemovedNoneFound": "কোনো মেটাডেটা নেই।লাইব্রেরিতে {0} ফাইল পাওয়া গেছে", + "ToastMetadataFilesRemovedNoneRemoved": "কোনো মেটাডেটা নেই।{0} ফাইল সরানো হয়েছে", + "ToastMetadataFilesRemovedSuccess": "{0} মেটাডেটা৷{1} ফাইল সরানো হয়েছে", + "ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে", "ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক", "ToastNameRequired": "নাম আবশ্যক", + "ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে", "ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"", "ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে", "ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে", "ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে", "ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে", "ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন", + "ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি", "ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই", "ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ", "ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ", @@ -946,6 +1026,7 @@ "ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে", "ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি", "ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই", + "ToastProgressIsNotBeingSynced": "অগ্রগতি সিঙ্ক হচ্ছে না, প্লেব্যাক পুনরায় চালু করুন", "ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে", "ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে", "ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক", @@ -972,6 +1053,7 @@ "ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে", "ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ", "ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে", + "ToastSleepTimerDone": "স্লিপ টাইমার হয়ে গেছে... zZzzZz", "ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে", "ToastSlugRequired": "স্লাগ আবশ্যক", "ToastSocketConnected": "সকেট সংযুক্ত", From a5457d7e22238432f3cfc4064cb6bfec670f6777 Mon Sep 17 00:00:00 2001 From: Pierrick Guillaume Date: Mon, 25 Nov 2024 05:04:14 +0000 Subject: [PATCH 112/762] Translated using Weblate (French) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/fr.json b/client/strings/fr.json index a1f5c2c8..28cdd342 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée", "LabelUpdatedAt": "Mis à jour à", "LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers", + "LabelUploaderDragAndDropFilesOnly": "Glisser & déposer des fichiers", "LabelUploaderDropFiles": "Déposer des fichiers", "LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, l’auteur et la série", "LabelUseAdvancedOptions": "Utiliser les options avancées", From 1ff1ba66fdd57d8a109ac240fb988be5186ad887 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 25 Nov 2024 19:38:25 +0000 Subject: [PATCH 113/762] Translated using Weblate (Russian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/ru.json b/client/strings/ru.json index b0743af6..2ec2fbd1 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены", "LabelUpdatedAt": "Обновлено в", "LabelUploaderDragAndDrop": "Перетащите файлы или каталоги", + "LabelUploaderDragAndDropFilesOnly": "Перетаскивание файлов", "LabelUploaderDropFiles": "Перетащите файлы", "LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии", "LabelUseAdvancedOptions": "Используйте расширенные опции", From 31e302ea599e43a095cc76f8e219330b1b66eda2 Mon Sep 17 00:00:00 2001 From: Charlie Date: Fri, 29 Nov 2024 13:49:11 +0000 Subject: [PATCH 114/762] Translated using Weblate (French) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index 28cdd342..42dfefc5 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -870,10 +870,10 @@ "MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »", "MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »", "MessageTaskTargetDirectoryNotWritable": "Le répertoire cible n’est pas accessible en écriture", - "MessageThinking": "Je cherche…", + "MessageThinking": "À la recherche de…", "MessageUploaderItemFailed": "Échec du téléversement", "MessageUploaderItemSuccess": "Téléversement effectué !", - "MessageUploading": "Téléversement…", + "MessageUploading": "Téléchargement…", "MessageValidCronExpression": "Expression cron valide", "MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur", "MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !", From 468a5478642baec96abb3110c5bc1892eca893b7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 30 Nov 2024 16:26:48 -0600 Subject: [PATCH 115/762] Version bump v2.17.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 b8b17f3e..588ad79d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.2", + "version": "2.17.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.2", + "version": "2.17.3", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 924220e0..c1a43e52 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.2", + "version": "2.17.3", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 3f9f7a44..062fb032 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.2", + "version": "2.17.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.2", + "version": "2.17.3", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 8cbbb029..db63261b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.2", + "version": "2.17.3", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From c496db7c95752407164aa55d4dc6f35e207db9f1 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 1 Dec 2024 09:51:26 -0600 Subject: [PATCH 116/762] Fix:Remove authors with no books when a books is removed #3668 - Handles bulk delete, single delete, deleting library folder, and removing items with issues - Also handles bulk editing and removing authors --- server/controllers/LibraryController.js | 66 +++++++++- server/controllers/LibraryItemController.js | 127 +++++++++++++++----- server/routers/ApiRouter.js | 67 +++-------- 3 files changed, 177 insertions(+), 83 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 84d6193d..fc15488d 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -400,19 +400,48 @@ class LibraryController { model: Database.podcastEpisodeModel, attributes: ['id'] } + }, + { + model: Database.bookModel, + attributes: ['id'], + include: [ + { + model: Database.bookAuthorModel, + attributes: ['authorId'] + }, + { + model: Database.bookSeriesModel, + attributes: ['seriesId'] + } + ] } ] }) Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`) + const seriesIds = [] + const authorIds = [] for (const libraryItem of libraryItemsInFolder) { let mediaItemIds = [] if (req.library.isPodcast) { mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) } else { mediaItemIds.push(libraryItem.mediaId) + if (libraryItem.media.bookAuthors.length) { + authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId)) + } + if (libraryItem.media.bookSeries.length) { + seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId)) + } } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`) - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + } + + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) } // Remove folder @@ -501,7 +530,7 @@ class LibraryController { mediaItemIds.push(libraryItem.mediaId) } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`) - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) } // Set PlaybackSessions libraryId to null @@ -580,6 +609,8 @@ class LibraryController { * DELETE: /api/libraries/:id/issues * Remove all library items missing or invalid * + * @this {import('../routers/ApiRouter')} + * * @param {LibraryControllerRequest} req * @param {Response} res */ @@ -605,6 +636,20 @@ class LibraryController { model: Database.podcastEpisodeModel, attributes: ['id'] } + }, + { + model: Database.bookModel, + attributes: ['id'], + include: [ + { + model: Database.bookAuthorModel, + attributes: ['authorId'] + }, + { + model: Database.bookSeriesModel, + attributes: ['seriesId'] + } + ] } ] }) @@ -615,15 +660,30 @@ class LibraryController { } Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`) + const authorIds = [] + const seriesIds = [] for (const libraryItem of libraryItemsWithIssues) { let mediaItemIds = [] if (req.library.isPodcast) { mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) } else { mediaItemIds.push(libraryItem.mediaId) + if (libraryItem.media.bookAuthors.length) { + authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId)) + } + if (libraryItem.media.bookSeries.length) { + seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId)) + } } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`) - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + } + + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) } // Set numIssues to 0 for library filter data diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 64069ac5..92bc3833 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -96,6 +96,8 @@ class LibraryItemController { * Optional query params: * ?hard=1 * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -103,14 +105,36 @@ class LibraryItemController { const hardDelete = req.query.hard == 1 // Delete from file system const libraryItemPath = req.libraryItem.path - const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id] - await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds) + const mediaItemIds = [] + const authorIds = [] + const seriesIds = [] + if (req.libraryItem.isPodcast) { + mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id)) + } else { + mediaItemIds.push(req.libraryItem.media.id) + if (req.libraryItem.media.metadata.authors?.length) { + authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id)) + } + if (req.libraryItem.media.metadata.series?.length) { + seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id)) + } + } + + await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) }) } + + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) + } + await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId) res.sendStatus(200) } @@ -212,15 +236,6 @@ class LibraryItemController { if (hasUpdates) { libraryItem.updatedAt = Date.now() - if (seriesRemoved.length) { - // Check remove empty series - Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) - await this.checkRemoveEmptySeries( - libraryItem.media.id, - seriesRemoved.map((se) => se.id) - ) - } - if (isPodcastAutoDownloadUpdated) { this.cronManager.checkUpdatePodcastCron(libraryItem) } @@ -232,10 +247,12 @@ class LibraryItemController { if (authorsRemoved.length) { // Check remove empty authors Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`) - await this.checkRemoveAuthorsWithNoBooks( - libraryItem.libraryId, - authorsRemoved.map((au) => au.id) - ) + await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id)) + } + if (seriesRemoved.length) { + // Check remove empty series + Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`) + await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id)) } } res.json({ @@ -450,6 +467,8 @@ class LibraryItemController { * Optional query params: * ?hard=1 * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -477,14 +496,33 @@ class LibraryItemController { for (const libraryItem of itemsToDelete) { const libraryItemPath = libraryItem.path Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`) - const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id] - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + const mediaItemIds = [] + const seriesIds = [] + const authorIds = [] + if (libraryItem.isPodcast) { + mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id)) + } else { + mediaItemIds.push(libraryItem.media.id) + if (libraryItem.media.metadata.series?.length) { + seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id)) + } + if (libraryItem.media.metadata.authors?.length) { + authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id)) + } + } + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) }) } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) + } + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } } await Database.resetLibraryIssuesFilterData(libraryId) @@ -494,6 +532,8 @@ class LibraryItemController { /** * POST: /api/items/batch/update * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -503,39 +543,62 @@ class LibraryItemController { return res.sendStatus(500) } + // Ensure that each update payload has a unique library item id + const libraryItemIds = [...new Set(updatePayloads.map((up) => up.id))] + if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) { + Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`) + return res.sendStatus(400) + } + + // Get all library items to update + const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ + id: libraryItemIds + }) + if (updatePayloads.length !== libraryItems.length) { + Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`) + return res.sendStatus(404) + } + let itemsUpdated = 0 + const seriesIdsRemoved = [] + const authorIdsRemoved = [] + for (const updatePayload of updatePayloads) { const mediaPayload = updatePayload.mediaPayload - const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id) - if (!libraryItem) return null + const libraryItem = libraryItems.find((li) => li.id === updatePayload.id) await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) - let seriesRemoved = [] - if (libraryItem.isBook && mediaPayload.metadata?.series) { - const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id) - seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + if (libraryItem.isBook) { + if (Array.isArray(mediaPayload.metadata?.series)) { + const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id) + const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id)) + } + if (Array.isArray(mediaPayload.metadata?.authors)) { + const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) + const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) + authorIdsRemoved.push(...authorsRemoved.map((au) => au.id)) + } } if (libraryItem.media.update(mediaPayload)) { Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) - if (seriesRemoved.length) { - // Check remove empty series - Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) - await this.checkRemoveEmptySeries( - libraryItem.media.id, - seriesRemoved.map((se) => se.id) - ) - } - await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) itemsUpdated++ } } + if (seriesIdsRemoved.length) { + await this.checkRemoveEmptySeries(seriesIdsRemoved) + } + if (authorIdsRemoved.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved) + } + res.json({ success: true, updates: itemsUpdated diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 7f21c3ac..0657b389 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -348,11 +348,10 @@ class ApiRouter { // /** * Remove library item and associated entities - * @param {string} mediaType * @param {string} libraryItemId * @param {string[]} mediaItemIds array of bookId or podcastEpisodeId */ - async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) { + async handleDeleteLibraryItem(libraryItemId, mediaItemIds) { const numProgressRemoved = await Database.mediaProgressModel.destroy({ where: { mediaItemId: mediaItemIds @@ -362,29 +361,6 @@ class ApiRouter { Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`) } - // TODO: Remove open sessions for library item - - // Remove series if empty - if (mediaType === 'book') { - // TODO: update filter data - const bookSeries = await Database.bookSeriesModel.findAll({ - where: { - bookId: mediaItemIds[0] - }, - include: { - model: Database.seriesModel, - include: { - model: Database.bookModel - } - } - }) - for (const bs of bookSeries) { - if (bs.series.books.length === 1) { - await this.removeEmptySeries(bs.series) - } - } - } - // remove item from playlists const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds) for (const playlist of playlistsWithItem) { @@ -423,6 +399,7 @@ class ApiRouter { // purge cover cache await CacheManager.purgeCoverCache(libraryItemId) + // Remove metadata file if in /metadata/items dir const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) if (await fs.pathExists(itemMetadataPath)) { Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) @@ -437,32 +414,27 @@ class ApiRouter { } /** - * Used when a series is removed from a book - * Series is removed if it only has 1 book + * After deleting book(s), remove empty series * - * @param {string} bookId * @param {string[]} seriesIds */ - async checkRemoveEmptySeries(bookId, seriesIds) { + async checkRemoveEmptySeries(seriesIds) { if (!seriesIds?.length) return - const bookSeries = await Database.bookSeriesModel.findAll({ + const series = await Database.seriesModel.findAll({ where: { - bookId, - seriesId: seriesIds + id: seriesIds }, - include: [ - { - model: Database.seriesModel, - include: { - model: Database.bookModel - } - } - ] + attributes: ['id', 'name', 'libraryId'], + include: { + model: Database.bookModel, + attributes: ['id'] + } }) - for (const bs of bookSeries) { - if (bs.series.books.length === 1) { - await this.removeEmptySeries(bs.series) + + for (const s of series) { + if (!s.books.length) { + await this.removeEmptySeries(s) } } } @@ -471,11 +443,10 @@ class ApiRouter { * Remove authors with no books and unset asin, description and imagePath * Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged) * - * @param {string} libraryId * @param {string[]} authorIds * @returns {Promise} */ - async checkRemoveAuthorsWithNoBooks(libraryId, authorIds) { + async checkRemoveAuthorsWithNoBooks(authorIds) { if (!authorIds?.length) return const bookAuthorsToRemove = ( @@ -495,10 +466,10 @@ class ApiRouter { }, sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0) ], - attributes: ['id', 'name'], + attributes: ['id', 'name', 'libraryId'], raw: true }) - ).map((au) => ({ id: au.id, name: au.name })) + ).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId })) if (bookAuthorsToRemove.length) { await Database.authorModel.destroy({ @@ -506,7 +477,7 @@ class ApiRouter { id: bookAuthorsToRemove.map((au) => au.id) } }) - bookAuthorsToRemove.forEach(({ id, name }) => { + bookAuthorsToRemove.forEach(({ id, name, libraryId }) => { Database.removeAuthorFromFilterData(libraryId, id) // TODO: Clients were expecting full author in payload but its unnecessary SocketAuthority.emitter('author_removed', { id, libraryId }) From 2b5484243b649ed2dac3c996eb1d9b8e907b4c4a Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 1 Dec 2024 12:44:21 -0600 Subject: [PATCH 117/762] Add LibraryItemController test for delete/batchDelete/updateMedia endpoint functions to correctly remove authors & series with no books --- server/managers/CacheManager.js | 1 + server/objects/LibraryItem.js | 2 +- server/routers/ApiRouter.js | 10 +- .../controllers/LibraryItemController.test.js | 202 ++++++++++++++++++ 4 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 test/server/controllers/LibraryItemController.test.js diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index f0375691..b44b65de 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -86,6 +86,7 @@ class CacheManager { } async purgeEntityCache(entityId, cachePath) { + if (!entityId || !cachePath) return [] return Promise.all( (await fs.readdir(cachePath)).reduce((promises, file) => { if (file.startsWith(entityId)) { diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 0259ee4c..84a37897 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -262,7 +262,7 @@ class LibraryItem { * @returns {Promise} null if not saved */ async saveMetadata() { - if (this.isSavingMetadata) return null + if (this.isSavingMetadata || !global.MetadataPath) return null this.isSavingMetadata = true diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 0657b389..a92796e8 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -400,10 +400,12 @@ class ApiRouter { await CacheManager.purgeCoverCache(libraryItemId) // Remove metadata file if in /metadata/items dir - const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) - if (await fs.pathExists(itemMetadataPath)) { - Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) - await fs.remove(itemMetadataPath) + if (global.MetadataPath) { + const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) + if (await fs.pathExists(itemMetadataPath)) { + Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) + await fs.remove(itemMetadataPath) + } } await Database.libraryItemModel.removeById(libraryItemId) diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js new file mode 100644 index 00000000..3e7c58b2 --- /dev/null +++ b/test/server/controllers/LibraryItemController.test.js @@ -0,0 +1,202 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') +const sinon = require('sinon') + +const Database = require('../../../server/Database') +const ApiRouter = require('../../../server/routers/ApiRouter') +const LibraryItemController = require('../../../server/controllers/LibraryItemController') +const ApiCacheManager = require('../../../server/managers/ApiCacheManager') +const RssFeedManager = require('../../../server/managers/RssFeedManager') +const Logger = require('../../../server/Logger') + +describe('LibraryItemController', () => { + /** @type {ApiRouter} */ + let apiRouter + + beforeEach(async () => { + global.ServerSettings = {} + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + + apiRouter = new ApiRouter({ + apiCacheManager: new ApiCacheManager(), + rssFeedManager: new RssFeedManager() + }) + + sinon.stub(Logger, 'info') + }) + + afterEach(async () => { + sinon.restore() + + // Clear all tables + await Database.sequelize.sync({ force: true }) + }) + + describe('checkRemoveAuthorsAndSeries', () => { + let libraryItem1Id + let libraryItem2Id + let author1Id + let author2Id + let author3Id + let series1Id + let series2Id + + beforeEach(async () => { + const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id }) + + const newBook = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const newLibraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id }) + libraryItem1Id = newLibraryItem.id + + const newBook2 = await Database.bookModel.create({ title: 'Test Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const newLibraryItem2 = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook2.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id }) + libraryItem2Id = newLibraryItem2.id + + const newAuthor = await Database.authorModel.create({ name: 'Test Author', libraryId: newLibrary.id }) + author1Id = newAuthor.id + const newAuthor2 = await Database.authorModel.create({ name: 'Test Author 2', libraryId: newLibrary.id }) + author2Id = newAuthor2.id + const newAuthor3 = await Database.authorModel.create({ name: 'Test Author 3', imagePath: '/fake/path/author.png', libraryId: newLibrary.id }) + author3Id = newAuthor3.id + + // Book 1 has Author 1, Author 2 and Author 3 + await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor.id }) + await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor2.id }) + await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor3.id }) + + // Book 2 has Author 2 + await Database.bookAuthorModel.create({ bookId: newBook2.id, authorId: newAuthor2.id }) + + const newSeries = await Database.seriesModel.create({ name: 'Test Series', libraryId: newLibrary.id }) + series1Id = newSeries.id + const newSeries2 = await Database.seriesModel.create({ name: 'Test Series 2', libraryId: newLibrary.id }) + series2Id = newSeries2.id + + // Book 1 is in Series 1 and Series 2 + await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries.id }) + await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries2.id }) + + // Book 2 is in Series 2 + await Database.bookSeriesModel.create({ bookId: newBook2.id, seriesId: newSeries2.id }) + }) + + it('should remove authors and series with no books on library item delete', async () => { + const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id) + + const fakeReq = { + query: {}, + libraryItem: oldLibraryItem + } + const fakeRes = { + sendStatus: sinon.spy() + } + await LibraryItemController.delete.bind(apiRouter)(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(200)).to.be.true + + // Author 1 should be removed because it has no books + const author1Exists = await Database.authorModel.checkExistsById(author1Id) + expect(author1Exists).to.be.false + + // Author 2 should not be removed because it still has Book 2 + const author2Exists = await Database.authorModel.checkExistsById(author2Id) + expect(author2Exists).to.be.true + + // Author 3 should not be removed because it has an image + const author3Exists = await Database.authorModel.checkExistsById(author3Id) + expect(author3Exists).to.be.true + + // Series 1 should be removed because it has no books + const series1Exists = await Database.seriesModel.checkExistsById(series1Id) + expect(series1Exists).to.be.false + + // Series 2 should not be removed because it still has Book 2 + const series2Exists = await Database.seriesModel.checkExistsById(series2Id) + expect(series2Exists).to.be.true + }) + + it('should remove authors and series with no books on library item batch delete', async () => { + // Batch delete library item 1 + const fakeReq = { + query: {}, + user: { + canDelete: true + }, + body: { + libraryItemIds: [libraryItem1Id] + } + } + const fakeRes = { + sendStatus: sinon.spy() + } + await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(200)).to.be.true + + // Author 1 should be removed because it has no books + const author1Exists = await Database.authorModel.checkExistsById(author1Id) + expect(author1Exists).to.be.false + + // Author 2 should not be removed because it still has Book 2 + const author2Exists = await Database.authorModel.checkExistsById(author2Id) + expect(author2Exists).to.be.true + + // Author 3 should not be removed because it has an image + const author3Exists = await Database.authorModel.checkExistsById(author3Id) + expect(author3Exists).to.be.true + + // Series 1 should be removed because it has no books + const series1Exists = await Database.seriesModel.checkExistsById(series1Id) + expect(series1Exists).to.be.false + + // Series 2 should not be removed because it still has Book 2 + const series2Exists = await Database.seriesModel.checkExistsById(series2Id) + expect(series2Exists).to.be.true + }) + + it('should remove authors and series with no books on library item update media', async () => { + const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id) + + // Update library item 1 remove all authors and series + const fakeReq = { + query: {}, + body: { + metadata: { + authors: [], + series: [] + } + }, + libraryItem: oldLibraryItem + } + const fakeRes = { + json: sinon.spy() + } + await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + + // Author 1 should be removed because it has no books + const author1Exists = await Database.authorModel.checkExistsById(author1Id) + expect(author1Exists).to.be.false + + // Author 2 should not be removed because it still has Book 2 + const author2Exists = await Database.authorModel.checkExistsById(author2Id) + expect(author2Exists).to.be.true + + // Author 3 should not be removed because it has an image + const author3Exists = await Database.authorModel.checkExistsById(author3Id) + expect(author3Exists).to.be.true + + // Series 1 should be removed because it has no books + const series1Exists = await Database.seriesModel.checkExistsById(series1Id) + expect(series1Exists).to.be.false + + // Series 2 should not be removed because it still has Book 2 + const series2Exists = await Database.seriesModel.checkExistsById(series2Id) + expect(series2Exists).to.be.true + }) + }) +}) From 0dedb09a07c3286d2383892520c107dfb06603c2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 1 Dec 2024 12:49:39 -0600 Subject: [PATCH 118/762] Update:batchUpdate endpoint validate req.body is an array of objects --- server/controllers/LibraryItemController.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 92bc3833..5aaacee0 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -539,12 +539,13 @@ class LibraryItemController { */ async batchUpdate(req, res) { const updatePayloads = req.body - if (!updatePayloads?.length) { - return res.sendStatus(500) + if (!Array.isArray(updatePayloads) || !updatePayloads.length) { + Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`) + return res.sendStatus(400) } // Ensure that each update payload has a unique library item id - const libraryItemIds = [...new Set(updatePayloads.map((up) => up.id))] + const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))] if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) { Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`) return res.sendStatus(400) From a03146e09c7ba04311a0e8e14765809cce151630 Mon Sep 17 00:00:00 2001 From: Techwolf Date: Sun, 1 Dec 2024 18:10:44 -0800 Subject: [PATCH 119/762] Support additional disc folder names --- server/utils/scandir.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index ff21e814..27cfe003 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -96,7 +96,7 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) { // This is the last directory, create group itemGroup[_path] = [Path.basename(path)] return - } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { + } else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] return From cc89db059bdd8ed3595f4846a78d5f843f2cdefa Mon Sep 17 00:00:00 2001 From: Techwolf Date: Sun, 1 Dec 2024 18:41:38 -0800 Subject: [PATCH 120/762] Fix second instance of regex --- server/utils/scandir.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 27cfe003..028a1022 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -179,7 +179,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly // This is the last directory, create group libraryItemGroup[_path] = [item.name] return - } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { + } else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)] return From 605bd73c11aa2b79552a1da26f6c29ff904b899a Mon Sep 17 00:00:00 2001 From: Techwolf Date: Sun, 1 Dec 2024 23:57:47 -0800 Subject: [PATCH 121/762] Fix third instance of regex --- server/scanner/AudioFileScanner.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/scanner/AudioFileScanner.js b/server/scanner/AudioFileScanner.js index 3c364c10..6c808aaa 100644 --- a/server/scanner/AudioFileScanner.js +++ b/server/scanner/AudioFileScanner.js @@ -133,8 +133,8 @@ class AudioFileScanner { // Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3 const pathdir = Path.dirname(path).split('/').pop() - if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) { - const discFromFolder = Number(pathdir.replace(/cd/i, '')) + if (pathdir && /^(cd|dis[ck])\s*\d{1,3}$/i.test(pathdir)) { + const discFromFolder = Number(pathdir.replace(/^(cd|dis[ck])\s*/i, '')) if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder } From 84803cef82226ca3382dc9a76cc5a42292720c76 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 2 Dec 2024 17:23:25 -0600 Subject: [PATCH 122/762] Fix:Load year in review stats for playback sessions with null mediaMetadata --- server/utils/queries/adminStats.js | 57 +++++++++++++++++------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/server/utils/queries/adminStats.js b/server/utils/queries/adminStats.js index 0c490de4..9d7f572a 100644 --- a/server/utils/queries/adminStats.js +++ b/server/utils/queries/adminStats.js @@ -5,7 +5,7 @@ const fsExtra = require('../../libs/fsExtra') module.exports = { /** - * + * * @param {number} year YYYY * @returns {Promise} */ @@ -22,7 +22,7 @@ module.exports = { }, /** - * + * * @param {number} year YYYY * @returns {Promise} */ @@ -39,7 +39,7 @@ module.exports = { }, /** - * + * * @param {number} year YYYY * @returns {Promise} */ @@ -63,7 +63,7 @@ module.exports = { }, /** - * + * * @param {number} year YYYY */ async getStatsForYear(year) { @@ -75,7 +75,7 @@ module.exports = { for (const book of booksAdded) { // Grab first 25 that have a cover - if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) { + if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && (await fsExtra.pathExists(book.coverPath))) { booksWithCovers.push(book.libraryItem.id) } if (book.duration && !isNaN(book.duration)) { @@ -95,45 +95,54 @@ module.exports = { const listeningSessions = await this.getListeningSessionsForYear(year) let totalListeningTime = 0 for (const ls of listeningSessions) { - totalListeningTime += (ls.timeListening || 0) + totalListeningTime += ls.timeListening || 0 - const authors = ls.mediaMetadata.authors || [] + const authors = ls.mediaMetadata?.authors || [] authors.forEach((au) => { if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0 - authorListeningMap[au.name] += (ls.timeListening || 0) + authorListeningMap[au.name] += ls.timeListening || 0 }) - const narrators = ls.mediaMetadata.narrators || [] + const narrators = ls.mediaMetadata?.narrators || [] narrators.forEach((narrator) => { if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0 - narratorListeningMap[narrator] += (ls.timeListening || 0) + narratorListeningMap[narrator] += ls.timeListening || 0 }) // Filter out bad genres like "audiobook" and "audio book" - const genres = (ls.mediaMetadata.genres || []).filter(g => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) + const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) genres.forEach((genre) => { if (!genreListeningMap[genre]) genreListeningMap[genre] = 0 - genreListeningMap[genre] += (ls.timeListening || 0) + genreListeningMap[genre] += ls.timeListening || 0 }) } let topAuthors = null - topAuthors = Object.keys(authorListeningMap).map(authorName => ({ - name: authorName, - time: Math.round(authorListeningMap[authorName]) - })).sort((a, b) => b.time - a.time).slice(0, 3) + topAuthors = Object.keys(authorListeningMap) + .map((authorName) => ({ + name: authorName, + time: Math.round(authorListeningMap[authorName]) + })) + .sort((a, b) => b.time - a.time) + .slice(0, 3) let topNarrators = null - topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({ - name: narratorName, - time: Math.round(narratorListeningMap[narratorName]) - })).sort((a, b) => b.time - a.time).slice(0, 3) + topNarrators = Object.keys(narratorListeningMap) + .map((narratorName) => ({ + name: narratorName, + time: Math.round(narratorListeningMap[narratorName]) + })) + .sort((a, b) => b.time - a.time) + .slice(0, 3) let topGenres = null - topGenres = Object.keys(genreListeningMap).map(genre => ({ - genre, - time: Math.round(genreListeningMap[genre]) - })).sort((a, b) => b.time - a.time).slice(0, 3) + topGenres = Object.keys(genreListeningMap) + .map((genre) => ({ + genre, + time: Math.round(genreListeningMap[genre]) + })) + .sort((a, b) => b.time - a.time) + .slice(0, 3) // Stats for total books, size and duration for everything added this year or earlier const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, { From 615ed26f0ffb8b2af9517d08a3e57208db99f243 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 2 Dec 2024 17:35:35 -0600 Subject: [PATCH 123/762] Update:Users table show count next to header --- client/components/tables/UsersTable.vue | 1 + client/pages/config/users/index.vue | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue index 92fa684e..09db3341 100644 --- a/client/components/tables/UsersTable.vue +++ b/client/components/tables/UsersTable.vue @@ -120,6 +120,7 @@ export default { this.users = res.users.sort((a, b) => { return a.createdAt - b.createdAt }) + this.$emit('numUsers', this.users.length) }) .catch((error) => { console.error('Failed', error) diff --git a/client/pages/config/users/index.vue b/client/pages/config/users/index.vue index 4dd82591..184529cb 100644 --- a/client/pages/config/users/index.vue +++ b/client/pages/config/users/index.vue @@ -2,6 +2,10 @@
- +
@@ -29,7 +33,8 @@ export default { data() { return { selectedAccount: null, - showAccountModal: false + showAccountModal: false, + numUsers: 0 } }, computed: {}, From 0f1b64b883479401d09ad3f45c76a976e1af4211 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 3 Dec 2024 17:21:57 -0600 Subject: [PATCH 124/762] Add test for grouping book library items --- test/server/utils/scandir.test.js | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 test/server/utils/scandir.test.js diff --git a/test/server/utils/scandir.test.js b/test/server/utils/scandir.test.js new file mode 100644 index 00000000..a5ff6ae0 --- /dev/null +++ b/test/server/utils/scandir.test.js @@ -0,0 +1,52 @@ +const Path = require('path') +const chai = require('chai') +const expect = chai.expect +const scanUtils = require('../../../server/utils/scandir') + +describe('scanUtils', async () => { + it('should properly group files into potential book library items', async () => { + global.isWin = process.platform === 'win32' + global.ServerSettings = { + scannerParseSubtitle: true + } + + const filePaths = [ + 'randomfile.txt', // Should be ignored because it's not a book media file + 'Book1.m4b', // Root single file audiobook + 'Book2/audiofile.m4b', + 'Book2/disk 001/audiofile.m4b', + 'Book2/disk 002/audiofile.m4b', + 'Author/Book3/audiofile.mp3', + 'Author/Book3/Disc 1/audiofile.mp3', + 'Author/Book3/Disc 2/audiofile.mp3', + 'Author/Series/Book4/cover.jpg', + 'Author/Series/Book4/CD1/audiofile.mp3', + 'Author/Series/Book4/CD2/audiofile.mp3', + 'Author/Series2/Book5/deeply/nested/cd 01/audiofile.mp3', + 'Author/Series2/Book5/deeply/nested/cd 02/audiofile.mp3', + 'Author/Series2/Book5/randomfile.js' // Should be ignored because it's not a book media file + ] + + // Create fileItems to match the format of fileUtils.recurseFiles + const fileItems = [] + for (const filePath of filePaths) { + const dirname = Path.dirname(filePath) + fileItems.push({ + name: Path.basename(filePath), + reldirpath: dirname === '.' ? '' : dirname, + extension: Path.extname(filePath), + deep: filePath.split('/').length - 1 + }) + } + + const libraryItemGrouping = scanUtils.groupFileItemsIntoLibraryItemDirs('book', fileItems, false) + + expect(libraryItemGrouping).to.deep.equal({ + 'Book1.m4b': 'Book1.m4b', + Book2: ['audiofile.m4b', 'disk 001/audiofile.m4b', 'disk 002/audiofile.m4b'], + 'Author/Book3': ['audiofile.mp3', 'Disc 1/audiofile.mp3', 'Disc 2/audiofile.mp3'], + 'Author/Series/Book4': ['CD1/audiofile.mp3', 'CD2/audiofile.mp3', 'cover.jpg'], + 'Author/Series2/Book5/deeply/nested': ['cd 01/audiofile.mp3', 'cd 02/audiofile.mp3'] + }) + }) +}) From 344890fb45e9cbf9c3421b97007dc99e6c5b24c0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 4 Dec 2024 16:25:17 -0600 Subject: [PATCH 125/762] Update watcher files changed function to use the same grouping function as other scans --- server/scanner/LibraryScanner.js | 4 +- server/utils/fileUtils.js | 33 +++++++++- server/utils/scandir.js | 101 ------------------------------- 3 files changed, 33 insertions(+), 105 deletions(-) diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index bd0bb310..a52350f6 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -424,8 +424,8 @@ class LibraryScanner { } const folder = library.libraryFolders[0] - const relFilePaths = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUpdate.relPath) - const fileUpdateGroup = scanUtils.groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths) + const filePathItems = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUtils.getFilePathItemFromFileUpdate(fileUpdate)) + const fileUpdateGroup = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, filePathItems, !!library.settings?.audiobooksOnly) if (!Object.keys(fileUpdateGroup).length) { Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index b0c73d6c..8b87d3a0 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -131,11 +131,21 @@ async function readTextFile(path) { } module.exports.readTextFile = readTextFile +/** + * @typedef FilePathItem + * @property {string} name - file name e.g. "audiofile.m4b" + * @property {string} path - fullpath excluding folder e.g. "Author/Book/audiofile.m4b" + * @property {string} reldirpath - path excluding file name e.g. "Author/Book" + * @property {string} fullpath - full path e.g. "/audiobooks/Author/Book/audiofile.m4b" + * @property {string} extension - file extension e.g. ".m4b" + * @property {number} deep - depth of file in directory (0 is file in folder root) + */ + /** * Get array of files inside dir * @param {string} path * @param {string} [relPathToReplace] - * @returns {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} + * @returns {FilePathItem[]} */ async function recurseFiles(path, relPathToReplace = null) { path = filePathToPOSIX(path) @@ -213,7 +223,6 @@ async function recurseFiles(path, relPathToReplace = null) { return { name: item.name, path: item.fullname.replace(relPathToReplace, ''), - dirpath: item.path, reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''), fullpath: item.fullname, extension: item.extension, @@ -228,6 +237,26 @@ async function recurseFiles(path, relPathToReplace = null) { } module.exports.recurseFiles = recurseFiles +/** + * + * @param {import('../Watcher').PendingFileUpdate} fileUpdate + * @returns {FilePathItem} + */ +module.exports.getFilePathItemFromFileUpdate = (fileUpdate) => { + let relPath = fileUpdate.relPath + if (relPath.startsWith('/')) relPath = relPath.slice(1) + + const dirname = Path.dirname(relPath) + return { + name: Path.basename(relPath), + path: relPath, + reldirpath: dirname === '.' ? '' : dirname, + fullpath: fileUpdate.path, + extension: Path.extname(relPath), + deep: relPath.split('/').length - 1 + } +} + /** * Download file from web to local file system * Uses SSRF filter to prevent internal URLs diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 028a1022..a70e09bb 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -32,107 +32,6 @@ function checkFilepathIsAudioFile(filepath) { } module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile -/** - * TODO: Function needs to be re-done - * @param {string} mediaType - * @param {string[]} paths array of relative file paths - * @returns {Record} map of files grouped into potential libarary item dirs - */ -function groupFilesIntoLibraryItemPaths(mediaType, paths) { - // Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir - var nonMediaFilePaths = [] - var pathsFiltered = paths - .map((path) => { - return path.startsWith('/') ? path.slice(1) : path - }) - .filter((path) => { - let parsedPath = Path.parse(path) - // Is not in root dir OR is a book media file - if (parsedPath.dir) { - if (!isMediaFile(mediaType, parsedPath.ext, false)) { - // Seperate out non-media files - nonMediaFilePaths.push(path) - return false - } - return true - } else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext, false)) { - // (book media type supports single file audiobooks/ebooks in root dir) - return true - } - return false - }) - - // Step 2: Sort by least number of directories - pathsFiltered.sort((a, b) => { - var pathsA = Path.dirname(a).split('/').length - var pathsB = Path.dirname(b).split('/').length - return pathsA - pathsB - }) - - // Step 3: Group files in dirs - var itemGroup = {} - pathsFiltered.forEach((path) => { - var dirparts = Path.dirname(path) - .split('/') - .filter((p) => !!p && p !== '.') // dirname returns . if no directory - var numparts = dirparts.length - var _path = '' - - if (!numparts) { - // Media file in root - itemGroup[path] = path - } else { - // Iterate over directories in path - for (let i = 0; i < numparts; i++) { - var dirpart = dirparts.shift() - _path = Path.posix.join(_path, dirpart) - - if (itemGroup[_path]) { - // Directory already has files, add file - var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path)) - itemGroup[_path].push(relpath) - return - } else if (!dirparts.length) { - // This is the last directory, create group - itemGroup[_path] = [Path.basename(path)] - return - } else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) { - // Next directory is the last and is a CD dir, create group - itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] - return - } - } - } - }) - - // Step 4: Add in non-media files if they fit into item group - if (nonMediaFilePaths.length) { - for (const nonMediaFilePath of nonMediaFilePaths) { - const pathDir = Path.dirname(nonMediaFilePath) - const filename = Path.basename(nonMediaFilePath) - const dirparts = pathDir.split('/') - const numparts = dirparts.length - let _path = '' - - // Iterate over directories in path - for (let i = 0; i < numparts; i++) { - const dirpart = dirparts.shift() - _path = Path.posix.join(_path, dirpart) - if (itemGroup[_path]) { - // Directory is a group - const relpath = Path.posix.join(dirparts.join('/'), filename) - itemGroup[_path].push(relpath) - } else if (!dirparts.length) { - itemGroup[_path] = [filename] - } - } - } - } - - return itemGroup -} -module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths - /** * @param {string} mediaType * @param {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} fileItems (see recurseFiles) From 9774b2cfa50b235a17406e0985723d3454f31433 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 4 Dec 2024 16:30:35 -0600 Subject: [PATCH 126/762] Update JSDocs for groupFileItemsIntoLibraryItemDirs --- server/utils/scandir.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index a70e09bb..f59d0a5b 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -34,7 +34,7 @@ module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile /** * @param {string} mediaType - * @param {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} fileItems (see recurseFiles) + * @param {import('./fileUtils').FilePathItem[]} fileItems * @param {boolean} [audiobooksOnly=false] * @returns {Record} map of files grouped into potential libarary item dirs */ @@ -46,7 +46,9 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly // Step 2: Seperate media files and other files // - Directories without a media file will not be included + /** @type {import('./fileUtils').FilePathItem[]} */ const mediaFileItems = [] + /** @type {import('./fileUtils').FilePathItem[]} */ const otherFileItems = [] itemsFiltered.forEach((item) => { if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item) From c35185fff722706d629cd56b806a4e2a735cd791 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 5 Dec 2024 16:15:23 -0600 Subject: [PATCH 127/762] Update prober to accept grp1 as an alternative tag to grouping #3681 --- server/utils/prober.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/prober.js b/server/utils/prober.js index b54b981d..838899bd 100644 --- a/server/utils/prober.js +++ b/server/utils/prober.js @@ -189,7 +189,7 @@ function parseTags(format, verbose) { file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'), file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'), file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'), - file_tag_grouping: tryGrabTags(format, 'grouping'), + file_tag_grouping: tryGrabTags(format, 'grouping', 'grp1'), file_tag_isbn: tryGrabTags(format, 'isbn'), // custom file_tag_language: tryGrabTags(format, 'language', 'lang'), file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom From 252a233282b5001e38e5c222f02c78ede3e9adc3 Mon Sep 17 00:00:00 2001 From: Henning Date: Mon, 2 Dec 2024 10:46:18 +0000 Subject: [PATCH 128/762] Translated using Weblate (German) Currently translated at 100.0% (1072 of 1072 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 030f8f1b..1ea58b5b 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -728,7 +728,7 @@ "MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis /metadata/cache löschen.

Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?", "MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter /metadata/cache/items gelöscht.
Bist du dir sicher?", "MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt.

Möchtest du fortfahren?", - "MessageConfirmQuickMatchEpisodes": "Schnelles Zuordnen von Episoden überschreibt die Details, wenn eine Übereinstimmung gefunden wird. Nur nicht zugeordnete Episoden werden aktualisiert. Bist du sicher?", + "MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?", "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?", "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", From 68413ae2f62e86d8ffa946877fb6a8fede43c56b Mon Sep 17 00:00:00 2001 From: thehijacker Date: Mon, 2 Dec 2024 06:00:11 +0000 Subject: [PATCH 129/762] Translated using Weblate (Slovenian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index 02c1fb13..e80ac8b2 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -184,7 +184,7 @@ "HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod", "HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice", "HeaderSession": "Seja", - "HeaderSetBackupSchedule": "Nastavite urnik varnostnega kopiranja", + "HeaderSetBackupSchedule": "Nastavi urnik varnostnega kopiranja", "HeaderSettings": "Nastavitve", "HeaderSettingsDisplay": "Zaslon", "HeaderSettingsExperimental": "Eksperimentalne funkcije", @@ -830,7 +830,7 @@ "MessageSearchResultsFor": "Rezultati iskanja za", "MessageSelected": "{0} izbrano", "MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči", - "MessageSetChaptersFromTracksDescription": "Nastavite poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke", + "MessageSetChaptersFromTracksDescription": "Nastavi poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke", "MessageShareExpirationWillBe": "Potečeno bo {0}", "MessageShareExpiresIn": "Poteče čez {0}", "MessageShareURLWillBe": "URL za skupno rabo bo {0}", From cbee6d8f5e74d2518ad27125314c495ec109caba Mon Sep 17 00:00:00 2001 From: Mario Date: Mon, 2 Dec 2024 12:01:08 +0000 Subject: [PATCH 130/762] Translated using Weblate (German) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 1ea58b5b..7f78360c 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -728,7 +728,7 @@ "MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis /metadata/cache löschen.

Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?", "MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter /metadata/cache/items gelöscht.
Bist du dir sicher?", "MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt.

Möchtest du fortfahren?", - "MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?", + "MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details, wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?", "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?", "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", @@ -833,7 +833,7 @@ "MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird", "MessageShareExpirationWillBe": "Läuft am {0} ab", "MessageShareExpiresIn": "Läuft in {0} ab", - "MessageShareURLWillBe": "Der Freigabe Link wird {0} sein.", + "MessageShareURLWillBe": "Der Freigabe Link wird {0} sein", "MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?", "MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt", "MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen", @@ -1041,7 +1041,7 @@ "ToastRenameFailed": "Umbenennen fehlgeschlagen", "ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}", "ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt", - "ToastRescanUpToDate": "Erneut scannen erledigt, Artikel wahr auf dem neusten Stand", + "ToastRescanUpToDate": "Erneut scannen erledigt, Artikel war auf dem neusten Stand", "ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert", "ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek", "ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus", From 658ac042685690d18f39678b4667d4b24700781a Mon Sep 17 00:00:00 2001 From: Mario Date: Tue, 3 Dec 2024 14:09:47 +0000 Subject: [PATCH 131/762] Translated using Weblate (German) Currently translated at 100.0% (1072 of 1072 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 7f78360c..865065aa 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -584,7 +584,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet", "LabelSettingsTimeFormat": "Zeitformat", "LabelShare": "Freigeben", - "LabelShareOpen": "Freigabe", + "LabelShareOpen": "Freigeben", "LabelShareURL": "Freigabe URL", "LabelShowAll": "Alles anzeigen", "LabelShowSeconds": "Zeige Sekunden", From 079a15541c6393f727f3684b32f25dc8f7f3e729 Mon Sep 17 00:00:00 2001 From: Milo Ivir Date: Tue, 3 Dec 2024 16:35:13 +0000 Subject: [PATCH 132/762] Translated using Weblate (Croatian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index a7f2562b..6ed299fb 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -532,7 +532,7 @@ "LabelSelectAllEpisodes": "Označi sve nastavke", "LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka", "LabelSelectUsers": "Označi korisnike", - "LabelSendEbookToDevice": "Pošalji e-knjigu", + "LabelSendEbookToDevice": "Pošalji e-knjigu …", "LabelSequence": "Slijed", "LabelSerial": "Serijal", "LabelSeries": "Serijal", From 67952cc57732317cb39d104053c9e829e8155ce3 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Wed, 4 Dec 2024 10:06:23 +0000 Subject: [PATCH 133/762] Translated using Weblate (Spanish) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index 76a62c16..87956e54 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Ver los ajustes del reproductor", "LabelViewQueue": "Ver Fila del Reproductor", "LabelVolume": "Volumen", + "LabelWebRedirectURLsDescription": "Autorice estas URL en su proveedor OAuth para permitir la redirección a la aplicación web después de iniciar sesión:", + "LabelWebRedirectURLsSubfolder": "Subcarpeta para URL de redireccionamiento", "LabelWeekdaysToRun": "Correr en Días de la Semana", "LabelXBooks": "{0} libros", "LabelXItems": "{0} elementos", From 867354e59d12c5cfa107af1af30f08fd59b8e945 Mon Sep 17 00:00:00 2001 From: Milo Ivir Date: Wed, 4 Dec 2024 20:56:24 +0000 Subject: [PATCH 134/762] Translated using Weblate (Croatian) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index 6ed299fb..48d9b5a0 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -271,7 +271,7 @@ "LabelCollapseSubSeries": "Podserijale prikaži sažeto", "LabelCollection": "Zbirka", "LabelCollections": "Zbirke", - "LabelComplete": "Dovršeno", + "LabelComplete": "Potpuno", "LabelConfirmPassword": "Potvrda zaporke", "LabelContinueListening": "Nastavi slušati", "LabelContinueReading": "Nastavi čitati", @@ -567,7 +567,7 @@ "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)", "LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal", - "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako se ova opcija uključi serijal će nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.", "LabelSettingsParseSubtitles": "Raščlani podnaslove", "LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.
Podnaslov mora biti odvojen s \" - \"
npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"", "LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki", @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Pogledaj postavke reproduktora", "LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora", "LabelVolume": "Glasnoća", + "LabelWebRedirectURLsDescription": "Autoriziraj ove URL-ove u svom pružatelju OAuth ovjere kako bi omogućio preusmjeravanje natrag na web-aplikaciju nakon prijave:", + "LabelWebRedirectURLsSubfolder": "Podmapa za URL-ove preusmjeravanja", "LabelWeekdaysToRun": "Dani u tjednu za pokretanje", "LabelXBooks": "{0} knjiga", "LabelXItems": "{0} stavki", From f467c44543c6e1a43b688086c778e6df4abb8941 Mon Sep 17 00:00:00 2001 From: Tamanegii Date: Wed, 4 Dec 2024 06:13:20 +0000 Subject: [PATCH 135/762] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.9% (1073 of 1074 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, 2 insertions(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 072cbd39..db262448 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息", "LabelUpdatedAt": "更新时间", "LabelUploaderDragAndDrop": "拖放文件或文件夹", + "LabelUploaderDragAndDropFilesOnly": "拖放文件", "LabelUploaderDropFiles": "删除文件", "LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列", "LabelUseAdvancedOptions": "使用高级选项", @@ -678,6 +679,7 @@ "LabelViewPlayerSettings": "查看播放器设置", "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", + "LabelWebRedirectURLsDescription": "在您的OAuth提供商中授权这些链接,以允许在登录后重定向回Web应用", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", "LabelXItems": "{0} 项目", From 7334580c8c5221ea82adb01070d82d5c2367af62 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Thu, 5 Dec 2024 13:19:57 +0000 Subject: [PATCH 136/762] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1074 of 1074 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 db262448..23137053 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -680,6 +680,7 @@ "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", "LabelWebRedirectURLsDescription": "在您的OAuth提供商中授权这些链接,以允许在登录后重定向回Web应用", + "LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", "LabelXItems": "{0} 项目", From 14f60a593b14c3473140603fc4a3eea4dd446d00 Mon Sep 17 00:00:00 2001 From: Tamanegii Date: Thu, 5 Dec 2024 13:20:37 +0000 Subject: [PATCH 137/762] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1074 of 1074 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 23137053..a277ecfd 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -680,7 +680,7 @@ "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", "LabelWebRedirectURLsDescription": "在您的OAuth提供商中授权这些链接,以允许在登录后重定向回Web应用", - "LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹", + "LabelWebRedirectURLsSubfolder": "查了一下GPT,给的回答的{重定向URL的子文件夹},但是不知道这个位置在哪儿,没法确定这个意思是否准确", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", "LabelXItems": "{0} 项目", From 259d93d8827ad2c6dd202ecee77a09378f4006ec Mon Sep 17 00:00:00 2001 From: SunSpring Date: Thu, 5 Dec 2024 13:22:25 +0000 Subject: [PATCH 138/762] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1074 of 1074 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 a277ecfd..6eea0a60 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -679,7 +679,7 @@ "LabelViewPlayerSettings": "查看播放器设置", "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", - "LabelWebRedirectURLsDescription": "在您的OAuth提供商中授权这些链接,以允许在登录后重定向回Web应用", + "LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:", "LabelWebRedirectURLsSubfolder": "查了一下GPT,给的回答的{重定向URL的子文件夹},但是不知道这个位置在哪儿,没法确定这个意思是否准确", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", From 1ff79520743558569a1b8997e6588ea233c479db Mon Sep 17 00:00:00 2001 From: SunSpring Date: Thu, 5 Dec 2024 13:23:34 +0000 Subject: [PATCH 139/762] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1074 of 1074 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 6eea0a60..e4791aff 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -680,7 +680,7 @@ "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", "LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:", - "LabelWebRedirectURLsSubfolder": "查了一下GPT,给的回答的{重定向URL的子文件夹},但是不知道这个位置在哪儿,没法确定这个意思是否准确", + "LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", "LabelXItems": "{0} 项目", From 890b0b949ee758102fd05ba26c5ed5c3ebbd747f Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 5 Dec 2024 16:50:30 -0600 Subject: [PATCH 140/762] Version bump v2.17.4 --- 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 588ad79d..e4e3236c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.3", + "version": "2.17.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.3", + "version": "2.17.4", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index c1a43e52..ea191901 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.3", + "version": "2.17.4", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 062fb032..10db84ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.3", + "version": "2.17.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.3", + "version": "2.17.4", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index db63261b..c122240a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.3", + "version": "2.17.4", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 9a1c773b7a26f0974824eaa83d135caeb0ebfc58 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 6 Dec 2024 16:59:34 -0600 Subject: [PATCH 141/762] Fix:Server crash on uploadCover temp file mv failed #3685 --- server/managers/CoverManager.js | 76 +++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 9b4aa32d..2b3a697d 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -12,7 +12,7 @@ const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const CacheManager = require('../managers/CacheManager') class CoverManager { - constructor() { } + constructor() {} getCoverDirectory(libraryItem) { if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) { @@ -93,10 +93,13 @@ class CoverManager { const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`) // Move cover from temp upload dir to destination - const success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => { - Logger.error('[CoverManager] Failed to move cover file', path, error) - return false - }) + const success = await coverFile + .mv(coverFullPath) + .then(() => true) + .catch((error) => { + Logger.error('[CoverManager] Failed to move cover file', coverFullPath, error) + return false + }) if (!success) { return { @@ -124,11 +127,13 @@ class CoverManager { var temppath = Path.posix.join(coverDirPath, 'cover') let errorMsg = '' - let success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { - errorMsg = err.message || 'Unknown error' - Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) - return false - }) + let success = await downloadImageFile(url, temppath) + .then(() => true) + .catch((err) => { + errorMsg = err.message || 'Unknown error' + Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) + return false + }) if (!success) { return { error: 'Failed to download image from url: ' + errorMsg @@ -180,7 +185,7 @@ class CoverManager { } // Cover path does not exist - if (!await fs.pathExists(coverPath)) { + if (!(await fs.pathExists(coverPath))) { Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`) return { error: 'Cover path does not exist' @@ -188,7 +193,7 @@ class CoverManager { } // Cover path is not a file - if (!await checkPathIsFile(coverPath)) { + if (!(await checkPathIsFile(coverPath))) { Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`) return { error: 'Cover path is not a file' @@ -211,10 +216,13 @@ class CoverManager { var newCoverPath = Path.posix.join(coverDirPath, coverFilename) Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`) - var copySuccess = await fs.copy(coverPath, newCoverPath, { overwrite: true }).then(() => true).catch((error) => { - Logger.error(`[CoverManager] validate cover path failed to copy cover`, error) - return false - }) + var copySuccess = await fs + .copy(coverPath, newCoverPath, { overwrite: true }) + .then(() => true) + .catch((error) => { + Logger.error(`[CoverManager] validate cover path failed to copy cover`, error) + return false + }) if (!copySuccess) { return { error: 'Failed to copy cover to dir' @@ -236,14 +244,14 @@ class CoverManager { /** * Extract cover art from audio file and save for library item - * - * @param {import('../models/Book').AudioFileObject[]} audioFiles - * @param {string} libraryItemId - * @param {string} [libraryItemPath] null for isFile library items + * + * @param {import('../models/Book').AudioFileObject[]} audioFiles + * @param {string} libraryItemId + * @param {string} [libraryItemPath] null for isFile library items * @returns {Promise} returns cover path */ async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) { - let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt) + let audioFileWithCover = audioFiles.find((af) => af.embeddedCoverArt) if (!audioFileWithCover) return null let coverDirPath = null @@ -273,10 +281,10 @@ class CoverManager { /** * Extract cover art from ebook and save for library item - * - * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData - * @param {string} libraryItemId - * @param {string} [libraryItemPath] null for isFile library items + * + * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData + * @param {string} libraryItemId + * @param {string} [libraryItemPath] null for isFile library items * @returns {Promise} returns cover path */ async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) { @@ -310,9 +318,9 @@ class CoverManager { } /** - * - * @param {string} url - * @param {string} libraryItemId + * + * @param {string} url + * @param {string} libraryItemId * @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast * @returns {Promise<{error:string}|{cover:string}>} */ @@ -328,10 +336,12 @@ class CoverManager { await fs.ensureDir(coverDirPath) const temppath = Path.posix.join(coverDirPath, 'cover') - const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { - Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) - return false - }) + const success = await downloadImageFile(url, temppath) + .then(() => true) + .catch((err) => { + Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) + return false + }) if (!success) { return { error: 'Failed to download image from url' @@ -361,4 +371,4 @@ class CoverManager { } } } -module.exports = new CoverManager() \ No newline at end of file +module.exports = new CoverManager() From 3b4a5b8785fff8672abb76fae4325c49b7ffca26 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 6 Dec 2024 17:17:32 -0600 Subject: [PATCH 142/762] Support ALLOW_IFRAME env variable to not include frame-ancestors header #3684 --- index.js | 1 + server/Server.js | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index de1ed5c3..9a0be347 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ if (isDev) { if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1' + if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath process.env.SOURCE = 'local' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' diff --git a/server/Server.js b/server/Server.js index 9153ab09..cd96733e 100644 --- a/server/Server.js +++ b/server/Server.js @@ -53,6 +53,7 @@ class Server { global.RouterBasePath = ROUTER_BASE_PATH global.XAccel = process.env.USE_X_ACCEL global.AllowCors = process.env.ALLOW_CORS === '1' + global.AllowIframe = process.env.ALLOW_IFRAME === '1' global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1' if (!fs.pathExistsSync(global.ConfigPath)) { @@ -194,8 +195,10 @@ class Server { const app = express() app.use((req, res, next) => { - // Prevent clickjacking by disallowing iframes - res.setHeader('Content-Security-Policy', "frame-ancestors 'self'") + if (!global.AllowIframe) { + // Prevent clickjacking by disallowing iframes + res.setHeader('Content-Security-Policy', "frame-ancestors 'self'") + } /** * @temporary From 835490a9fcecf0ea608179071dad2fc5d2b17b3b Mon Sep 17 00:00:00 2001 From: Jaume Date: Sat, 7 Dec 2024 01:45:41 +0100 Subject: [PATCH 143/762] Catalan translation added new file client/strings/ca.json --- client/strings/ca.json | 1029 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1029 insertions(+) create mode 100644 client/strings/ca.json diff --git a/client/strings/ca.json b/client/strings/ca.json new file mode 100644 index 00000000..8dde850b --- /dev/null +++ b/client/strings/ca.json @@ -0,0 +1,1029 @@ +{ + "ButtonAdd": "Afegeix", + "ButtonAddChapters": "Afegeix", + "ButtonAddDevice": "Afegeix Dispositiu", + "ButtonAddLibrary": "Crea Biblioteca", + "ButtonAddPodcasts": "Afegeix Podcasts", + "ButtonAddUser": "Crea Usuari", + "ButtonAddYourFirstLibrary": "Crea la teva Primera Biblioteca", + "ButtonApply": "Aplica", + "ButtonApplyChapters": "Aplica Capítols", + "ButtonAuthors": "Autors", + "ButtonBack": "Enrere", + "ButtonBrowseForFolder": "Cerca Carpeta", + "ButtonCancel": "Cancel·la", + "ButtonCancelEncode": "Cancel·la Codificador", + "ButtonChangeRootPassword": "Canvia Contrasenya Root", + "ButtonCheckAndDownloadNewEpisodes": "Verifica i Descarrega Nous Episodis", + "ButtonChooseAFolder": "Tria una Carpeta", + "ButtonChooseFiles": "Tria un Fitxer", + "ButtonClearFilter": "Elimina Filtres", + "ButtonCloseFeed": "Tanca Font", + "ButtonCloseSession": "Tanca la sessió oberta", + "ButtonCollections": "Col·leccions", + "ButtonConfigureScanner": "Configura Escàner", + "ButtonCreate": "Crea", + "ButtonCreateBackup": "Crea Còpia de Seguretat", + "ButtonDelete": "Elimina", + "ButtonDownloadQueue": "Cua", + "ButtonEdit": "Edita", + "ButtonEditChapters": "Edita Capítol", + "ButtonEditPodcast": "Edita Podcast", + "ButtonEnable": "Habilita", + "ButtonFireAndFail": "Executat i fallat", + "ButtonFireOnTest": "Activa esdeveniment de prova", + "ButtonForceReScan": "Força Re-escaneig", + "ButtonFullPath": "Ruta Completa", + "ButtonHide": "Amaga", + "ButtonHome": "Inici", + "ButtonIssues": "Problemes", + "ButtonJumpBackward": "Retrocedeix", + "ButtonJumpForward": "Avança", + "ButtonLatest": "Últims", + "ButtonLibrary": "Biblioteca", + "ButtonLogout": "Tanca Sessió", + "ButtonLookup": "Cerca", + "ButtonManageTracks": "Gestiona Pistes d'Àudio", + "ButtonMapChapterTitles": "Assigna Títols als Capítols", + "ButtonMatchAllAuthors": "Troba Tots els Autors", + "ButtonMatchBooks": "Troba Llibres", + "ButtonNevermind": "Oblida-ho", + "ButtonNext": "Següent", + "ButtonNextChapter": "Següent Capítol", + "ButtonNextItemInQueue": "Següent element a la cua", + "ButtonOk": "D'acord", + "ButtonOpenFeed": "Obre Font", + "ButtonOpenManager": "Obre Editor", + "ButtonPause": "Pausa", + "ButtonPlay": "Reprodueix", + "ButtonPlayAll": "Reprodueix tot", + "ButtonPlaying": "Reproduint", + "ButtonPlaylists": "Llistes de reproducció", + "ButtonPrevious": "Anterior", + "ButtonPreviousChapter": "Capítol Anterior", + "ButtonProbeAudioFile": "Examina fitxer d'àudio", + "ButtonPurgeAllCache": "Esborra Tot el Cache", + "ButtonPurgeItemsCache": "Esborra Cache d'Elements", + "ButtonQueueAddItem": "Afegeix a la Cua", + "ButtonQueueRemoveItem": "Elimina de la Cua", + "ButtonQuickEmbed": "Inserció Ràpida", + "ButtonQuickEmbedMetadata": "Afegeix Metadades Ràpidament", + "ButtonQuickMatch": "Troba Ràpidament", + "ButtonReScan": "Re-escaneja", + "ButtonRead": "Llegeix", + "ButtonReadLess": "Llegeix menys", + "ButtonReadMore": "Llegeix més", + "ButtonRefresh": "Refresca", + "ButtonRemove": "Elimina", + "ButtonRemoveAll": "Elimina Tot", + "ButtonRemoveAllLibraryItems": "Elimina Tots els Elements de la Biblioteca", + "ButtonRemoveFromContinueListening": "Elimina de Continuar Escoltant", + "ButtonRemoveFromContinueReading": "Elimina de Continuar Llegint", + "ButtonRemoveSeriesFromContinueSeries": "Elimina Sèrie de Continuar Sèries", + "ButtonReset": "Restableix", + "ButtonResetToDefault": "Restaura Valors per Defecte", + "ButtonRestore": "Restaura", + "ButtonSave": "Desa", + "ButtonSaveAndClose": "Desa i Tanca", + "ButtonSaveTracklist": "Desa Pistes", + "ButtonScan": "Escaneja", + "ButtonScanLibrary": "Escaneja Biblioteca", + "ButtonSearch": "Cerca", + "ButtonSelectFolderPath": "Selecciona Ruta de Carpeta", + "ButtonSeries": "Sèries", + "ButtonSetChaptersFromTracks": "Selecciona Capítols Segons les Pistes", + "ButtonShare": "Comparteix", + "ButtonShiftTimes": "Desplaça Temps", + "ButtonShow": "Mostra", + "ButtonStartM4BEncode": "Inicia Codificació M4B", + "ButtonStartMetadataEmbed": "Inicia Inserció de Metadades", + "ButtonStats": "Estadístiques", + "ButtonSubmit": "Envia", + "ButtonTest": "Prova", + "ButtonUnlinkOpenId": "Desvincula OpenID", + "ButtonUpload": "Carrega", + "ButtonUploadBackup": "Carrega Còpia de Seguretat", + "ButtonUploadCover": "Carrega Portada", + "ButtonUploadOPMLFile": "Carrega Fitxer OPML", + "ButtonUserDelete": "Elimina Usuari {0}", + "ButtonUserEdit": "Edita Usuari {0}", + "ButtonViewAll": "Mostra-ho Tot", + "ButtonYes": "Sí", + "ErrorUploadFetchMetadataAPI": "Error obtenint metadades", + "ErrorUploadFetchMetadataNoResults": "No s'han pogut obtenir metadades - Intenta actualitzar el títol i/o autor", + "ErrorUploadLacksTitle": "S'ha de tenir un títol", + "HeaderAccount": "Compte", + "HeaderAddCustomMetadataProvider": "Afegeix un proveïdor de metadades personalitzat", + "HeaderAdvanced": "Avançat", + "HeaderAppriseNotificationSettings": "Configuració de Notificacions Apprise", + "HeaderAudioTracks": "Pistes d'àudio", + "HeaderAudiobookTools": "Eines de Gestió d'Arxius d'Audiollibre", + "HeaderAuthentication": "Autenticació", + "HeaderBackups": "Còpies de Seguretat", + "HeaderChangePassword": "Canvia Contrasenya", + "HeaderChapters": "Capítols", + "HeaderChooseAFolder": "Tria una Carpeta", + "HeaderCollection": "Col·lecció", + "HeaderCollectionItems": "Elements a la Col·lecció", + "HeaderCover": "Portada", + "HeaderCurrentDownloads": "Descàrregues Actuals", + "HeaderCustomMessageOnLogin": "Missatge Personalitzat a l'Iniciar Sessió", + "HeaderCustomMetadataProviders": "Proveïdors de Metadades Personalitzats", + "HeaderDetails": "Detalls", + "HeaderDownloadQueue": "Cua de Descàrregues", + "HeaderEbookFiles": "Fitxers de Llibres Digitals", + "HeaderEmail": "Correu electrònic", + "HeaderEmailSettings": "Configuració de Correu Electrònic", + "HeaderEpisodes": "Episodis", + "HeaderEreaderDevices": "Dispositius Ereader", + "HeaderEreaderSettings": "Configuració del Lector", + "HeaderFiles": "Element", + "HeaderFindChapters": "Cerca Capítol", + "HeaderIgnoredFiles": "Ignora Element", + "HeaderItemFiles": "Carpetes d'Elements", + "HeaderItemMetadataUtils": "Utilitats de Metadades d'Elements", + "HeaderLastListeningSession": "Últimes Sessions", + "HeaderLatestEpisodes": "Últims Episodis", + "HeaderLibraries": "Biblioteques", + "HeaderLibraryFiles": "Fitxers de Biblioteca", + "HeaderLibraryStats": "Estadístiques de Biblioteca", + "HeaderListeningSessions": "Sessió", + "HeaderListeningStats": "Estadístiques de Temps Escoltat", + "HeaderLogin": "Inicia Sessió", + "HeaderLogs": "Registres", + "HeaderManageGenres": "Gestiona Gèneres", + "HeaderManageTags": "Gestiona Etiquetes", + "HeaderMapDetails": "Assigna Detalls", + "HeaderMatch": "Troba", + "HeaderMetadataOrderOfPrecedence": "Ordre de Precedència de Metadades", + "HeaderMetadataToEmbed": "Metadades a Inserir", + "HeaderNewAccount": "Nou Compte", + "HeaderNewLibrary": "Nova Biblioteca", + "HeaderNotificationCreate": "Crea Notificació", + "HeaderNotificationUpdate": "Actualització de Notificació", + "HeaderNotifications": "Notificacions", + "HeaderOpenIDConnectAuthentication": "Autenticació OpenID Connect", + "HeaderOpenListeningSessions": "Sessions públiques d'escolta", + "HeaderOpenRSSFeed": "Obre Font RSS", + "HeaderOtherFiles": "Altres Fitxers", + "HeaderPasswordAuthentication": "Autenticació per Contrasenya", + "HeaderPermissions": "Permisos", + "HeaderPlayerQueue": "Cua del Reproductor", + "HeaderPlayerSettings": "Configuració del Reproductor", + "HeaderPlaylist": "Llista de Reproducció", + "HeaderPlaylistItems": "Elements de la Llista de Reproducció", + "HeaderPodcastsToAdd": "Podcasts a afegir", + "HeaderPreviewCover": "Previsualització de la Portada", + "HeaderRSSFeedGeneral": "Detalls RSS", + "HeaderRSSFeedIsOpen": "La Font RSS està oberta", + "HeaderRSSFeeds": "Fonts RSS", + "HeaderRemoveEpisode": "Elimina Episodi", + "HeaderRemoveEpisodes": "Elimina {0} Episodis", + "HeaderSavedMediaProgress": "Desa el Progrés del Multimèdia", + "HeaderSchedule": "Horari", + "HeaderScheduleEpisodeDownloads": "Programa Descàrregues Automàtiques d'Episodis", + "HeaderScheduleLibraryScans": "Programa Escaneig Automàtic de Biblioteca", + "HeaderSession": "Sessió", + "HeaderSetBackupSchedule": "Programa Còpies de Seguretat", + "HeaderSettings": "Configuració", + "HeaderSettingsDisplay": "Interfície", + "HeaderSettingsExperimental": "Funcions Experimentals", + "HeaderSettingsGeneral": "General", + "HeaderSettingsScanner": "Escàner", + "HeaderSleepTimer": "Temporitzador de son", + "HeaderStatsLargestItems": "Elements més Grans", + "HeaderStatsLongestItems": "Elements més Llargs (h)", + "HeaderStatsMinutesListeningChart": "Minuts Escoltant (Últims 7 dies)", + "HeaderStatsRecentSessions": "Sessions Recents", + "HeaderStatsTop10Authors": "Top 10 Autors", + "HeaderStatsTop5Genres": "Top 5 Gèneres", + "HeaderTableOfContents": "Taula de Continguts", + "HeaderTools": "Eines", + "HeaderUpdateAccount": "Actualitza Compte", + "HeaderUpdateAuthor": "Actualitza Autor", + "HeaderUpdateDetails": "Actualitza Detalls", + "HeaderUpdateLibrary": "Actualitza Biblioteca", + "HeaderUsers": "Usuaris", + "HeaderYearReview": "Revisió de l'Any {0}", + "HeaderYourStats": "Les teves Estadístiques", + "LabelAbridged": "Resumit", + "LabelAbridgedChecked": "Resumit (comprovat)", + "LabelAbridgedUnchecked": "Sense resumir (no comprovat)", + "LabelAccessibleBy": "Accessible per", + "LabelAccountType": "Tipus de Compte", + "LabelAccountTypeAdmin": "Administrador", + "LabelAccountTypeGuest": "Convidat", + "LabelAccountTypeUser": "Usuari", + "LabelActivity": "Activitat", + "LabelAddToCollection": "Afegit a la Col·lecció", + "LabelAddToCollectionBatch": "S'han Afegit {0} Llibres a la Col·lecció", + "LabelAddToPlaylist": "Afegit a la llista de reproducció", + "LabelAddToPlaylistBatch": "S'han Afegit {0} Elements a la Llista de Reproducció", + "LabelAddedAt": "Afegit", + "LabelAddedDate": "{0} Afegit", + "LabelAdminUsersOnly": "Només usuaris administradors", + "LabelAll": "Tots", + "LabelAllUsers": "Tots els Usuaris", + "LabelAllUsersExcludingGuests": "Tots els usuaris excepte convidats", + "LabelAllUsersIncludingGuests": "Tots els usuaris i convidats", + "LabelAlreadyInYourLibrary": "Ja existeix a la Biblioteca", + "LabelApiToken": "Token de l'API", + "LabelAppend": "Adjuntar", + "LabelAudioBitrate": "Taxa de bits d'àudio (per exemple, 128k)", + "LabelAudioChannels": "Canals d'àudio (1 o 2)", + "LabelAudioCodec": "Còdec d'àudio", + "LabelAuthor": "Autor", + "LabelAuthorFirstLast": "Autor (Nom Cognom)", + "LabelAuthorLastFirst": "Autor (Cognom, Nom)", + "LabelAuthors": "Autors", + "LabelAutoDownloadEpisodes": "Descarregar episodis automàticament", + "LabelAutoFetchMetadata": "Actualitzar Metadades Automàticament", + "LabelAutoFetchMetadataHelp": "Obtén metadades de títol, autor i sèrie per agilitzar la càrrega. És possible que calgui revisar metadades addicionals després de la càrrega.", + "LabelAutoLaunch": "Inici automàtic", + "LabelAutoLaunchDescription": "Redirigir automàticament al proveïdor d'autenticació quan s'accedeix a la pàgina d'inici de sessió (ruta d'excepció manual /login?autoLaunch=0)", + "LabelAutoRegister": "Registre automàtic", + "LabelAutoRegisterDescription": "Crear usuaris automàticament en iniciar sessió", + "LabelBackToUser": "Torna a Usuari", + "LabelBackupAudioFiles": "Còpia de seguretat d'arxius d'àudio", + "LabelBackupLocation": "Ubicació de la Còpia de Seguretat", + "LabelBackupsEnableAutomaticBackups": "Habilitar Còpies de Seguretat Automàtiques", + "LabelBackupsEnableAutomaticBackupsHelp": "Còpies de seguretat desades a /metadata/backups", + "LabelBackupsMaxBackupSize": "Mida màxima de la còpia de seguretat (en GB) (0 per il·limitat)", + "LabelBackupsMaxBackupSizeHelp": "Com a protecció contra una configuració incorrecta, les còpies de seguretat fallaran si superen la mida configurada.", + "LabelBackupsNumberToKeep": "Nombre de còpies de seguretat a conservar", + "LabelBackupsNumberToKeepHelp": "Només s'eliminarà una còpia de seguretat alhora. Si té més còpies desades, haurà d'eliminar-les manualment.", + "LabelBitrate": "Taxa de bits", + "LabelBonus": "Bonus", + "LabelBooks": "Llibres", + "LabelButtonText": "Text del botó", + "LabelByAuthor": "per {0}", + "LabelChangePassword": "Canviar Contrasenya", + "LabelChannels": "Canals", + "LabelChapterCount": "{0} capítols", + "LabelChapterTitle": "Títol del Capítol", + "LabelChapters": "Capítols", + "LabelChaptersFound": "Capítol Trobat", + "LabelClickForMoreInfo": "Fes clic per a més informació", + "LabelClickToUseCurrentValue": "Fes clic per utilitzar el valor actual", + "LabelClosePlayer": "Tancar reproductor", + "LabelCodec": "Còdec", + "LabelCollapseSeries": "Contraure sèrie", + "LabelCollapseSubSeries": "Contraure la subsèrie", + "LabelCollection": "Col·lecció", + "LabelCollections": "Col·leccions", + "LabelComplete": "Complet", + "LabelConfirmPassword": "Confirmar Contrasenya", + "LabelContinueListening": "Continuar escoltant", + "LabelContinueReading": "Continuar llegint", + "LabelContinueSeries": "Continuar sèries", + "LabelCover": "Portada", + "LabelCoverImageURL": "URL de la Imatge de Portada", + "LabelCreatedAt": "Creat", + "LabelCronExpression": "Expressió de Cron", + "LabelCurrent": "Actual", + "LabelCurrently": "En aquest moment:", + "LabelCustomCronExpression": "Expressió de Cron Personalitzada:", + "LabelDatetime": "Hora i Data", + "LabelDays": "Dies", + "LabelDeleteFromFileSystemCheckbox": "Eliminar arxius del sistema (desmarcar per eliminar només de la base de dades)", + "LabelDescription": "Descripció", + "LabelDeselectAll": "Desseleccionar Tots", + "LabelDevice": "Dispositiu", + "LabelDeviceInfo": "Informació del Dispositiu", + "LabelDeviceIsAvailableTo": "El dispositiu està disponible per a...", + "LabelDirectory": "Directori", + "LabelDiscFromFilename": "Disc a partir del Nom de l'Arxiu", + "LabelDiscFromMetadata": "Disc a partir de Metadades", + "LabelDiscover": "Descobrir", + "LabelDownload": "Descarregar", + "LabelDownloadNEpisodes": "Descarregar {0} episodis", + "LabelDuration": "Duració", + "LabelDurationComparisonExactMatch": "(coincidència exacta)", + "LabelDurationComparisonLonger": "({0} més llarg)", + "LabelDurationComparisonShorter": "({0} més curt)", + "LabelDurationFound": "Duració Trobada:", + "LabelEbook": "Llibre electrònic", + "LabelEbooks": "Llibres electrònics", + "LabelEdit": "Editar", + "LabelEmail": "Correu electrònic", + "LabelEmailSettingsFromAddress": "Remitent", + "LabelEmailSettingsRejectUnauthorized": "Rebutja certificats no autoritzats", + "LabelEmailSettingsRejectUnauthorizedHelp": "Desactivar la validació de certificats SSL pot exposar la teva connexió a riscos de seguretat, com atacs de tipus man-in-the-middle. Desactiva aquesta opció només si coneixes les implicacions i confies en el servidor de correu al qual et connectes.", + "LabelEmailSettingsSecure": "Seguretat", + "LabelEmailSettingsSecureHelp": "Si està activat, es farà servir TLS per connectar-se al servidor. Si està desactivat, es farà servir TLS si el servidor admet l'extensió STARTTLS. En la majoria dels casos, pots deixar aquesta opció activada si et connectes al port 465. Desactiva-la en el cas d'usar els ports 587 o 25. (de nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "Provar Adreça", + "LabelEmbeddedCover": "Portada Integrada", + "LabelEnable": "Habilitar", + "LabelEncodingBackupLocation": "Es guardarà una còpia de seguretat dels teus arxius d'àudio originals a:", + "LabelEncodingChaptersNotEmbedded": "Els capítols no s'incrusten en els audiollibres multipista.", + "LabelEncodingClearItemCache": "Assegura't de purgar periòdicament la memòria cau.", + "LabelEncodingFinishedM4B": "El M4B acabat es col·locarà a la teva carpeta d'audiollibres a:", + "LabelEncodingInfoEmbedded": "Les metadades s'integraran a les pistes d'àudio dins de la carpeta d'audiollibres.", + "LabelEncodingStartedNavigation": "Un cop iniciada la tasca, pots sortir d'aquesta pàgina.", + "LabelEncodingTimeWarning": "La codificació pot trigar fins a 30 minuts.", + "LabelEncodingWarningAdvancedSettings": "Advertència: No actualitzis aquesta configuració tret que estiguis familiaritzat amb les opcions de codificació d'ffmpeg.", + "LabelEncodingWatcherDisabled": "Si has desactivat la supervisió dels arxius, hauràs de tornar a escanejar aquest audiollibre més endavant.", + "LabelEnd": "Fi", + "LabelEndOfChapter": "Fi del capítol", + "LabelEpisode": "Episodi", + "LabelEpisodeNotLinkedToRssFeed": "Episodi no enllaçat al feed RSS", + "LabelEpisodeNumber": "Episodi #{0}", + "LabelEpisodeTitle": "Títol de l'Episodi", + "LabelEpisodeType": "Tipus d'Episodi", + "LabelEpisodeUrlFromRssFeed": "URL de l'episodi del feed RSS", + "LabelEpisodes": "Episodis", + "LabelEpisodic": "Episodis", + "LabelExample": "Exemple", + "LabelExpandSeries": "Ampliar sèrie", + "LabelExpandSubSeries": "Expandir la subsèrie", + "LabelExplicit": "Explícit", + "LabelExplicitChecked": "Explícit (marcat)", + "LabelExplicitUnchecked": "No Explícit (sense marcar)", + "LabelExportOPML": "Exportar OPML", + "LabelFeedURL": "Font de URL", + "LabelFetchingMetadata": "Obtenció de metadades", + "LabelFile": "Arxiu", + "LabelFileBirthtime": "Arxiu creat a", + "LabelFileBornDate": "Creat {0}", + "LabelFileModified": "Arxiu modificat", + "LabelFileModifiedDate": "Modificat {0}", + "LabelFilename": "Nom de l'arxiu", + "LabelFilterByUser": "Filtrar per Usuari", + "LabelFindEpisodes": "Cercar Episodi", + "LabelFinished": "Acabat", + "LabelFolder": "Carpeta", + "LabelFolders": "Carpetes", + "LabelFontBold": "Negreta", + "LabelFontBoldness": "Nivell de negreta en font", + "LabelFontFamily": "Família tipogràfica", + "LabelFontItalic": "Cursiva", + "LabelFontScale": "Mida de la font", + "LabelFontStrikethrough": "Ratllat", + "LabelFormat": "Format", + "LabelFull": "Complet", + "LabelGenre": "Gènere", + "LabelGenres": "Gèneres", + "LabelHardDeleteFile": "Eliminar Definitivament", + "LabelHasEbook": "Té un llibre electrònic", + "LabelHasSupplementaryEbook": "Té un llibre electrònic complementari", + "LabelHideSubtitles": "Amagar subtítols", + "LabelHighestPriority": "Prioritat més alta", + "LabelHost": "Amfitrió", + "LabelHour": "Hora", + "LabelHours": "Hores", + "LabelIcon": "Icona", + "LabelImageURLFromTheWeb": "URL de la imatge", + "LabelInProgress": "En procés", + "LabelIncludeInTracklist": "Incloure a la Llista de Pistes", + "LabelIncomplete": "Incomplet", + "LabelInterval": "Interval", + "LabelIntervalCustomDailyWeekly": "Personalitzar diari/setmanal", + "LabelIntervalEvery12Hours": "Cada 12 Hores", + "LabelIntervalEvery15Minutes": "Cada 15 minuts", + "LabelIntervalEvery2Hours": "Cada 2 Hores", + "LabelIntervalEvery30Minutes": "Cada 30 minuts", + "LabelIntervalEvery6Hours": "Cada 6 Hores", + "LabelIntervalEveryDay": "Cada Dia", + "LabelIntervalEveryHour": "Cada Hora", + "LabelInvert": "Invertir", + "LabelItem": "Element", + "LabelJumpBackwardAmount": "Quantitat de salts cap enrere", + "LabelJumpForwardAmount": "Quantitat de salts cap endavant", + "LabelLanguage": "Idioma", + "LabelLanguageDefaultServer": "Idioma Predeterminat del Servidor", + "LabelLanguages": "Idiomes", + "LabelLastBookAdded": "Últim Llibre Afegit", + "LabelLastBookUpdated": "Últim Llibre Actualitzat", + "LabelLastSeen": "Última Vegada Vist", + "LabelLastTime": "Última Vegada", + "LabelLastUpdate": "Última Actualització", + "LabelLayout": "Distribució", + "LabelLayoutSinglePage": "Pàgina única", + "LabelLayoutSplitPage": "Dues Pàgines", + "LabelLess": "Menys", + "LabelLibrariesAccessibleToUser": "Biblioteques Disponibles per a l'Usuari", + "LabelLibrary": "Biblioteca", + "LabelLibraryFilterSublistEmpty": "Sense {0}", + "LabelLibraryItem": "Element de Biblioteca", + "LabelLibraryName": "Nom de Biblioteca", + "LabelLimit": "Límits", + "LabelLineSpacing": "Interlineat", + "LabelListenAgain": "Escoltar de nou", + "LabelLogLevelDebug": "Depurar", + "LabelLogLevelInfo": "Informació", + "LabelLogLevelWarn": "Advertència", + "LabelLookForNewEpisodesAfterDate": "Cercar nous episodis a partir d'aquesta data", + "LabelLowestPriority": "Menor prioritat", + "LabelMatchExistingUsersBy": "Emparellar els usuaris existents per", + "LabelMatchExistingUsersByDescription": "S'utilitza per connectar usuaris existents. Un cop connectats, els usuaris seran emparellats per un identificador únic del seu proveïdor de SSO", + "LabelMaxEpisodesToDownload": "Nombre màxim d'episodis per descarregar. Usa 0 per descarregar una quantitat il·limitada.", + "LabelMaxEpisodesToDownloadPerCheck": "Nombre màxim de nous episodis que es descarregaran per comprovació", + "LabelMaxEpisodesToKeep": "Nombre màxim d'episodis que es mantindran", + "LabelMaxEpisodesToKeepHelp": "El valor 0 no estableix un límit màxim. Després de descarregar automàticament un nou episodi, això eliminarà l'episodi més antic si té més de X episodis. Això només eliminarà 1 episodi per nova descàrrega.", + "LabelMediaPlayer": "Reproductor de Mitjans", + "LabelMediaType": "Tipus de multimèdia", + "LabelMetaTag": "Metaetiqueta", + "LabelMetaTags": "Metaetiquetes", + "LabelMetadataOrderOfPrecedenceDescription": "Les fonts de metadades de major prioritat prevaldran sobre les de menor prioritat", + "LabelMetadataProvider": "Proveïdor de Metadades", + "LabelMinute": "Minut", + "LabelMinutes": "Minuts", + "LabelMissing": "Absent", + "LabelMissingEbook": "No té ebook", + "LabelMissingSupplementaryEbook": "No té ebook complementari", + "LabelMobileRedirectURIs": "URI de redirecció mòbil permeses", + "LabelMobileRedirectURIsDescription": "Aquesta és una llista blanca d'URI de redirecció vàlides per a aplicacions mòbils. El predeterminat és audiobookshelf, que pots eliminar o complementar amb URI addicionals per a la integració d'aplicacions de tercers. Usant un asterisc ( *) com a única entrada que permet qualsevol URI.", + "LabelMore": "Més", + "LabelMoreInfo": "Més informació", + "LabelName": "Nom", + "LabelNarrator": "Narrador", + "LabelNarrators": "Narradors", + "LabelNew": "Nou", + "LabelNewPassword": "Nova Contrasenya", + "LabelNewestAuthors": "Autors més recents", + "LabelNewestEpisodes": "Episodis més recents", + "LabelNextBackupDate": "Data del Següent Respatller", + "LabelNextScheduledRun": "Proper Execució Programada", + "LabelNoCustomMetadataProviders": "Sense proveïdors de metadades personalitzats", + "LabelNoEpisodesSelected": "Cap Episodi Seleccionat", + "LabelNotFinished": "No acabat", + "LabelNotStarted": "Sense iniciar", + "LabelNotes": "Notes", + "LabelNotificationAppriseURL": "URL(s) d'Apprise", + "LabelNotificationAvailableVariables": "Variables Disponibles", + "LabelNotificationBodyTemplate": "Plantilla de Cos", + "LabelNotificationEvent": "Esdeveniment de Notificació", + "LabelNotificationTitleTemplate": "Plantilla de Títol", + "LabelNotificationsMaxFailedAttempts": "Màxim d'Intents Fallits", + "LabelNotificationsMaxFailedAttemptsHelp": "Les notificacions es desactivaran després de fallar aquest nombre de vegades", + "LabelNotificationsMaxQueueSize": "Mida màxima de la cua de notificacions", + "LabelNotificationsMaxQueueSizeHelp": "Les notificacions estan limitades a 1 per segon. Les notificacions seran ignorades si arriben al número màxim de cua per prevenir spam d'esdeveniments.", + "LabelNumberOfBooks": "Nombre de Llibres", + "LabelNumberOfEpisodes": "Nombre d'Episodis", + "LabelOpenIDAdvancedPermsClaimDescription": "Nom de la notificació de OpenID que conté permisos avançats per accions d'usuari dins l'aplicació que s'aplicaran a rols que no siguin d'administrador (si estan configurats). Si el reclam no apareix en la resposta, es denegarà l'accés a ABS. Si manca una sola opció, es tractarà com a falsa. Assegura't que la notificació del proveïdor d'identitats coincideixi amb l'estructura esperada:", + "LabelOpenIDClaims": "Deixa les següents opcions buides per desactivar l'assignació avançada de grups i permisos, el que assignaria automàticament al grup 'Usuari'.", + "LabelOpenIDGroupClaimDescription": "Nom de la declaració OpenID que conté una llista de grups de l'usuari. Comunament coneguts com grups. Si es configura, l'aplicació assignarà automàticament rols basats en la pertinença a grups de l'usuari, sempre que aquests grups es denominen 'admin', 'user' o 'guest' en la notificació. La sol·licitud ha de contenir una llista, i si un usuari pertany a diversos grups, l'aplicació assignarà el rol corresponent al major nivell d'accés. Si cap grup coincideix, es denegarà l'accés.", + "LabelOverwrite": "Sobreescriure", + "LabelPaginationPageXOfY": "Pàgina {0} de {1}", + "LabelPassword": "Contrasenya", + "LabelPath": "Ruta de carpeta", + "LabelPermanent": "Permanent", + "LabelPermissionsAccessAllLibraries": "Pot Accedir a Totes les Biblioteques", + "LabelPermissionsAccessAllTags": "Pot Accedir a Totes les Etiquetes", + "LabelPermissionsAccessExplicitContent": "Pot Accedir a Contingut Explícit", + "LabelPermissionsCreateEreader": "Pot Crear un Gestor de Projectes", + "LabelPermissionsDelete": "Pot Eliminar", + "LabelPermissionsDownload": "Pot Descarregar", + "LabelPermissionsUpdate": "Pot Actualitzar", + "LabelPermissionsUpload": "Pot Pujar", + "LabelPersonalYearReview": "Revisió del teu any ({0})", + "LabelPhotoPathURL": "Ruta/URL de la Foto", + "LabelPlayMethod": "Mètode de Reproducció", + "LabelPlayerChapterNumberMarker": "{0} de {1}", + "LabelPlaylists": "Llistes de Reproducció", + "LabelPodcast": "Podcast", + "LabelPodcastSearchRegion": "Regió de Cerca de Podcasts", + "LabelPodcastType": "Tipus de Podcast", + "LabelPodcasts": "Podcasts", + "LabelPort": "Port", + "LabelPrefixesToIgnore": "Prefixos per Ignorar (no distingeix entre majúscules i minúscules.)", + "LabelPreventIndexing": "Evita que la teva font sigui indexada pels directoris de podcasts d'iTunes i Google", + "LabelPrimaryEbook": "Ebook Principal", + "LabelProgress": "Progrés", + "LabelProvider": "Proveïdor", + "LabelProviderAuthorizationValue": "Valor de l'encapçalament d'autorització", + "LabelPubDate": "Data de Publicació", + "LabelPublishYear": "Any de Publicació", + "LabelPublishedDate": "Publicat {0}", + "LabelPublishedDecade": "Dècada de Publicació", + "LabelPublishedDecades": "Dècades Publicades", + "LabelPublisher": "Editor", + "LabelPublishers": "Editors", + "LabelRSSFeedCustomOwnerEmail": "Correu Electrònic Personalitzat del Propietari", + "LabelRSSFeedCustomOwnerName": "Nom Personalitzat del Propietari", + "LabelRSSFeedOpen": "Font RSS Oberta", + "LabelRSSFeedPreventIndexing": "Evitar l'indexació", + "LabelRSSFeedSlug": "Font RSS Slug", + "LabelRSSFeedURL": "URL de la Font RSS", + "LabelRandomly": "Aleatòriament", + "LabelReAddSeriesToContinueListening": "Reafegir la sèrie per continuar escoltant-la", + "LabelRead": "Llegit", + "LabelReadAgain": "Tornar a llegir", + "LabelReadEbookWithoutProgress": "Llegir Ebook sense guardar progrés", + "LabelRecentSeries": "Sèries Recents", + "LabelRecentlyAdded": "Afegit Recentment", + "LabelRecommended": "Recomanats", + "LabelRedo": "Refer", + "LabelRegion": "Regió", + "LabelReleaseDate": "Data d'Estrena", + "LabelRemoveAllMetadataAbs": "Eliminar tots els fitxers metadata.abs", + "LabelRemoveAllMetadataJson": "Eliminar tots els fitxers metadata.json", + "LabelRemoveCover": "Eliminar Coberta", + "LabelRemoveMetadataFile": "Eliminar fitxers de metadades en carpetes d'elements de biblioteca", + "LabelRemoveMetadataFileHelp": "Elimina tots els fitxers metadata.json i metadata.abs de les teves carpetes {0}.", + "LabelRowsPerPage": "Files per Pàgina", + "LabelSearchTerm": "Cercar Terme", + "LabelSearchTitle": "Cercar Títol", + "LabelSearchTitleOrASIN": "Cercar Títol o ASIN", + "LabelSeason": "Temporada", + "LabelSeasonNumber": "Temporada #{0}", + "LabelSelectAll": "Seleccionar tot", + "LabelSelectAllEpisodes": "Seleccionar tots els episodis", + "LabelSelectEpisodesShowing": "Seleccionar els {0} episodis visibles", + "LabelSelectUsers": "Seleccionar usuaris", + "LabelSendEbookToDevice": "Enviar Ebook a...", + "LabelSequence": "Seqüència", + "LabelSerial": "Serial", + "LabelSeries": "Sèries", + "LabelSeriesName": "Nom de la Sèrie", + "LabelSeriesProgress": "Progrés de la Sèrie", + "LabelServerLogLevel": "Nivell de registre del servidor", + "LabelServerYearReview": "Resum de l'any del servidor ({0})", + "LabelSetEbookAsPrimary": "Establir com a principal", + "LabelSetEbookAsSupplementary": "Establir com a suplementari", + "LabelSettingsAudiobooksOnly": "Només Audiollibres", + "LabelSettingsAudiobooksOnlyHelp": "Activant aquesta opció s'ignoraran els fitxers d'ebook, excepte si estan dins d'una carpeta d'audiollibre, en aquest cas es marcaran com ebooks suplementaris", + "LabelSettingsBookshelfViewHelp": "Disseny esqueomorf amb prestatgeries de fusta", + "LabelSettingsChromecastSupport": "Compatibilitat amb Chromecast", + "LabelSettingsDateFormat": "Format de Data", + "LabelSettingsDisableWatcher": "Desactivar Watcher", + "LabelSettingsDisableWatcherForLibrary": "Desactivar Watcher de Carpetes per a aquesta biblioteca", + "LabelSettingsDisableWatcherHelp": "Desactiva la funció d'afegir/actualitzar elements automàticament quan es detectin canvis en els fitxers. *Requereix reiniciar el servidor", + "LabelSettingsEnableWatcher": "Habilitar Watcher", + "LabelSettingsEnableWatcherForLibrary": "Habilitar Watcher per a la carpeta d'aquesta biblioteca", + "LabelSettingsEnableWatcherHelp": "Permet afegir/actualitzar elements automàticament quan es detectin canvis en els fitxers. *Requereix reiniciar el servidor", + "LabelSettingsEpubsAllowScriptedContent": "Permetre scripts en epubs", + "LabelSettingsEpubsAllowScriptedContentHelp": "Permetre que els fitxers epub executin scripts. Es recomana mantenir aquesta opció desactivada tret que confiïs en l'origen dels fitxers epub.", + "LabelSettingsExperimentalFeatures": "Funcions Experimentals", + "LabelSettingsExperimentalFeaturesHelp": "Funcions en desenvolupament que es beneficiarien dels teus comentaris i experiències de prova. Feu clic aquí per obrir una conversa a Github.", + "LabelShowAll": "Mostra-ho tot", + "LabelShowSeconds": "Mostra segons", + "LabelShowSubtitles": "Mostra subtítols", + "LabelSize": "Mida", + "LabelSleepTimer": "Temporitzador de repòs", + "LabelSlug": "Slug", + "LabelStart": "Inicia", + "LabelStartTime": "Hora d'inici", + "LabelStarted": "Iniciat", + "LabelStartedAt": "Iniciat a", + "LabelStatsAudioTracks": "Pistes d'àudio", + "LabelStatsAuthors": "Autors", + "LabelStatsBestDay": "Millor dia", + "LabelStatsDailyAverage": "Mitjana diària", + "LabelStatsDays": "Dies", + "LabelStatsDaysListened": "Dies escoltats", + "LabelStatsHours": "Hores", + "LabelStatsInARow": "seguits", + "LabelStatsItemsFinished": "Elements acabats", + "LabelStatsItemsInLibrary": "Elements a la biblioteca", + "LabelStatsMinutes": "minuts", + "LabelStatsMinutesListening": "Minuts escoltant", + "LabelStatsOverallDays": "Total de dies", + "LabelStatsOverallHours": "Total d'hores", + "LabelStatsWeekListening": "Temps escoltat aquesta setmana", + "LabelSubtitle": "Subtítol", + "LabelSupportedFileTypes": "Tipus de fitxers compatibles", + "LabelTag": "Etiqueta", + "LabelTags": "Etiquetes", + "LabelTagsAccessibleToUser": "Etiquetes accessibles per a l'usuari", + "LabelTagsNotAccessibleToUser": "Etiquetes no accessibles per a l'usuari", + "LabelTasks": "Tasques en execució", + "LabelTextEditorBulletedList": "Llista amb punts", + "LabelTextEditorLink": "Enllaça", + "LabelTextEditorNumberedList": "Llista numerada", + "LabelTextEditorUnlink": "Desenllaça", + "LabelTheme": "Tema", + "LabelThemeDark": "Fosc", + "LabelThemeLight": "Clar", + "LabelTimeBase": "Temps base", + "LabelTimeDurationXHours": "{0} hores", + "LabelTimeDurationXMinutes": "{0} minuts", + "LabelTimeDurationXSeconds": "{0} segons", + "LabelTimeInMinutes": "Temps en minuts", + "LabelTimeLeft": "Queden {0}", + "LabelTimeListened": "Temps escoltat", + "LabelTimeListenedToday": "Temps escoltat avui", + "LabelTimeRemaining": "{0} restant", + "LabelTimeToShift": "Temps per canviar en segons", + "LabelTitle": "Títol", + "LabelToolsEmbedMetadata": "Incrusta metadades", + "LabelToolsEmbedMetadataDescription": "Incrusta metadades en els fitxers d'àudio, incloent la portada i capítols.", + "LabelToolsM4bEncoder": "Codificador M4B", + "LabelToolsMakeM4b": "Crea fitxer d'audiollibre M4B", + "LabelToolsMakeM4bDescription": "Genera un fitxer d'audiollibre .M4B amb metadades, imatges de portada i capítols incrustats.", + "LabelToolsSplitM4b": "Divideix M4B en fitxers MP3", + "LabelToolsSplitM4bDescription": "Divideix un M4B en fitxers MP3 i incrusta metadades, imatges de portada i capítols.", + "LabelTotalDuration": "Duració total", + "LabelTotalTimeListened": "Temps total escoltat", + "LabelTrackFromFilename": "Pista des del nom del fitxer", + "LabelTrackFromMetadata": "Pista des de metadades", + "LabelTracks": "Pistes", + "LabelTracksMultiTrack": "Diverses pistes", + "LabelTracksNone": "Cap pista", + "LabelTracksSingleTrack": "Una pista", + "LabelTrailer": "Tràiler", + "LabelType": "Tipus", + "LabelUnabridged": "No abreujat", + "LabelUndo": "Desfés", + "LabelUnknown": "Desconegut", + "LabelUnknownPublishDate": "Data de publicació desconeguda", + "LabelUpdateCover": "Actualitza portada", + "LabelUpdateCoverHelp": "Permet sobreescriure les portades existents dels llibres seleccionats quan es trobi una coincidència.", + "LabelUpdateDetails": "Actualitza detalls", + "LabelUpdateDetailsHelp": "Permet sobreescriure els detalls existents dels llibres seleccionats quan es trobin coincidències.", + "LabelUpdatedAt": "Actualitzat a", + "LabelUploaderDragAndDrop": "Arrossega i deixa anar fitxers o carpetes", + "LabelUploaderDragAndDropFilesOnly": "Arrossega i deixa anar fitxers", + "LabelUploaderDropFiles": "Deixa anar els fitxers", + "LabelUploaderItemFetchMetadataHelp": "Cerca títol, autor i sèries automàticament", + "LabelUseAdvancedOptions": "Utilitza opcions avançades", + "LabelUseChapterTrack": "Utilitza pista per capítol", + "LabelUseFullTrack": "Utilitza pista completa", + "LabelUseZeroForUnlimited": "Utilitza 0 per il·limitat", + "LabelUser": "Usuari", + "LabelUsername": "Nom d'usuari", + "LabelValue": "Valor", + "LabelVersion": "Versió", + "LabelViewBookmarks": "Mostra marcadors", + "LabelViewChapters": "Mostra capítols", + "LabelViewPlayerSettings": "Mostra els ajustaments del reproductor", + "LabelViewQueue": "Mostra cua del reproductor", + "LabelVolume": "Volum", + "LabelWebRedirectURLsDescription": "Autoritza aquestes URL al teu proveïdor OAuth per permetre redirecció a l'aplicació web després d'iniciar sessió:", + "LabelWebRedirectURLsSubfolder": "Subcarpeta per a URL de redirecció", + "LabelWeekdaysToRun": "Executar en dies de la setmana", + "LabelXBooks": "{0} llibres", + "LabelXItems": "{0} elements", + "LabelYearReviewHide": "Oculta resum de l'any", + "LabelYearReviewShow": "Mostra resum de l'any", + "LabelYourAudiobookDuration": "Duració del teu audiollibre", + "LabelYourBookmarks": "Els teus marcadors", + "LabelYourPlaylists": "Les teves llistes", + "LabelYourProgress": "El teu progrés", + "MessageAddToPlayerQueue": "Afegeix a la cua del reproductor", + "MessageAppriseDescription": "Per utilitzar aquesta funció, hauràs de tenir l'API d'Apprise en funcionament o una API que gestioni resultats similars.
La URL de l'API d'Apprise ha de tenir la mateixa ruta d'arxius que on s'envien les notificacions. Per exemple: si la teva API és a http://192.168.1.1:8337, llavors posaries http://192.168.1.1:8337/notify.", + "MessageBackupsDescription": "Les còpies de seguretat inclouen: usuaris, progrés dels usuaris, detalls dels elements de la biblioteca, configuració del servidor i imatges a /metadata/items i /metadata/authors. Les còpies de seguretat NO inclouen cap fitxer guardat a la carpeta de la teva biblioteca.", + "MessageBackupsLocationEditNote": "Nota: Actualitzar la ubicació de la còpia de seguretat no mourà ni modificarà les còpies existents", + "MessageBackupsLocationNoEditNote": "Nota: La ubicació de la còpia de seguretat es defineix mitjançant una variable d'entorn i no es pot modificar aquí.", + "MessageBackupsLocationPathEmpty": "La ruta de la còpia de seguretat no pot estar buida", + "MessageBatchQuickMatchDescription": "La funció \"Troba Ràpid\" intentarà afegir portades i metadades que falten als elements seleccionats. Activa l'opció següent perquè \"Troba Ràpid\" pugui sobreescriure portades i/o metadades existents.", + "MessageBookshelfNoCollections": "No tens cap col·lecció", + "MessageBookshelfNoRSSFeeds": "Cap font RSS està oberta", + "MessageBookshelfNoResultsForFilter": "Cap resultat per al filtre \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "Cap resultat per a la consulta", + "MessageBookshelfNoSeries": "No tens cap sèrie", + "MessageChapterEndIsAfter": "El final del capítol és després del final del teu audiollibre", + "MessageChapterErrorFirstNotZero": "El primer capítol ha de començar a 0", + "MessageChapterErrorStartGteDuration": "El temps d'inici no és vàlid: ha de ser inferior a la durada de l'audiollibre", + "MessageChapterErrorStartLtPrev": "El temps d'inici no és vàlid: ha de ser igual o més gran que el temps d'inici del capítol anterior", + "MessageChapterStartIsAfter": "L'inici del capítol és després del final del teu audiollibre", + "MessageCheckingCron": "Comprovant cron...", + "MessageConfirmCloseFeed": "Estàs segur que vols tancar aquesta font?", + "MessageConfirmDeleteBackup": "Estàs segur que vols eliminar la còpia de seguretat {0}?", + "MessageConfirmDeleteDevice": "Estàs segur que vols eliminar el lector electrònic \"{0}\"?", + "MessageConfirmDeleteFile": "Això eliminarà el fitxer del teu sistema. Estàs segur?", + "MessageConfirmDeleteLibrary": "Estàs segur que vols eliminar permanentment la biblioteca \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "Això eliminarà l'element de la base de dades i del sistema. Estàs segur?", + "MessageConfirmDeleteLibraryItems": "Això eliminarà {0} element(s) de la base de dades i del sistema. Estàs segur?", + "MessageConfirmDeleteMetadataProvider": "Estàs segur que vols eliminar el proveïdor de metadades personalitzat \"{0}\"?", + "MessageConfirmDeleteNotification": "Estàs segur que vols eliminar aquesta notificació?", + "MessageConfirmDeleteSession": "Estàs segur que vols eliminar aquesta sessió?", + "MessageConfirmEmbedMetadataInAudioFiles": "Estàs segur que vols incrustar metadades a {0} fitxer(s) d'àudio?", + "MessageConfirmForceReScan": "Estàs segur que vols forçar un reescaneig?", + "MessageConfirmMarkAllEpisodesFinished": "Estàs segur que vols marcar tots els episodis com a acabats?", + "MessageConfirmMarkAllEpisodesNotFinished": "Estàs segur que vols marcar tots els episodis com a no acabats?", + "MessageConfirmMarkItemFinished": "Estàs segur que vols marcar \"{0}\" com a acabat?", + "MessageConfirmMarkItemNotFinished": "Estàs segur que vols marcar \"{0}\" com a no acabat?", + "MessageConfirmMarkSeriesFinished": "Estàs segur que vols marcar tots els llibres d'aquesta sèrie com a acabats?", + "MessageConfirmMarkSeriesNotFinished": "Estàs segur que vols marcar tots els llibres d'aquesta sèrie com a no acabats?", + "MessageConfirmNotificationTestTrigger": "Vols activar aquesta notificació amb dades de prova?", + "MessageConfirmPurgeCache": "Esborrar la memòria cau eliminarà tot el directori localitzat a /metadata/cache.

Estàs segur que vols eliminar-lo?", + "MessageConfirmPurgeItemsCache": "Esborrar la memòria cau dels elements eliminarà el directori /metadata/cache/items.
Estàs segur?", + "MessageConfirmQuickEmbed": "Advertència! La integració ràpida no fa còpies de seguretat dels teus fitxers d'àudio. Assegura't d'haver-ne fet una còpia abans.

Vols continuar?", + "MessageConfirmQuickMatchEpisodes": "El reconeixement ràpid sobreescriurà els detalls si es troba una coincidència. Estàs segur?", + "MessageConfirmReScanLibraryItems": "Estàs segur que vols reescanejar {0} element(s)?", + "MessageConfirmRemoveAllChapters": "Estàs segur que vols eliminar tots els capítols?", + "MessageConfirmRemoveAuthor": "Estàs segur que vols eliminar l'autor \"{0}\"?", + "MessageConfirmRemoveCollection": "Estàs segur que vols eliminar la col·lecció \"{0}\"?", + "MessageConfirmRemoveEpisode": "Estàs segur que vols eliminar l'episodi \"{0}\"?", + "MessageConfirmRemoveEpisodes": "Estàs segur que vols eliminar {0} episodis?", + "MessageConfirmRemoveListeningSessions": "Estàs segur que vols eliminar {0} sessions d'escolta?", + "MessageConfirmRemoveMetadataFiles": "Estàs segur que vols eliminar tots els fitxers de metadades.{0} de les carpetes dels elements de la teva biblioteca?", + "MessageConfirmRemoveNarrator": "Estàs segur que vols eliminar el narrador \"{0}\"?", + "MessageConfirmRemovePlaylist": "Estàs segur que vols eliminar la llista de reproducció \"{0}\"?", + "MessageConfirmRenameGenre": "Estàs segur que vols canviar el gènere \"{0}\" a \"{1}\" per a tots els elements?", + "MessageConfirmRenameGenreMergeNote": "Nota: Aquest gènere ja existeix, i es fusionarà.", + "MessageConfirmRenameGenreWarning": "Advertència! Ja existeix un gènere similar \"{0}\".", + "MessageConfirmRenameTag": "Estàs segur que vols canviar l'etiqueta \"{0}\" a \"{1}\" per a tots els elements?", + "MessageConfirmRenameTagMergeNote": "Nota: Aquesta etiqueta ja existeix, i es fusionarà.", + "MessageConfirmRenameTagWarning": "Advertència! Ja existeix una etiqueta similar \"{0}\".", + "MessageConfirmResetProgress": "Estàs segur que vols reiniciar el teu progrés?", + "MessageConfirmSendEbookToDevice": "Estàs segur que vols enviar {0} ebook(s) \"{1}\" al dispositiu \"{2}\"?", + "MessageConfirmUnlinkOpenId": "Estàs segur que vols desvincular aquest usuari d'OpenID?", + "MessageDownloadingEpisode": "Descarregant capítol", + "MessageDragFilesIntoTrackOrder": "Arrossega els fitxers en l'ordre correcte de les pistes", + "MessageEmbedFailed": "Error en incrustar!", + "MessageEmbedFinished": "Incrustació acabada!", + "MessageEmbedQueue": "En cua per incrustar metadades ({0} en cua)", + "MessageMarkAsFinished": "Marcar com acabat", + "MessageMarkAsNotFinished": "Marcar com no acabat", + "MessageMatchBooksDescription": "S'intentarà fer coincidir els llibres de la biblioteca amb un llibre del proveïdor de cerca seleccionat, i s'ompliran els detalls buits i la portada. No sobreescriu els detalls.", + "MessageNoAudioTracks": "Sense pistes d'àudio", + "MessageNoAuthors": "Sense autors", + "MessageNoBackups": "Sense còpies de seguretat", + "MessageNoBookmarks": "Sense marcadors", + "MessageNoChapters": "Sense capítols", + "MessageNoCollections": "Sense col·leccions", + "MessageNoCoversFound": "Cap portada trobada", + "MessageNoDescription": "Sense descripció", + "MessageNoDevices": "Sense dispositius", + "MessageNoDownloadsInProgress": "No hi ha descàrregues en curs", + "MessageNoDownloadsQueued": "Sense cua de descàrrega", + "MessageNoEpisodeMatchesFound": "No s'han trobat episodis que coincideixin", + "MessageNoEpisodes": "Sense episodis", + "MessageNoFoldersAvailable": "No hi ha carpetes disponibles", + "MessageNoGenres": "Sense gèneres", + "MessageNoIssues": "Sense problemes", + "MessageNoItems": "Sense elements", + "MessageNoItemsFound": "Cap element trobat", + "MessageNoListeningSessions": "Sense sessions escoltades", + "MessageNoLogs": "Sense registres", + "MessageNoMediaProgress": "Sense progrés multimèdia", + "MessageNoNotifications": "Sense notificacions", + "MessageNoPodcastFeed": "Podcast no vàlid: sense font", + "MessageNoPodcastsFound": "Cap podcast trobat", + "MessageNoResults": "Sense resultats", + "MessageNoSearchResultsFor": "No hi ha resultats per a la cerca \"{0}\"", + "MessageNoSeries": "Sense sèries", + "MessageNoTags": "Sense etiquetes", + "MessageNoTasksRunning": "Sense tasques en execució", + "MessageNoUpdatesWereNecessary": "No calien actualitzacions", + "MessageNoUserPlaylists": "No tens cap llista de reproducció", + "MessageNotYetImplemented": "Encara no implementat", + "MessageOpmlPreviewNote": "Nota: Aquesta és una vista prèvia de l'arxiu OPML analitzat. El títol real del podcast s'obtindrà del canal RSS.", + "MessageOr": "o", + "MessagePauseChapter": "Pausar la reproducció del capítol", + "MessagePlayChapter": "Escoltar l'inici del capítol", + "MessagePlaylistCreateFromCollection": "Crear una llista de reproducció a partir d'una col·lecció", + "MessagePleaseWait": "Espera si us plau...", + "MessagePodcastHasNoRSSFeedForMatching": "El podcast no té una URL de font RSS que es pugui utilitzar", + "MessagePodcastSearchField": "Introdueix el terme de cerca o la URL de la font RSS", + "MessageQuickEmbedInProgress": "Integració ràpida en procés", + "MessageQuickEmbedQueue": "En cua per a inserció ràpida ({0} en cua)", + "MessageQuickMatchAllEpisodes": "Combina ràpidament tots els episodis", + "MessageQuickMatchDescription": "Omple detalls d'elements buits i portades amb els primers resultats de '{0}'. No sobreescriu els detalls tret que l'opció \"Preferir metadades trobades\" del servidor estigui habilitada.", + "MessageRemoveChapter": "Eliminar capítols", + "MessageRemoveEpisodes": "Eliminar {0} episodi(s)", + "MessageRemoveFromPlayerQueue": "Eliminar de la cua del reproductor", + "MessageRemoveUserWarning": "Estàs segur que vols eliminar l'usuari \"{0}\"?", + "MessageReportBugsAndContribute": "Informa d'errors, sol·licita funcions i contribueix a", + "MessageResetChaptersConfirm": "Estàs segur que vols desfer els canvis i revertir els capítols al seu estat original?", + "MessageRestoreBackupConfirm": "Estàs segur que vols restaurar la còpia de seguretat creada a", + "MessageRestoreBackupWarning": "Restaurar sobreescriurà tota la base de dades situada a /config i les imatges de portades a /metadata/items i /metadata/authors.

La còpia de seguretat no modifica cap fitxer a les carpetes de la teva biblioteca. Si has activat l'opció del servidor per guardar portades i metadades a les carpetes de la biblioteca, aquests fitxers no es guarden ni sobreescriuen.

Tots els clients que utilitzin el teu servidor s'actualitzaran automàticament.", + "MessageSearchResultsFor": "Resultats de la cerca de", + "MessageSelected": "{0} seleccionat(s)", + "MessageServerCouldNotBeReached": "No es va poder establir la connexió amb el servidor", + "MessageSetChaptersFromTracksDescription": "Establir capítols utilitzant cada fitxer d'àudio com un capítol i el títol del capítol com el nom del fitxer d'àudio", + "MessageShareExpirationWillBe": "La caducitat serà {0}", + "MessageShareExpiresIn": "Caduca en {0}", + "MessageShareURLWillBe": "La URL per compartir serà {0}", + "MessageStartPlaybackAtTime": "Començar la reproducció per a \"{0}\" a {1}?", + "MessageTaskAudioFileNotWritable": "El fitxer d'àudio \"{0}\" no es pot escriure", + "MessageTaskCanceledByUser": "Tasca cancel·lada per l'usuari", + "MessageTaskDownloadingEpisodeDescription": "Descarregant l'episodi \"{0}\"", + "MessageTaskEmbeddingMetadata": "Inserint metadades", + "MessageTaskEmbeddingMetadataDescription": "Inserint metadades en l'audiollibre \"{0}\"", + "MessageTaskEncodingM4b": "Codificant M4B", + "MessageTaskEncodingM4bDescription": "Codificant l'audiollibre \"{0}\" en un únic fitxer M4B", + "MessageTaskFailed": "Fallada", + "MessageTaskFailedToBackupAudioFile": "Error en fer una còpia de seguretat del fitxer d'àudio \"{0}\"", + "MessageTaskFailedToCreateCacheDirectory": "Error en crear el directori de la memòria cau", + "MessageTaskFailedToEmbedMetadataInFile": "Error en incrustar metadades en el fitxer \"{0}\"", + "MessageTaskFailedToMergeAudioFiles": "Error en fusionar fitxers d'àudio", + "MessageTaskFailedToMoveM4bFile": "Error en moure el fitxer M4B", + "MessageTaskFailedToWriteMetadataFile": "Error en escriure el fitxer de metadades", + "MessageTaskMatchingBooksInLibrary": "Coincidint llibres a la biblioteca \"{0}\"", + "MessageTaskNoFilesToScan": "Sense fitxers per escanejar", + "MessageTaskOpmlImport": "Importar OPML", + "MessageTaskOpmlImportDescription": "Creant podcasts a partir de {0} fonts RSS", + "MessageTaskOpmlImportFeed": "Importació de feed OPML", + "MessageTaskOpmlImportFeedDescription": "Importació del feed RSS \"{0}\"", + "MessageTaskOpmlImportFeedFailed": "No es pot obtenir el podcast", + "MessageTaskOpmlImportFeedPodcastDescription": "Creant el podcast \"{0}\"", + "MessageTaskOpmlImportFeedPodcastExists": "El podcast ja existeix a la ruta", + "MessageTaskOpmlImportFeedPodcastFailed": "Error en crear el podcast", + "MessageTaskOpmlImportFinished": "Afegit {0} podcasts", + "MessageTaskOpmlParseFailed": "No s'ha pogut analitzar el fitxer OPML", + "MessageTaskOpmlParseFastFail": "No s'ha trobat l'etiqueta o al fitxer OPML", + "MessageTaskOpmlParseNoneFound": "No s'han trobat fonts al fitxer OPML", + "MessageTaskScanItemsAdded": "{0} afegit", + "MessageTaskScanItemsMissing": "{0} faltant", + "MessageTaskScanItemsUpdated": "{0} actualitzat", + "MessageTaskScanNoChangesNeeded": "No calen canvis", + "MessageTaskScanningFileChanges": "Escanejant canvis al fitxer en \"{0}\"", + "MessageTaskScanningLibrary": "Escanejant la biblioteca \"{0}\"", + "MessageTaskTargetDirectoryNotWritable": "El directori de destinació no es pot escriure", + "MessageThinking": "Pensant...", + "MessageUploaderItemFailed": "Error en pujar", + "MessageUploaderItemSuccess": "Pujada amb èxit!", + "MessageUploading": "Pujant...", + "MessageValidCronExpression": "Expressió de cron vàlida", + "MessageWatcherIsDisabledGlobally": "El watcher està desactivat globalment a la configuració del servidor", + "MessageXLibraryIsEmpty": "La biblioteca {0} està buida!", + "MessageYourAudiobookDurationIsLonger": "La durada del teu audiollibre és més llarga que la durada trobada", + "MessageYourAudiobookDurationIsShorter": "La durada del teu audiollibre és més curta que la durada trobada", + "NoteChangeRootPassword": "L'usuari Root és l'únic usuari que pot no tenir una contrasenya", + "NoteChapterEditorTimes": "Nota: El temps d'inici del primer capítol ha de romandre a 0:00, i el temps d'inici de l'últim capítol no pot superar la durada de l'audiollibre.", + "NoteFolderPicker": "Nota: Les carpetes ja assignades no es mostraran", + "NoteRSSFeedPodcastAppsHttps": "Advertència: La majoria d'aplicacions de podcast requereixen que la URL de la font RSS utilitzi HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Advertència: Un o més dels teus episodis no tenen data de publicació. Algunes aplicacions de podcast ho requereixen.", + "NoteUploaderFoldersWithMediaFiles": "Les carpetes amb fitxers multimèdia es gestionaran com a elements separats a la biblioteca.", + "NoteUploaderOnlyAudioFiles": "Si només puges fitxers d'àudio, cada fitxer es gestionarà com un audiollibre separat.", + "NoteUploaderUnsupportedFiles": "Els fitxers no compatibles seran ignorats. Si selecciona o arrossega una carpeta, els fitxers que no estiguin dins d'una subcarpeta seran ignorats.", + "NotificationOnBackupCompletedDescription": "S'activa quan es completa una còpia de seguretat", + "NotificationOnBackupFailedDescription": "S'activa quan falla una còpia de seguretat", + "NotificationOnEpisodeDownloadedDescription": "S'activa quan es descarrega automàticament un episodi d'un podcast", + "NotificationOnTestDescription": "Esdeveniment per provar el sistema de notificacions", + "PlaceholderNewCollection": "Nou nom de la col·lecció", + "PlaceholderNewFolderPath": "Nova ruta de carpeta", + "PlaceholderNewPlaylist": "Nou nom de la llista de reproducció", + "PlaceholderSearch": "Cerca...", + "PlaceholderSearchEpisode": "Cerca d'episodis...", + "StatsAuthorsAdded": "autors afegits", + "StatsBooksAdded": "llibres afegits", + "StatsBooksAdditional": "Algunes addicions inclouen…", + "StatsBooksFinished": "llibres acabats", + "StatsBooksFinishedThisYear": "Alguns llibres acabats aquest any…", + "StatsBooksListenedTo": "llibres escoltats", + "StatsCollectionGrewTo": "La teva col·lecció de llibres ha crescut fins a…", + "StatsSessions": "sessions", + "StatsSpentListening": "dedicat a escoltar", + "StatsTopAuthor": "AUTOR DESTACAT", + "StatsTopAuthors": "AUTORS DESTACATS", + "StatsTopGenre": "GÈNERE PRINCIPAL", + "StatsTopGenres": "GÈNERES PRINCIPALS", + "StatsTopMonth": "DESTACAT DEL MES", + "StatsTopNarrator": "NARRADOR DESTACAT", + "StatsTopNarrators": "NARRADORS DESTACATS", + "StatsTotalDuration": "Amb una durada total de…", + "StatsYearInReview": "RESUM DE L'ANY", + "ToastAccountUpdateSuccess": "Compte actualitzat", + "ToastAppriseUrlRequired": "Cal introduir una URL de Apprise", + "ToastAsinRequired": "ASIN requerit", + "ToastAuthorImageRemoveSuccess": "S'ha eliminat la imatge de l'autor", + "ToastAuthorNotFound": "No s'ha trobat l'autor \"{0}\"", + "ToastAuthorRemoveSuccess": "Autor eliminat", + "ToastAuthorSearchNotFound": "No s'ha trobat l'autor", + "ToastAuthorUpdateMerged": "Autor combinat", + "ToastAuthorUpdateSuccess": "Autor actualitzat", + "ToastAuthorUpdateSuccessNoImageFound": "Autor actualitzat (Imatge no trobada)", + "ToastBackupAppliedSuccess": "Còpia de seguretat aplicada", + "ToastBackupCreateFailed": "Error en crear la còpia de seguretat", + "ToastBackupCreateSuccess": "Còpia de seguretat creada", + "ToastBackupDeleteFailed": "Error en eliminar la còpia de seguretat", + "ToastBackupDeleteSuccess": "Còpia de seguretat eliminada", + "ToastBackupInvalidMaxKeep": "Nombre no vàlid de còpies de seguretat a conservar", + "ToastBackupInvalidMaxSize": "Mida màxima de còpia de seguretat no vàlida", + "ToastBackupRestoreFailed": "Error en restaurar la còpia de seguretat", + "ToastBackupUploadFailed": "Error en carregar la còpia de seguretat", + "ToastBackupUploadSuccess": "Còpia de seguretat carregada", + "ToastBatchDeleteFailed": "Error en l'eliminació per lots", + "ToastBatchDeleteSuccess": "Eliminació per lots correcte", + "ToastBatchQuickMatchFailed": "Error en la sincronització ràpida per lots!", + "ToastBatchQuickMatchStarted": "S'ha iniciat la sincronització ràpida per lots de {0} llibres!", + "ToastBatchUpdateFailed": "Error en l'actualització massiva", + "ToastBatchUpdateSuccess": "Actualització massiva completada", + "ToastBookmarkCreateFailed": "Error en crear marcador", + "ToastBookmarkCreateSuccess": "Marcador afegit", + "ToastBookmarkRemoveSuccess": "Marcador eliminat", + "ToastBookmarkUpdateSuccess": "Marcador actualitzat", + "ToastCachePurgeFailed": "Error en purgar la memòria cau", + "ToastCachePurgeSuccess": "Memòria cau purgada amb èxit", + "ToastChaptersHaveErrors": "Els capítols tenen errors", + "ToastChaptersMustHaveTitles": "Els capítols han de tenir un títol", + "ToastChaptersRemoved": "Capítols eliminats", + "ToastChaptersUpdated": "Capítols actualitzats", + "ToastCollectionItemsAddFailed": "Error en afegir elements a la col·lecció", + "ToastCollectionItemsAddSuccess": "Elements afegits a la col·lecció", + "ToastCollectionItemsRemoveSuccess": "Elements eliminats de la col·lecció", + "ToastCollectionRemoveSuccess": "Col·lecció eliminada", + "ToastCollectionUpdateSuccess": "Col·lecció actualitzada", + "ToastCoverUpdateFailed": "Error en actualitzar la portada", + "ToastDeleteFileFailed": "Error en eliminar l'arxiu", + "ToastDeleteFileSuccess": "Arxiu eliminat", + "ToastDeviceAddFailed": "Error en afegir el dispositiu", + "ToastDeviceNameAlreadyExists": "Ja existeix un dispositiu amb aquest nom", + "ToastDeviceTestEmailFailed": "Error en enviar el correu de prova", + "ToastDeviceTestEmailSuccess": "Correu de prova enviat", + "ToastEmailSettingsUpdateSuccess": "Configuració de correu electrònic actualitzada", + "ToastEncodeCancelFailed": "No s'ha pogut cancel·lar la codificació", + "ToastEncodeCancelSucces": "Codificació cancel·lada", + "ToastEpisodeDownloadQueueClearFailed": "No s'ha pogut buidar la cua de descàrregues", + "ToastEpisodeDownloadQueueClearSuccess": "Cua de descàrregues buidada", + "ToastEpisodeUpdateSuccess": "{0} episodi(s) actualitzat(s)", + "ToastErrorCannotShare": "No es pot compartir de manera nativa en aquest dispositiu", + "ToastFailedToLoadData": "Error en carregar les dades", + "ToastFailedToMatch": "Error en emparellar", + "ToastFailedToShare": "Error en compartir", + "ToastFailedToUpdate": "Error en actualitzar", + "ToastInvalidImageUrl": "URL de la imatge no vàlida", + "ToastInvalidMaxEpisodesToDownload": "Nombre màxim d'episodis per descarregar no vàlid", + "ToastInvalidUrl": "URL no vàlida", + "ToastItemCoverUpdateSuccess": "Portada de l'element actualitzada", + "ToastItemDeletedFailed": "Error en eliminar l'element", + "ToastItemDeletedSuccess": "Element eliminat", + "ToastItemDetailsUpdateSuccess": "Detalls de l'element actualitzats", + "ToastItemMarkedAsFinishedFailed": "Error en marcar com a acabat", + "ToastItemMarkedAsFinishedSuccess": "Element marcat com a acabat", + "ToastItemMarkedAsNotFinishedFailed": "Error en marcar com a no acabat", + "ToastItemMarkedAsNotFinishedSuccess": "Element marcat com a no acabat", + "ToastItemUpdateSuccess": "Element actualitzat", + "ToastLibraryCreateFailed": "Error en crear la biblioteca", + "ToastLibraryCreateSuccess": "Biblioteca \"{0}\" creada", + "ToastLibraryDeleteFailed": "Error en eliminar la biblioteca", + "ToastLibraryDeleteSuccess": "Biblioteca eliminada", + "ToastLibraryScanFailedToStart": "Error en iniciar l'escaneig", + "ToastLibraryScanStarted": "S'ha iniciat l'escaneig de la biblioteca", + "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualitzada", + "ToastMatchAllAuthorsFailed": "No coincideix amb tots els autors", + "ToastMetadataFilesRemovedError": "Error en eliminar metadades de {0} arxius", + "ToastMetadataFilesRemovedNoneFound": "No s'han trobat metadades en {0} arxius", + "ToastMetadataFilesRemovedNoneRemoved": "Cap metadada eliminada en {0} arxius", + "ToastMetadataFilesRemovedSuccess": "{0} metadades eliminades en {1} arxius", + "ToastMustHaveAtLeastOnePath": "Ha de tenir almenys una ruta", + "ToastNameEmailRequired": "El nom i el correu electrònic són obligatoris", + "ToastNameRequired": "Nom obligatori", + "ToastNewEpisodesFound": "{0} episodi(s) nou(s) trobat(s)", + "ToastNewUserCreatedFailed": "Error en crear el compte: \"{0}\"", + "ToastNewUserCreatedSuccess": "Nou compte creat", + "ToastNewUserLibraryError": "Ha de seleccionar almenys una biblioteca", + "ToastNewUserPasswordError": "Necessites una contrasenya, només el root pot estar sense contrasenya", + "ToastNewUserTagError": "Selecciona almenys una etiqueta", + "ToastNewUserUsernameError": "Introdueix un nom d'usuari", + "ToastNoNewEpisodesFound": "No s'han trobat nous episodis", + "ToastNoUpdatesNecessary": "No cal actualitzar", + "ToastNotificationCreateFailed": "Error en crear la notificació", + "ToastNotificationDeleteFailed": "Error en eliminar la notificació", + "ToastNotificationFailedMaximum": "El nombre màxim d'intents fallits ha de ser ≥ 0", + "ToastNotificationQueueMaximum": "La cua de notificació màxima ha de ser ≥ 0", + "ToastNotificationSettingsUpdateSuccess": "Configuració de notificació actualitzada", + "ToastNotificationTestTriggerFailed": "No s'ha pogut activar la notificació de prova", + "ToastNotificationTestTriggerSuccess": "Notificació de prova activada", + "ToastNotificationUpdateSuccess": "Notificació actualitzada", + "ToastPlaylistCreateFailed": "Error en crear la llista de reproducció", + "ToastPlaylistCreateSuccess": "Llista de reproducció creada", + "ToastPlaylistRemoveSuccess": "Llista de reproducció eliminada", + "ToastPlaylistUpdateSuccess": "Llista de reproducció actualitzada", + "ToastPodcastCreateFailed": "Error en crear el podcast", + "ToastPodcastCreateSuccess": "Podcast creat", + "ToastPodcastGetFeedFailed": "No s'ha pogut obtenir el podcast", + "ToastPodcastNoEpisodesInFeed": "No s'han trobat episodis en el feed RSS", + "ToastPodcastNoRssFeed": "El podcast no té un feed RSS", + "ToastProgressIsNotBeingSynced": "El progrés no s'està sincronitzant, reinicia la reproducció", + "ToastProviderCreatedFailed": "Error en afegir el proveïdor", + "ToastProviderCreatedSuccess": "Nou proveïdor afegit", + "ToastProviderNameAndUrlRequired": "Nom i URL obligatoris", + "ToastProviderRemoveSuccess": "Proveïdor eliminat", + "ToastRSSFeedCloseFailed": "Error en tancar el feed RSS", + "ToastRSSFeedCloseSuccess": "Feed RSS tancat", + "ToastRemoveFailed": "Error en eliminar", + "ToastRemoveItemFromCollectionFailed": "Error en eliminar l'element de la col·lecció", + "ToastRemoveItemFromCollectionSuccess": "Element eliminat de la col·lecció", + "ToastRemoveItemsWithIssuesFailed": "Error en eliminar elements incorrectes de la biblioteca", + "ToastRemoveItemsWithIssuesSuccess": "S'han eliminat els elements incorrectes de la biblioteca", + "ToastRenameFailed": "Error en canviar el nom", + "ToastRescanFailed": "Error en reescanejar per a {0}", + "ToastRescanRemoved": "Element reescanejat eliminat", + "ToastRescanUpToDate": "Reescaneig completat, l'element ja estava actualitzat", + "ToastRescanUpdated": "Reescaneig completat, l'element ha estat actualitzat", + "ToastScanFailed": "No s'ha pogut escanejar l'element de la biblioteca", + "ToastSelectAtLeastOneUser": "Selecciona almenys un usuari", + "ToastSendEbookToDeviceFailed": "Error en enviar l'ebook al dispositiu", + "ToastSendEbookToDeviceSuccess": "Ebook enviat al dispositiu \"{0}\"", + "ToastSeriesUpdateFailed": "Error en actualitzar la sèrie", + "ToastSeriesUpdateSuccess": "Sèrie actualitzada", + "ToastServerSettingsUpdateSuccess": "Configuració del servidor actualitzada", + "ToastSessionCloseFailed": "Error en tancar la sessió", + "ToastSessionDeleteFailed": "Error en eliminar la sessió", + "ToastSessionDeleteSuccess": "Sessió eliminada", + "ToastSleepTimerDone": "Temporitzador d'apagada activat... zZzzZz", + "ToastSlugMustChange": "L'slug conté caràcters no vàlids", + "ToastSlugRequired": "Slug obligatori", + "ToastSocketConnected": "Socket connectat", + "ToastSocketDisconnected": "Socket desconnectat", + "ToastSocketFailedToConnect": "Error en connectar al Socket", + "ToastSortingPrefixesEmptyError": "Cal tenir almenys 1 prefix per ordenar", + "ToastSortingPrefixesUpdateSuccess": "Prefixos d'ordenació actualitzats ({0} elements)", + "ToastTitleRequired": "Títol obligatori", + "ToastUnknownError": "Error desconegut", + "ToastUnlinkOpenIdFailed": "Error en desvincular l'usuari d'OpenID", + "ToastUnlinkOpenIdSuccess": "Usuari desvinculat d'OpenID", + "ToastUserDeleteFailed": "Error en eliminar l'usuari", + "ToastUserDeleteSuccess": "Usuari eliminat", + "ToastUserPasswordChangeSuccess": "Contrasenya canviada correctament", + "ToastUserPasswordMismatch": "Les contrasenyes no coincideixen", + "ToastUserPasswordMustChange": "La nova contrasenya no pot ser igual a l'anterior", + "ToastUserRootRequireName": "Cal introduir un nom d'usuari root" +} + + From 7486d6345dd03357a6e069bd789cdaad5da785c2 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 7 Dec 2024 09:34:06 +0100 Subject: [PATCH 144/762] Resolved a server crash when a playback session lacked associated media metadata. --- server/utils/queries/userStats.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js index 76b69ed7..fbba7129 100644 --- a/server/utils/queries/userStats.js +++ b/server/utils/queries/userStats.js @@ -127,20 +127,20 @@ module.exports = { bookListeningMap[ls.displayTitle] += listeningSessionListeningTime } - const authors = ls.mediaMetadata.authors || [] + const authors = ls.mediaMetadata?.authors || [] authors.forEach((au) => { if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0 authorListeningMap[au.name] += listeningSessionListeningTime }) - const narrators = ls.mediaMetadata.narrators || [] + const narrators = ls.mediaMetadata?.narrators || [] narrators.forEach((narrator) => { if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0 narratorListeningMap[narrator] += listeningSessionListeningTime }) // Filter out bad genres like "audiobook" and "audio book" - const genres = (ls.mediaMetadata.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) + const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) genres.forEach((genre) => { if (!genreListeningMap[genre]) genreListeningMap[genre] = 0 genreListeningMap[genre] += listeningSessionListeningTime From 33e0987d7325191b82bc99b51ef3426b5d300e80 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 7 Dec 2024 10:09:14 +0100 Subject: [PATCH 145/762] Added mediaMetadata to playbackSessions --- server/managers/PlaybackSessionManager.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index ce43fc8c..d0daa3ba 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -175,6 +175,12 @@ class PlaybackSessionManager { // New session from local session = new PlaybackSession(sessionJson) session.deviceInfo = deviceInfo + // This makes sure that the client's metadata is preferred over the library's metadata, if available, to make a non-breaking change + if(session.mediaMetadata == null) { + // Only sync important metadata + const { title, subtitle, narrators, authors, series, genres } = libraryItem.media.metadata || {}; + session.mediaMetadata = { title, subtitle, narrators, authors, series, genres }; + } session.setDuration(libraryItem, sessionJson.episodeId) Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`) await Database.createPlaybackSession(session) From 89167543fa18e43eab06bf194bbc1996bb08b648 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 7 Dec 2024 10:25:52 +0100 Subject: [PATCH 146/762] added author for podcasts --- server/managers/PlaybackSessionManager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index d0daa3ba..8dd7bc36 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -178,8 +178,8 @@ class PlaybackSessionManager { // This makes sure that the client's metadata is preferred over the library's metadata, if available, to make a non-breaking change if(session.mediaMetadata == null) { // Only sync important metadata - const { title, subtitle, narrators, authors, series, genres } = libraryItem.media.metadata || {}; - session.mediaMetadata = { title, subtitle, narrators, authors, series, genres }; + const { title, subtitle, narrators, authors, author, series, genres } = libraryItem.media.metadata || {}; + session.mediaMetadata = { title, subtitle, narrators, authors, series, genres, author}; } session.setDuration(libraryItem, sessionJson.episodeId) Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`) From 2fbb31e0ea84fabe0e08564141d427e3ab385cbf Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 7 Dec 2024 10:37:00 +0100 Subject: [PATCH 147/762] added null saftey and added displayTitle and displayAuthor --- server/managers/PlaybackSessionManager.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index 8dd7bc36..e8704470 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -178,8 +178,14 @@ class PlaybackSessionManager { // This makes sure that the client's metadata is preferred over the library's metadata, if available, to make a non-breaking change if(session.mediaMetadata == null) { // Only sync important metadata - const { title, subtitle, narrators, authors, author, series, genres } = libraryItem.media.metadata || {}; - session.mediaMetadata = { title, subtitle, narrators, authors, series, genres, author}; + const { title, subtitle, narrators, authors, author, series, genres } = libraryItem?.media?.metadata || {} + session.mediaMetadata = { title, subtitle, narrators, authors, series, genres, author} + } + if(session.displayTitle == null || session.displayTitle === '') { + session.displayTitle = libraryItem?.media?.metadata?.title ?? '' + } + if(session.displayAuthor == null || session.displayAuthor === '') { + session.displayAuthor = libraryItem?.media?.metadata?.authors?.map(a => a.name).join(', ') ?? libraryItem?.media?.metadata?.author ?? '' } session.setDuration(libraryItem, sessionJson.episodeId) Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`) From 9b8e059efe68bb21500f2b84de36f54d5750ba97 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 7 Dec 2024 19:27:37 +0200 Subject: [PATCH 148/762] Remove serverAddress from Feeds and FeedEpisodes URLs --- .../modals/rssfeed/OpenCloseModal.vue | 9 +- .../modals/rssfeed/ViewFeedModal.vue | 7 +- client/pages/config/rss-feeds.vue | 2 +- server/Server.js | 4 + server/managers/RssFeedManager.js | 2 +- .../v2.17.5-remove-host-from-feed-urls.js | 74 +++++++ server/objects/Feed.js | 30 +-- server/objects/FeedEpisode.js | 16 +- server/objects/FeedMeta.js | 32 ++- ...v2.17.5-remove-host-from-feed-urls.test.js | 202 ++++++++++++++++++ 10 files changed, 331 insertions(+), 47 deletions(-) create mode 100644 server/migrations/v2.17.5-remove-host-from-feed-urls.js create mode 100644 test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js diff --git a/client/components/modals/rssfeed/OpenCloseModal.vue b/client/components/modals/rssfeed/OpenCloseModal.vue index 53542cf5..4eff9401 100644 --- a/client/components/modals/rssfeed/OpenCloseModal.vue +++ b/client/components/modals/rssfeed/OpenCloseModal.vue @@ -10,9 +10,9 @@

{{ $strings.HeaderRSSFeedIsOpen }}

- + - content_copy + content_copy
@@ -111,8 +111,11 @@ export default { userIsAdminOrUp() { return this.$store.getters['user/getIsAdminOrUp'] }, + feedUrl() { + return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : '' + }, demoFeedUrl() { - return `${window.origin}/feed/${this.newFeedSlug}` + return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}` }, isHttp() { return window.origin.startsWith('http://') diff --git a/client/components/modals/rssfeed/ViewFeedModal.vue b/client/components/modals/rssfeed/ViewFeedModal.vue index cd06350b..70412517 100644 --- a/client/components/modals/rssfeed/ViewFeedModal.vue +++ b/client/components/modals/rssfeed/ViewFeedModal.vue @@ -5,8 +5,8 @@

{{ $strings.HeaderRSSFeedGeneral }}

- - content_copy + + content_copy
@@ -70,6 +70,9 @@ export default { }, _feed() { return this.feed || {} + }, + feedUrl() { + return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : '' } }, methods: { diff --git a/client/pages/config/rss-feeds.vue b/client/pages/config/rss-feeds.vue index 68117a85..039e9a0d 100644 --- a/client/pages/config/rss-feeds.vue +++ b/client/pages/config/rss-feeds.vue @@ -126,7 +126,7 @@ export default { }, coverUrl(feed) { if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png` - return `${feed.feedUrl}/cover` + return `${this.$config.routerBasePath}${feed.feedUrl}/cover` }, async loadFeeds() { const data = await this.$axios.$get(`/api/feeds`).catch((err) => { diff --git a/server/Server.js b/server/Server.js index cd96733e..dfcb474a 100644 --- a/server/Server.js +++ b/server/Server.js @@ -253,6 +253,10 @@ class Server { // if RouterBasePath is set, modify all requests to include the base path if (global.RouterBasePath) { app.use((req, res, next) => { + const host = req.get('host') + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const prefix = req.url.startsWith(global.RouterBasePath) ? global.RouterBasePath : '' + req.originalHostPrefix = `${protocol}://${host}${prefix}` if (!req.url.startsWith(global.RouterBasePath)) { req.url = `${global.RouterBasePath}${req.url}` } diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 7716440d..8984a39b 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -162,7 +162,7 @@ class RssFeedManager { } } - const xml = feed.buildXml() + const xml = feed.buildXml(req.originalHostPrefix) res.set('Content-Type', 'text/xml') res.send(xml) } diff --git a/server/migrations/v2.17.5-remove-host-from-feed-urls.js b/server/migrations/v2.17.5-remove-host-from-feed-urls.js new file mode 100644 index 00000000..e08877f2 --- /dev/null +++ b/server/migrations/v2.17.5-remove-host-from-feed-urls.js @@ -0,0 +1,74 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.17.5' +const migrationName = `${migrationVersion}-remove-host-from-feed-urls` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + logger.info(`${loggerPrefix} Removing serverAddress from Feeds table URLs`) + await queryInterface.sequelize.query(` + UPDATE Feeds + SET feedUrl = REPLACE(feedUrl, COALESCE(serverAddress, ''), ''), + imageUrl = REPLACE(imageUrl, COALESCE(serverAddress, ''), ''), + siteUrl = REPLACE(siteUrl, COALESCE(serverAddress, ''), ''); + `) + logger.info(`${loggerPrefix} Removed serverAddress from Feeds table URLs`) + + logger.info(`${loggerPrefix} Removing serverAddress from FeedEpisodes table URLs`) + await queryInterface.sequelize.query(` + UPDATE FeedEpisodes + SET siteUrl = REPLACE(siteUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''), + enclosureUrl = REPLACE(enclosureUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''); + `) + logger.info(`${loggerPrefix} Removed serverAddress from FeedEpisodes table URLs`) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script adds the host (serverAddress) back to URL columns in the feeds and feedEpisodes tables. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + logger.info(`${loggerPrefix} Adding serverAddress back to Feeds table URLs`) + await queryInterface.sequelize.query(` + UPDATE Feeds + SET feedUrl = COALESCE(serverAddress, '') || feedUrl, + imageUrl = COALESCE(serverAddress, '') || imageUrl, + siteUrl = COALESCE(serverAddress, '') || siteUrl; + `) + logger.info(`${loggerPrefix} Added serverAddress back to Feeds table URLs`) + + logger.info(`${loggerPrefix} Adding serverAddress back to FeedEpisodes table URLs`) + await queryInterface.sequelize.query(` + UPDATE FeedEpisodes + SET siteUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.siteUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), + enclosureUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.enclosureUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId); + `) + logger.info(`${loggerPrefix} Added serverAddress back to FeedEpisodes table URLs`) + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/objects/Feed.js b/server/objects/Feed.js index 74a220e3..da76067d 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -109,7 +109,7 @@ class Feed { const mediaMetadata = media.metadata const isPodcast = libraryItem.mediaType === 'podcast' - const feedUrl = `${serverAddress}/feed/${slug}` + const feedUrl = `/feed/${slug}` const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName this.id = uuidv4() @@ -128,9 +128,9 @@ class Feed { this.meta.title = mediaMetadata.title this.meta.description = mediaMetadata.description this.meta.author = author - this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` + this.meta.imageUrl = media.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.feedUrl = feedUrl - this.meta.link = `${serverAddress}/item/${libraryItem.id}` + this.meta.link = `/item/${libraryItem.id}` this.meta.explicit = !!mediaMetadata.explicit this.meta.type = mediaMetadata.type this.meta.language = mediaMetadata.language @@ -176,7 +176,7 @@ class Feed { this.meta.title = mediaMetadata.title this.meta.description = mediaMetadata.description this.meta.author = author - this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = media.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.explicit = !!mediaMetadata.explicit this.meta.type = mediaMetadata.type this.meta.language = mediaMetadata.language @@ -206,7 +206,7 @@ class Feed { } setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { - const feedUrl = `${serverAddress}/feed/${slug}` + const feedUrl = `/feed/${slug}` const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length) const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath) @@ -227,9 +227,9 @@ class Feed { this.meta.title = collectionExpanded.name this.meta.description = collectionExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.feedUrl = feedUrl - this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}` + this.meta.link = `/collection/${collectionExpanded.id}` this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit this.meta.preventIndexing = preventIndexing this.meta.ownerName = ownerName @@ -272,7 +272,7 @@ class Feed { this.meta.title = collectionExpanded.name this.meta.description = collectionExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit this.episodes = [] @@ -301,7 +301,7 @@ class Feed { } setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { - const feedUrl = `${serverAddress}/feed/${slug}` + const feedUrl = `/feed/${slug}` let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length) // Sort series items by series sequence @@ -326,9 +326,9 @@ class Feed { this.meta.title = seriesExpanded.name this.meta.description = seriesExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.feedUrl = feedUrl - this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}` + this.meta.link = `/library/${libraryId}/series/${seriesExpanded.id}` this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit this.meta.preventIndexing = preventIndexing this.meta.ownerName = ownerName @@ -374,7 +374,7 @@ class Feed { this.meta.title = seriesExpanded.name this.meta.description = seriesExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit this.episodes = [] @@ -402,12 +402,12 @@ class Feed { this.xml = null } - buildXml() { + buildXml(originalHostPrefix) { if (this.xml) return this.xml - var rssfeed = new RSS(this.meta.getRSSData()) + var rssfeed = new RSS(this.meta.getRSSData(originalHostPrefix)) this.episodes.forEach((ep) => { - rssfeed.item(ep.getRSSData()) + rssfeed.item(ep.getRSSData(originalHostPrefix)) }) this.xml = rssfeed.xml() return this.xml diff --git a/server/objects/FeedEpisode.js b/server/objects/FeedEpisode.js index 6d9f36a0..13d590ff 100644 --- a/server/objects/FeedEpisode.js +++ b/server/objects/FeedEpisode.js @@ -79,7 +79,7 @@ class FeedEpisode { this.title = episode.title this.description = episode.description || '' this.enclosure = { - url: `${serverAddress}${contentUrl}`, + url: `${contentUrl}`, type: episode.audioTrack.mimeType, size: episode.size } @@ -136,7 +136,7 @@ class FeedEpisode { this.title = title this.description = mediaMetadata.description || '' this.enclosure = { - url: `${serverAddress}${contentUrl}`, + url: `${contentUrl}`, type: audioTrack.mimeType, size: audioTrack.metadata.size } @@ -151,15 +151,19 @@ class FeedEpisode { this.fullPath = audioTrack.metadata.path } - getRSSData() { + getRSSData(hostPrefix) { return { title: this.title, description: this.description || '', - url: this.link, - guid: this.enclosure.url, + url: `${hostPrefix}${this.link}`, + guid: `${hostPrefix}${this.enclosure.url}`, author: this.author, date: this.pubDate, - enclosure: this.enclosure, + enclosure: { + url: `${hostPrefix}${this.enclosure.url}`, + type: this.enclosure.type, + size: this.enclosure.size + }, custom_elements: [ { 'itunes:author': this.author }, { 'itunes:duration': secondsToTimestamp(this.duration) }, diff --git a/server/objects/FeedMeta.js b/server/objects/FeedMeta.js index 307e12bc..e439fe8f 100644 --- a/server/objects/FeedMeta.js +++ b/server/objects/FeedMeta.js @@ -60,42 +60,36 @@ class FeedMeta { } } - getRSSData() { - const blockTags = [ - { 'itunes:block': 'yes' }, - { 'googleplay:block': 'yes' } - ] + getRSSData(hostPrefix) { + const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }] return { title: this.title, description: this.description || '', generator: 'Audiobookshelf', - feed_url: this.feedUrl, - site_url: this.link, - image_url: this.imageUrl, + feed_url: `${hostPrefix}${this.feedUrl}`, + site_url: `${hostPrefix}${this.link}`, + image_url: `${hostPrefix}${this.imageUrl}`, custom_namespaces: { - 'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd', - 'psc': 'http://podlove.org/simple-chapters', - 'podcast': 'https://podcastindex.org/namespace/1.0', - 'googleplay': 'http://www.google.com/schemas/play-podcasts/1.0' + itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd', + psc: 'http://podlove.org/simple-chapters', + podcast: 'https://podcastindex.org/namespace/1.0', + googleplay: 'http://www.google.com/schemas/play-podcasts/1.0' }, custom_elements: [ - { 'language': this.language || 'en' }, - { 'author': this.author || 'advplyr' }, + { language: this.language || 'en' }, + { author: this.author || 'advplyr' }, { 'itunes:author': this.author || 'advplyr' }, { 'itunes:summary': this.description || '' }, { 'itunes:type': this.type }, { 'itunes:image': { _attr: { - href: this.imageUrl + href: `${hostPrefix}${this.imageUrl}` } } }, { - 'itunes:owner': [ - { 'itunes:name': this.ownerName || this.author || '' }, - { 'itunes:email': this.ownerEmail || '' } - ] + 'itunes:owner': [{ 'itunes:name': this.ownerName || this.author || '' }, { 'itunes:email': this.ownerEmail || '' }] }, { 'itunes:explicit': !!this.explicit }, ...(this.preventIndexing ? blockTags : []) diff --git a/test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js b/test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js new file mode 100644 index 00000000..786ed6ae --- /dev/null +++ b/test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js @@ -0,0 +1,202 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up, down } = require('../../../server/migrations/v2.17.5-remove-host-from-feed-urls') +const { Sequelize, DataTypes } = require('sequelize') +const Logger = require('../../../server/Logger') + +const defineModels = (sequelize) => { + const Feeds = sequelize.define('Feeds', { + id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 }, + feedUrl: { type: DataTypes.STRING }, + imageUrl: { type: DataTypes.STRING }, + siteUrl: { type: DataTypes.STRING }, + serverAddress: { type: DataTypes.STRING } + }) + + const FeedEpisodes = sequelize.define('FeedEpisodes', { + id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 }, + feedId: { type: DataTypes.UUID }, + siteUrl: { type: DataTypes.STRING }, + enclosureUrl: { type: DataTypes.STRING } + }) + + return { Feeds, FeedEpisodes } +} + +describe('Migration v2.17.4-use-subfolder-for-oidc-redirect-uris', () => { + let queryInterface, logger, context + let sequelize + let Feeds, FeedEpisodes + const feed1Id = '00000000-0000-4000-a000-000000000001' + const feed2Id = '00000000-0000-4000-a000-000000000002' + const feedEpisode1Id = '00000000-4000-a000-0000-000000000011' + const feedEpisode2Id = '00000000-4000-a000-0000-000000000012' + const feedEpisode3Id = '00000000-4000-a000-0000-000000000021' + + before(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + ;({ Feeds, FeedEpisodes } = defineModels(sequelize)) + await sequelize.sync() + }) + + after(async () => { + await sequelize.close() + }) + + beforeEach(async () => { + // Reset tables before each test + await Feeds.destroy({ where: {}, truncate: true }) + await FeedEpisodes.destroy({ where: {}, truncate: true }) + + logger = { + info: sinon.stub(), + error: sinon.stub() + } + context = { queryInterface, logger } + }) + + describe('up', () => { + it('should remove serverAddress from URLs in Feeds and FeedEpisodes tables', async () => { + await Feeds.bulkCreate([ + { id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: 'http://server1.com/img1', siteUrl: 'http://server1.com/site1', serverAddress: 'http://server1.com' }, + { id: feed2Id, feedUrl: 'http://server2.com/feed2', imageUrl: 'http://server2.com/img2', siteUrl: 'http://server2.com/site2', serverAddress: 'http://server2.com' } + ]) + + await FeedEpisodes.bulkCreate([ + { id: feedEpisode1Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode11', enclosureUrl: 'http://server1.com/enclosure11' }, + { id: feedEpisode2Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode12', enclosureUrl: 'http://server1.com/enclosure12' }, + { id: feedEpisode3Id, feedId: feed2Id, siteUrl: 'http://server2.com/episode21', enclosureUrl: 'http://server2.com/enclosure21' } + ]) + + await up({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(logger.info.calledWith('[2.17.5 migration] UPGRADE BEGIN: 2.17.5-remove-host-from-feed-urls')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] Removing serverAddress from Feeds table URLs')).to.be.true + + expect(feeds[0].feedUrl).to.equal('/feed1') + expect(feeds[0].imageUrl).to.equal('/img1') + expect(feeds[0].siteUrl).to.equal('/site1') + expect(feeds[1].feedUrl).to.equal('/feed2') + expect(feeds[1].imageUrl).to.equal('/img2') + expect(feeds[1].siteUrl).to.equal('/site2') + + expect(logger.info.calledWith('[2.17.5 migration] Removed serverAddress from Feeds table URLs')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] Removing serverAddress from FeedEpisodes table URLs')).to.be.true + + expect(feedEpisodes[0].siteUrl).to.equal('/episode11') + expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11') + expect(feedEpisodes[1].siteUrl).to.equal('/episode12') + expect(feedEpisodes[1].enclosureUrl).to.equal('/enclosure12') + expect(feedEpisodes[2].siteUrl).to.equal('/episode21') + expect(feedEpisodes[2].enclosureUrl).to.equal('/enclosure21') + + expect(logger.info.calledWith('[2.17.5 migration] Removed serverAddress from FeedEpisodes table URLs')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] UPGRADE END: 2.17.5-remove-host-from-feed-urls')).to.be.true + }) + + it('should handle null URLs in Feeds and FeedEpisodes tables', async () => { + await Feeds.bulkCreate([{ id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: null, siteUrl: 'http://server1.com/site1', serverAddress: 'http://server1.com' }]) + + await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: null, enclosureUrl: 'http://server1.com/enclosure11' }]) + + await up({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(feeds[0].feedUrl).to.equal('/feed1') + expect(feeds[0].imageUrl).to.be.null + expect(feeds[0].siteUrl).to.equal('/site1') + expect(feedEpisodes[0].siteUrl).to.be.null + expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11') + }) + + it('should handle null serverAddress in Feeds table', async () => { + await Feeds.bulkCreate([{ id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: 'http://server1.com/img1', siteUrl: 'http://server1.com/site1', serverAddress: null }]) + await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode11', enclosureUrl: 'http://server1.com/enclosure11' }]) + + await up({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1') + expect(feeds[0].imageUrl).to.equal('http://server1.com/img1') + expect(feeds[0].siteUrl).to.equal('http://server1.com/site1') + expect(feedEpisodes[0].siteUrl).to.equal('http://server1.com/episode11') + expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11') + }) + }) + + describe('down', () => { + it('should add serverAddress back to URLs in Feeds and FeedEpisodes tables', async () => { + await Feeds.bulkCreate([ + { id: feed1Id, feedUrl: '/feed1', imageUrl: '/img1', siteUrl: '/site1', serverAddress: 'http://server1.com' }, + { id: feed2Id, feedUrl: '/feed2', imageUrl: '/img2', siteUrl: '/site2', serverAddress: 'http://server2.com' } + ]) + + await FeedEpisodes.bulkCreate([ + { id: feedEpisode1Id, feedId: feed1Id, siteUrl: '/episode11', enclosureUrl: '/enclosure11' }, + { id: feedEpisode2Id, feedId: feed1Id, siteUrl: '/episode12', enclosureUrl: '/enclosure12' }, + { id: feedEpisode3Id, feedId: feed2Id, siteUrl: '/episode21', enclosureUrl: '/enclosure21' } + ]) + + await down({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(logger.info.calledWith('[2.17.5 migration] DOWNGRADE BEGIN: 2.17.5-remove-host-from-feed-urls')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] Adding serverAddress back to Feeds table URLs')).to.be.true + + expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1') + expect(feeds[0].imageUrl).to.equal('http://server1.com/img1') + expect(feeds[0].siteUrl).to.equal('http://server1.com/site1') + expect(feeds[1].feedUrl).to.equal('http://server2.com/feed2') + expect(feeds[1].imageUrl).to.equal('http://server2.com/img2') + expect(feeds[1].siteUrl).to.equal('http://server2.com/site2') + + expect(logger.info.calledWith('[2.17.5 migration] Added serverAddress back to Feeds table URLs')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] Adding serverAddress back to FeedEpisodes table URLs')).to.be.true + + expect(feedEpisodes[0].siteUrl).to.equal('http://server1.com/episode11') + expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11') + expect(feedEpisodes[1].siteUrl).to.equal('http://server1.com/episode12') + expect(feedEpisodes[1].enclosureUrl).to.equal('http://server1.com/enclosure12') + expect(feedEpisodes[2].siteUrl).to.equal('http://server2.com/episode21') + expect(feedEpisodes[2].enclosureUrl).to.equal('http://server2.com/enclosure21') + + expect(logger.info.calledWith('[2.17.5 migration] DOWNGRADE END: 2.17.5-remove-host-from-feed-urls')).to.be.true + }) + + it('should handle null URLs in Feeds and FeedEpisodes tables', async () => { + await Feeds.bulkCreate([{ id: feed1Id, feedUrl: '/feed1', imageUrl: null, siteUrl: '/site1', serverAddress: 'http://server1.com' }]) + await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: null, enclosureUrl: '/enclosure11' }]) + + await down({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1') + expect(feeds[0].imageUrl).to.be.null + expect(feeds[0].siteUrl).to.equal('http://server1.com/site1') + expect(feedEpisodes[0].siteUrl).to.be.null + expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11') + }) + + it('should handle null serverAddress in Feeds table', async () => { + await Feeds.bulkCreate([{ id: feed1Id, feedUrl: '/feed1', imageUrl: '/img1', siteUrl: '/site1', serverAddress: null }]) + await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: '/episode11', enclosureUrl: '/enclosure11' }]) + + await down({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(feeds[0].feedUrl).to.equal('/feed1') + expect(feeds[0].imageUrl).to.equal('/img1') + expect(feeds[0].siteUrl).to.equal('/site1') + expect(feedEpisodes[0].siteUrl).to.equal('/episode11') + expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11') + }) + }) +}) From 6fa11934be0e7c5b28c423f495377980a7e9fb63 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 7 Dec 2024 15:15:47 -0600 Subject: [PATCH 149/762] Add:Catalan language option --- client/plugins/i18n.js | 1 + client/strings/ca.json | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/client/plugins/i18n.js b/client/plugins/i18n.js index 0ec5ccce..12d2b44b 100644 --- a/client/plugins/i18n.js +++ b/client/plugins/i18n.js @@ -7,6 +7,7 @@ const defaultCode = 'en-us' const languageCodeMap = { bg: { label: 'Български', dateFnsLocale: 'bg' }, bn: { label: 'বাংলা', dateFnsLocale: 'bn' }, + ca: { label: 'Català', dateFnsLocale: 'ca' }, cs: { label: 'Čeština', dateFnsLocale: 'cs' }, da: { label: 'Dansk', dateFnsLocale: 'da' }, de: { label: 'Deutsch', dateFnsLocale: 'de' }, diff --git a/client/strings/ca.json b/client/strings/ca.json index 8dde850b..f7e85ae2 100644 --- a/client/strings/ca.json +++ b/client/strings/ca.json @@ -1025,5 +1025,3 @@ "ToastUserPasswordMustChange": "La nova contrasenya no pot ser igual a l'anterior", "ToastUserRootRequireName": "Cal introduir un nom d'usuari root" } - - From 61729881cb0bfca2f7a22da06597713acbc043b2 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 7 Dec 2024 16:52:31 -0700 Subject: [PATCH 150/762] Change: no compression when downloading library item as zip file --- server/utils/zipHelpers.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/utils/zipHelpers.js b/server/utils/zipHelpers.js index c1617272..44b65296 100644 --- a/server/utils/zipHelpers.js +++ b/server/utils/zipHelpers.js @@ -7,7 +7,7 @@ module.exports.zipDirectoryPipe = (path, filename, res) => { res.attachment(filename) const archive = archiver('zip', { - zlib: { level: 9 } // Sets the compression level. + zlib: { level: 0 } // Sets the compression level. }) // listen for all archive data to be written @@ -49,4 +49,4 @@ module.exports.zipDirectoryPipe = (path, filename, res) => { archive.finalize() }) -} \ No newline at end of file +} From a8ab8badd5c42e1794715a370b6a8ae60c6b8652 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 8 Dec 2024 09:23:39 +0200 Subject: [PATCH 151/762] always set req.originalHostPrefix --- server/Server.js | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/server/Server.js b/server/Server.js index dfcb474a..79598275 100644 --- a/server/Server.js +++ b/server/Server.js @@ -251,18 +251,17 @@ class Server { const router = express.Router() // if RouterBasePath is set, modify all requests to include the base path - if (global.RouterBasePath) { - app.use((req, res, next) => { - const host = req.get('host') - const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' - const prefix = req.url.startsWith(global.RouterBasePath) ? global.RouterBasePath : '' - req.originalHostPrefix = `${protocol}://${host}${prefix}` - if (!req.url.startsWith(global.RouterBasePath)) { - req.url = `${global.RouterBasePath}${req.url}` - } - next() - }) - } + app.use((req, res, next) => { + const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath) + const host = req.get('host') + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : '' + req.originalHostPrefix = `${protocol}://${host}${prefix}` + if (!urlStartsWithRouterBasePath) { + req.url = `${global.RouterBasePath}${req.url}` + } + next() + }) app.use(global.RouterBasePath, router) app.disable('x-powered-by') From b38ce4173144a9d33330ac3b59fbf7faf8320292 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 8 Dec 2024 09:48:58 +0200 Subject: [PATCH 152/762] Remove xml cache from Feed object --- server/objects/Feed.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/server/objects/Feed.js b/server/objects/Feed.js index da76067d..ac50b899 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -29,9 +29,6 @@ class Feed { this.createdAt = null this.updatedAt = null - // Cached xml - this.xml = null - if (feed) { this.construct(feed) } @@ -202,7 +199,6 @@ class Feed { } this.updatedAt = Date.now() - this.xml = null } setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { @@ -297,7 +293,6 @@ class Feed { }) this.updatedAt = Date.now() - this.xml = null } setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { @@ -399,18 +394,14 @@ class Feed { }) this.updatedAt = Date.now() - this.xml = null } buildXml(originalHostPrefix) { - if (this.xml) return this.xml - var rssfeed = new RSS(this.meta.getRSSData(originalHostPrefix)) this.episodes.forEach((ep) => { rssfeed.item(ep.getRSSData(originalHostPrefix)) }) - this.xml = rssfeed.xml() - return this.xml + return rssfeed.xml() } getAuthorsStringFromLibraryItems(libraryItems) { From 5646466aa371cc03f12496cd0a1d28de34839734 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Dec 2024 08:05:33 -0600 Subject: [PATCH 153/762] Update JSDocs for feeds endpoints --- server/managers/RssFeedManager.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 8984a39b..583f0bb6 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -1,3 +1,4 @@ +const { Request, Response } = require('express') const Path = require('path') const Logger = require('../Logger') @@ -77,6 +78,12 @@ class RssFeedManager { return Database.feedModel.findByPkOld(id) } + /** + * GET: /feed/:slug + * + * @param {Request} req + * @param {Response} res + */ async getFeed(req, res) { const feed = await this.findFeedBySlug(req.params.slug) if (!feed) { @@ -167,6 +174,12 @@ class RssFeedManager { res.send(xml) } + /** + * GET: /feed/:slug/item/:episodeId/* + * + * @param {Request} req + * @param {Response} res + */ async getFeedItem(req, res) { const feed = await this.findFeedBySlug(req.params.slug) if (!feed) { @@ -183,6 +196,12 @@ class RssFeedManager { res.sendFile(episodePath) } + /** + * GET: /feed/:slug/cover* + * + * @param {Request} req + * @param {Response} res + */ async getFeedCover(req, res) { const feed = await this.findFeedBySlug(req.params.slug) if (!feed) { From f7b7b85673fb8a5ac1a9b9c09e1bb686aa7d2f90 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Dec 2024 08:19:23 -0600 Subject: [PATCH 154/762] Add v2.17.5 migration to changelog --- server/migrations/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index f46cd4ae..f4992432 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -10,3 +10,4 @@ Please add a record of every database migration that you create to this file. Th | v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | | v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration | | v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | +| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables | From 57906540fef30b2b8801e4abbf38ca12d7307f9f Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Dec 2024 08:57:45 -0600 Subject: [PATCH 155/762] Add:Server setting to allow iframe & update UI to differentiate web client settings #3684 --- client/pages/config/index.vue | 47 ++++++++++++++--------- client/store/index.js | 11 +++--- client/strings/en-us.json | 2 + server/Server.js | 3 +- server/controllers/MiscController.js | 5 ++- server/objects/settings/ServerSettings.js | 8 ++++ 6 files changed, 49 insertions(+), 27 deletions(-) diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 1f0d61eb..bbb75b93 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -42,11 +42,6 @@
-
- -

{{ $strings.LabelSettingsChromecastSupport }}

-
-

{{ $strings.HeaderSettingsScanner }}

@@ -94,6 +89,20 @@

+ +
+

{{ $strings.HeaderSettingsWebClient }}

+
+ +
+ +

{{ $strings.LabelSettingsChromecastSupport }}

+
+ +
+ +

{{ $strings.LabelSettingsAllowIframe }}

+
@@ -324,21 +333,21 @@ export default { }, updateServerSettings(payload) { this.updatingServerSettings = true - this.$store - .dispatch('updateServerSettings', payload) - .then(() => { - this.updatingServerSettings = false + this.$store.dispatch('updateServerSettings', payload).then((response) => { + this.updatingServerSettings = false - if (payload.language) { - // Updating language after save allows for re-rendering - this.$setLanguageCode(payload.language) - } - }) - .catch((error) => { - console.error('Failed to update server settings', error) - this.updatingServerSettings = false - this.$toast.error(this.$strings.ToastFailedToUpdate) - }) + if (response.error) { + console.error('Failed to update server settins', response.error) + this.$toast.error(response.error) + this.initServerSettings() + return + } + + if (payload.language) { + // Updating language after save allows for re-rendering + this.$setLanguageCode(payload.language) + } + }) }, initServerSettings() { this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {} diff --git a/client/store/index.js b/client/store/index.js index acd03eb4..2f2201b6 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -72,16 +72,17 @@ export const actions = { return this.$axios .$patch('/api/settings', updatePayload) .then((result) => { - if (result.success) { + if (result.serverSettings) { commit('setServerSettings', result.serverSettings) - return true - } else { - return false } + return result }) .catch((error) => { console.error('Failed to update server settings', error) - return false + const errorMsg = error.response?.data || 'Unknown error' + return { + error: errorMsg + } }) }, checkForUpdate({ commit }) { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 75069cd3..805e8f48 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -190,6 +190,7 @@ "HeaderSettingsExperimental": "Experimental Features", "HeaderSettingsGeneral": "General", "HeaderSettingsScanner": "Scanner", + "HeaderSettingsWebClient": "Web Client", "HeaderSleepTimer": "Sleep Timer", "HeaderStatsLargestItems": "Largest Items", "HeaderStatsLongestItems": "Longest Items (hrs)", @@ -542,6 +543,7 @@ "LabelServerYearReview": "Server Year in Review ({0})", "LabelSetEbookAsPrimary": "Set as primary", "LabelSetEbookAsSupplementary": "Set as supplementary", + "LabelSettingsAllowIframe": "Allow embedding in an iframe", "LabelSettingsAudiobooksOnly": "Audiobooks only", "LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks", "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves", diff --git a/server/Server.js b/server/Server.js index 79598275..2f1220d8 100644 --- a/server/Server.js +++ b/server/Server.js @@ -53,7 +53,6 @@ class Server { global.RouterBasePath = ROUTER_BASE_PATH global.XAccel = process.env.USE_X_ACCEL global.AllowCors = process.env.ALLOW_CORS === '1' - global.AllowIframe = process.env.ALLOW_IFRAME === '1' global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1' if (!fs.pathExistsSync(global.ConfigPath)) { @@ -195,7 +194,7 @@ class Server { const app = express() app.use((req, res, next) => { - if (!global.AllowIframe) { + if (!global.ServerSettings.allowIframe) { // Prevent clickjacking by disallowing iframes res.setHeader('Content-Security-Policy', "frame-ancestors 'self'") } diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 2a87f2fe..b35619b7 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -126,6 +126,10 @@ class MiscController { if (!isObject(settingsUpdate)) { return res.status(400).send('Invalid settings update object') } + if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') { + Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment') + return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment') + } const madeUpdates = Database.serverSettings.update(settingsUpdate) if (madeUpdates) { @@ -137,7 +141,6 @@ class MiscController { } } return res.json({ - success: true, serverSettings: Database.serverSettings.toJSONForBrowser() }) } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index ff28027f..29913e44 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -24,6 +24,7 @@ class ServerSettings { // Security/Rate limits this.rateLimitLoginRequests = 10 this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes + this.allowIframe = false // Backups this.backupPath = Path.join(global.MetadataPath, 'backups') @@ -99,6 +100,7 @@ class ServerSettings { this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10 this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes + this.allowIframe = !!settings.allowIframe this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups') this.backupSchedule = settings.backupSchedule || false @@ -190,6 +192,11 @@ class ServerSettings { Logger.info(`[ServerSettings] Using backup path from environment variable ${process.env.BACKUP_PATH}`) this.backupPath = process.env.BACKUP_PATH } + + if (process.env.ALLOW_IFRAME === '1' && !this.allowIframe) { + Logger.info(`[ServerSettings] Using allowIframe from environment variable`) + this.allowIframe = true + } } toJSON() { @@ -207,6 +214,7 @@ class ServerSettings { metadataFileFormat: this.metadataFileFormat, rateLimitLoginRequests: this.rateLimitLoginRequests, rateLimitLoginWindow: this.rateLimitLoginWindow, + allowIframe: this.allowIframe, backupPath: this.backupPath, backupSchedule: this.backupSchedule, backupsToKeep: this.backupsToKeep, From 5f72e30e63884c731e520849be60f224d30a2278 Mon Sep 17 00:00:00 2001 From: Clara Papke Date: Fri, 6 Dec 2024 16:56:14 +0000 Subject: [PATCH 156/762] Translated using Weblate (German) Currently translated at 100.0% (1074 of 1074 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 865065aa..d3a10ead 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Zeige player Einstellungen", "LabelViewQueue": "Player-Warteschlange anzeigen", "LabelVolume": "Lautstärke", + "LabelWebRedirectURLsDescription": "Autorisieren Sie diese URLs bei ihrem OAuth-Anbieter, um die Weiterleitung zurück zur Webanwendung nach dem Login zu ermöglichen:", + "LabelWebRedirectURLsSubfolder": "Unterordner für Weiterleitung-URLs", "LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelXBooks": "{0} Bücher", "LabelXItems": "{0} Medien", From e6d754113e95f780a3b18dd5be555da164048c76 Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Fri, 6 Dec 2024 10:34:22 +0000 Subject: [PATCH 157/762] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1074 of 1074 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, 2 insertions(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index 448bbf4c..f2342636 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Переглянути налаштування програвача", "LabelViewQueue": "Переглянути чергу відтворення", "LabelVolume": "Гучність", + "LabelWebRedirectURLsDescription": "Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:", + "LabelWebRedirectURLsSubfolder": "Підпапка для Redirect URL", "LabelWeekdaysToRun": "Виконувати у дні", "LabelXBooks": "{0} книг", "LabelXItems": "{0} елементів", From 8aaf62f2433aca7689e675a9c63b556ee19728e5 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Fri, 6 Dec 2024 10:03:47 +0000 Subject: [PATCH 158/762] Translated using Weblate (Slovenian) Currently translated at 100.0% (1074 of 1074 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, 2 insertions(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index e80ac8b2..58500f9f 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Ogled nastavitev predvajalnika", "LabelViewQueue": "Ogled čakalno vrsto predvajalnika", "LabelVolume": "Glasnost", + "LabelWebRedirectURLsDescription": "Avtorizirajte URL-je pri svojem ponudniku OAuth ter s tem omogočite preusmeritev nazaj v spletno aplikacijo po prijavi:", + "LabelWebRedirectURLsSubfolder": "Podmapa za URL-je preusmeritve", "LabelWeekdaysToRun": "Delovni dnevi predvajanja", "LabelXBooks": "{0} knjig", "LabelXItems": "{0} elementov", From 190a1000d9b5909b5bcd953f32f39fa8f261ecb9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Dec 2024 09:03:05 -0600 Subject: [PATCH 159/762] Version bump v2.17.5 --- 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 e4e3236c..807976bd 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.4", + "version": "2.17.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.4", + "version": "2.17.5", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index ea191901..6f9d9d44 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.4", + "version": "2.17.5", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 10db84ea..efa917dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.4", + "version": "2.17.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.4", + "version": "2.17.5", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index c122240a..2e9c9709 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.4", + "version": "2.17.5", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 4610e58337eb3953a7c6efd533c6d0ae36722d45 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 9 Dec 2024 17:24:21 -0600 Subject: [PATCH 160/762] Update:Home shelf labels use h2 tag, play & edit buttons overlaying item page updated to button tag with aria-label for accessibility #3699 --- client/components/app/BookShelfCategorized.vue | 2 +- client/pages/item/_id/index.vue | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index a977dd21..94b2e4ba 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -17,7 +17,7 @@
diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 1baf521c..2e7e601c 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -12,12 +12,12 @@
-
+
+
- edit +
@@ -87,7 +87,7 @@ - error + error {{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }} From c5c3aab130ccf67316d61300b131752a41fb187f Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 10 Dec 2024 17:19:47 -0600 Subject: [PATCH 161/762] Update:Accessibility for buttons on item page, context menu dropdown, library filter/sort #3699 --- .../controls/LibraryFilterSelect.vue | 30 ++++++++++--------- .../components/controls/LibrarySortSelect.vue | 10 +++---- client/components/ui/ContextMenuDropdown.vue | 16 +++++----- client/components/ui/IconBtn.vue | 5 ++-- client/components/ui/LibrariesDropdown.vue | 6 ++-- client/components/ui/ReadIconBtn.vue | 2 +- client/pages/item/_id/index.vue | 8 ++--- client/strings/en-us.json | 2 ++ 8 files changed, 42 insertions(+), 37 deletions(-) diff --git a/client/components/controls/LibraryFilterSelect.vue b/client/components/controls/LibraryFilterSelect.vue index 2d9ced5a..c600d80f 100644 --- a/client/components/controls/LibraryFilterSelect.vue +++ b/client/components/controls/LibraryFilterSelect.vue @@ -1,28 +1,30 @@