From 1d3ad38187708ca0c6efefce2d04b82820f19522 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 30 Sep 2023 18:08:03 +0000 Subject: [PATCH 01/84] [cleanup] refactor OpenLib sort into getOpenLibResult --- server/finders/BookFinder.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 96735cc9..debac709 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -136,6 +136,10 @@ class BookFinder { if (!booksFiltered.length && books.length) { if (this.verbose) Logger.debug(`Search has ${books.length} matches, but no close title matches`) } + booksFiltered.sort((a, b) => { + return a.totalDistance - b.totalDistance + }) + return booksFiltered } @@ -282,12 +286,6 @@ class BookFinder { } } - if (provider === 'openlibrary') { - books.sort((a, b) => { - return a.totalDistance - b.totalDistance - }) - } - return books } From 46b0b3a6efb7f31ac7d67ee5fff6dcbd2ff28542 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 1 Oct 2023 08:42:47 +0000 Subject: [PATCH 02/84] [cleanup] Refactor candidates logic to separate class --- server/finders/BookFinder.js | 113 ++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 47 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index debac709..b30510f2 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -183,35 +183,67 @@ class BookFinder { return books } - addTitleCandidate(title, candidates) { - // Main variant - const cleanTitle = this.cleanTitleForCompares(title).trim() - if (!cleanTitle) return - candidates.add(cleanTitle) + static TitleCandidates = class { - let candidate = cleanTitle + constructor(bookFinder, cleanAuthor) { + this.bookFinder = bookFinder + this.candidates = new Set() + this.cleanAuthor = cleanAuthor + } - // Remove subtitle - candidate = candidate.replace(/([,:;_]| by ).*/g, "").trim() - if (candidate) - candidates.add(candidate) + add(title) { + const titleTransformers = [ + [/([,:;_]| by ).*/g, ''], // Remove subtitle + [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers + [/(^| )\d+k(bps)?( |$)/, ' '], // Remove bitrate + [/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''] // Remove edition + ] - // Remove preceding/trailing numbers - candidate = candidate.replace(/^\d+ | \d+$/g, "").trim() - if (candidate) - candidates.add(candidate) + // Main variant + const cleanTitle = this.bookFinder.cleanTitleForCompares(title).trim() + if (!cleanTitle) return + this.candidates.add(cleanTitle) - // Remove bitrate - candidate = candidate.replace(/(^| )\d+k(bps)?( |$)/, " ").trim() - if (candidate) - candidates.add(candidate) + let candidate = cleanTitle - // Remove edition - candidate = candidate.replace(/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/, "").trim() - if (candidate) - candidates.add(candidate) + for (const transformer of titleTransformers) { + candidate = candidate.replace(transformer[0], transformer[1]).trim() + if (candidate) { + this.candidates.add(candidate) + } + } + } + + get size() { + return this.candidates.size + } + + getCandidates() { + var candidates = [...this.candidates] + candidates.sort((a, b) => { + // Candidates that include the author are likely low quality + const includesAuthorDiff = !b.includes(this.cleanAuthor) - !a.includes(this.cleanAuthor) + if (includesAuthorDiff) return includesAuthorDiff + // Candidates that include only digits are also likely low quality + const onlyDigits = /^\d+$/ + const includesOnlyDigitsDiff = !onlyDigits.test(b) - !onlyDigits.test(a) + if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff + // Start with longer candidaets, as they are likely more specific + const lengthDiff = b.length - a.length + if (lengthDiff) return lengthDiff + return b.localeCompare(a) + }) + Logger.debug(`[${this.constructor.name}] Found ${candidates.length} fuzzy title candidates`) + Logger.debug(candidates) + return candidates + } + + delete(title) { + return this.candidates.delete(title) + } } + /** * Search for books including fuzzy searches * @@ -240,46 +272,33 @@ class BookFinder { title = title.trim().toLowerCase() author = author.trim().toLowerCase() + const cleanAuthor = this.cleanAuthorForCompares(author) + // Now run up to maxFuzzySearches fuzzy searches - let candidates = new Set() - let cleanedAuthor = this.cleanAuthorForCompares(author) - this.addTitleCandidate(title, candidates) + let titleCandidates = new BookFinder.TitleCandidates(this, cleanAuthor) + titleCandidates.add(title) // remove parentheses and their contents, and replace with a separator const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}/g, " - ") // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) for (const titlePart of titleParts) { - this.addTitleCandidate(titlePart, candidates) + titleCandidates.add(titlePart) } // We already searched for original title - if (author == cleanedAuthor) candidates.delete(title) - if (candidates.size > 0) { - candidates = [...candidates] - candidates.sort((a, b) => { - // Candidates that include the author are likely low quality - const includesAuthorDiff = !b.includes(cleanedAuthor) - !a.includes(cleanedAuthor) - if (includesAuthorDiff) return includesAuthorDiff - // Candidates that include only digits are also likely low quality - const onlyDigits = /^\d+$/ - const includesOnlyDigitsDiff = !onlyDigits.test(b) - !onlyDigits.test(a) - if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff - // Start with longer candidaets, as they are likely more specific - const lengthDiff = b.length - a.length - if (lengthDiff) return lengthDiff - return b.localeCompare(a) - }) - Logger.debug(`[BookFinder] Found ${candidates.length} fuzzy title candidates`, candidates) - for (const candidate of candidates) { + if (author == cleanAuthor) titleCandidates.delete(title) + if (titleCandidates.size > 0) { + titleCandidates = titleCandidates.getCandidates() + for (const titleCandidate of titleCandidates) { if (++numFuzzySearches > maxFuzzySearches) return books - books = await this.runSearch(candidate, cleanedAuthor, provider, asin, maxTitleDistance, maxAuthorDistance) + books = await this.runSearch(titleCandidate, cleanAuthor, provider, asin, maxTitleDistance, maxAuthorDistance) if (books.length) break } if (!books.length) { // Now try searching without the author - for (const candidate of candidates) { + for (const titleCandidate of titleCandidates) { if (++numFuzzySearches > maxFuzzySearches) return books - books = await this.runSearch(candidate, '', provider, asin, maxTitleDistance, maxAuthorDistance) + books = await this.runSearch(titleCandidate, '', provider, asin, maxTitleDistance, maxAuthorDistance) if (books.length) break } } From 73bb73a04aa04fb123a9ff1ef8e96e3ce5c2201e Mon Sep 17 00:00:00 2001 From: Alistair Bahr Date: Mon, 2 Oct 2023 09:25:34 +0200 Subject: [PATCH 03/84] make force transcode apply to all "ffmpeg error 1" --- server/objects/Stream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/objects/Stream.js b/server/objects/Stream.js index c8452ac3..1860c47f 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -340,7 +340,7 @@ class Stream extends EventEmitter { Logger.error('Ffmpeg Err', '"' + err.message + '"') // Temporary workaround for https://github.com/advplyr/audiobookshelf/issues/172 - const aacErrorMsg = 'ffmpeg exited with code 1: Could not write header for output file #0 (incorrect codec parameters ?)' + const aacErrorMsg = 'ffmpeg exited with code 1:' if (audioCodec === 'copy' && this.isAACEncodable && err.message && err.message.startsWith(aacErrorMsg)) { Logger.info(`[Stream] Re-attempting stream with AAC encode`) this.transcodeOptions.forceAAC = true From 4352989242bf9a25b092deafb7c50b5e17a0eadc Mon Sep 17 00:00:00 2001 From: Alistair1231 Date: Mon, 2 Oct 2023 09:30:57 +0200 Subject: [PATCH 04/84] update comment to include second issue that is adressed by change --- server/objects/Stream.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/objects/Stream.js b/server/objects/Stream.js index 1860c47f..9f5ff59b 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -339,7 +339,7 @@ class Stream extends EventEmitter { } else { Logger.error('Ffmpeg Err', '"' + err.message + '"') - // Temporary workaround for https://github.com/advplyr/audiobookshelf/issues/172 + // Temporary workaround for https://github.com/advplyr/audiobookshelf/issues/172 and https://github.com/advplyr/audiobookshelf/issues/2157 const aacErrorMsg = 'ffmpeg exited with code 1:' if (audioCodec === 'copy' && this.isAACEncodable && err.message && err.message.startsWith(aacErrorMsg)) { Logger.info(`[Stream] Re-attempting stream with AAC encode`) @@ -435,4 +435,4 @@ class Stream extends EventEmitter { return newAudioTrack } } -module.exports = Stream \ No newline at end of file +module.exports = Stream From 7c9631c1b0dbff6e15a33c72945ef69449225e0d Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 2 Oct 2023 08:34:56 -0500 Subject: [PATCH 05/84] Update server/objects/Stream.js --- server/objects/Stream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/objects/Stream.js b/server/objects/Stream.js index 9f5ff59b..115bb96e 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -341,7 +341,7 @@ class Stream extends EventEmitter { // Temporary workaround for https://github.com/advplyr/audiobookshelf/issues/172 and https://github.com/advplyr/audiobookshelf/issues/2157 const aacErrorMsg = 'ffmpeg exited with code 1:' - if (audioCodec === 'copy' && this.isAACEncodable && err.message && err.message.startsWith(aacErrorMsg)) { + if (audioCodec === 'copy' && this.isAACEncodable && err.message?.startsWith(aacErrorMsg)) { Logger.info(`[Stream] Re-attempting stream with AAC encode`) this.transcodeOptions.forceAAC = true this.reset(this.startTime) From a3a8937ba37b439dd40c406005c4bb280af0b12c Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 2 Oct 2023 17:09:12 -0500 Subject: [PATCH 06/84] Fix:Crash when searching for cover without an author #2174 --- server/controllers/LibraryItemController.js | 1 - server/controllers/SearchController.js | 2 +- server/finders/BookFinder.js | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 4aff7a13..2b25474e 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -259,7 +259,6 @@ class LibraryItemController { // Check if library item media has a cover path if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) { - Logger.debug(`[LibraryItemController] getCover: Library item "${req.params.id}" has no cover path`) return res.sendStatus(404) } diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 2749016c..93587bc4 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -26,7 +26,7 @@ class SearchController { let results = null if (podcast) results = await PodcastFinder.findCovers(query.title) - else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || null) + else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || '') res.json({ results }) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 96735cc9..bcf8ea06 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -234,7 +234,7 @@ class BookFinder { if (!books.length && maxFuzzySearches > 0) { // normalize title and author title = title.trim().toLowerCase() - author = author.trim().toLowerCase() + author = author?.trim().toLowerCase() || '' // Now run up to maxFuzzySearches fuzzy searches let candidates = new Set() From 733ad52684cfa0b88e6d7aa33d31a01f87f374e3 Mon Sep 17 00:00:00 2001 From: MarshDeer Date: Mon, 2 Oct 2023 19:42:25 -0300 Subject: [PATCH 07/84] Typo correction --- client/strings/en-us.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index ae018d4b..15812766 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -417,7 +417,7 @@ "LabelSettingsPreferAudioMetadata": "Prefer audio metadata", "LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tags will be used for book details over folder names", "LabelSettingsPreferMatchedMetadata": "Prefer matched metadata", - "LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.", + "LabelSettingsPreferMatchedMetadataHelp": "Matched data will override item details when using Quick Match. By default Quick Match will only fill in missing details.", "LabelSettingsPreferOPFMetadata": "Prefer OPF metadata", "LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names", "LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN", @@ -713,4 +713,4 @@ "ToastSocketFailedToConnect": "Socket failed to connect", "ToastUserDeleteFailed": "Failed to delete user", "ToastUserDeleteSuccess": "User deleted" -} \ No newline at end of file +} From 8e97be8ef41fc3f11aa4b9faf07eb1ae9bda151c Mon Sep 17 00:00:00 2001 From: MarshDeer Date: Mon, 2 Oct 2023 19:42:42 -0300 Subject: [PATCH 08/84] Spanish translation completed --- client/strings/es.json | 454 ++++++++++++++++++++--------------------- 1 file changed, 227 insertions(+), 227 deletions(-) diff --git a/client/strings/es.json b/client/strings/es.json index b872ca4e..d2ea30c7 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -20,11 +20,11 @@ "ButtonCreate": "Crear", "ButtonCreateBackup": "Crear Respaldo", "ButtonDelete": "Eliminar", - "ButtonDownloadQueue": "Queue", + "ButtonDownloadQueue": "Fila", "ButtonEdit": "Editar", - "ButtonEditChapters": "Editar Capitulo", + "ButtonEditChapters": "Editar Capítulo", "ButtonEditPodcast": "Editar Podcast", - "ButtonForceReScan": "Forzar Re-Escanear", + "ButtonForceReScan": "Forzar Re-Escaneo", "ButtonFullPath": "Ruta de Acceso Completa", "ButtonHide": "Esconder", "ButtonHome": "Inicio", @@ -34,13 +34,13 @@ "ButtonLogout": "Cerrar Sesión", "ButtonLookup": "Buscar", "ButtonManageTracks": "Administrar Pistas de Audio", - "ButtonMapChapterTitles": "Map Chapter Titles", + "ButtonMapChapterTitles": "Asignar Títulos a Capítulos", "ButtonMatchAllAuthors": "Encontrar Todos los Autores", "ButtonMatchBooks": "Encontrar Libros", "ButtonNevermind": "Olvidar", "ButtonOk": "Ok", "ButtonOpenFeed": "Abrir Fuente", - "ButtonOpenManager": "Open Manager", + "ButtonOpenManager": "Abrir Editor", "ButtonPlay": "Reproducir", "ButtonPlaying": "Reproduciendo", "ButtonPlaylists": "Listas de Reproducción", @@ -55,7 +55,7 @@ "ButtonRemoveAll": "Remover Todos", "ButtonRemoveAllLibraryItems": "Remover Todos los Elementos de la Biblioteca", "ButtonRemoveFromContinueListening": "Remover de Continuar Escuchando", - "ButtonRemoveFromContinueReading": "Remove from Continue Reading", + "ButtonRemoveFromContinueReading": "Remover de Continuar Leyendo", "ButtonRemoveSeriesFromContinueSeries": "Remover Serie de Continuar Series", "ButtonReScan": "Re-Escanear", "ButtonReset": "Reiniciar", @@ -74,7 +74,7 @@ "ButtonStartM4BEncode": "Iniciar Codificación M4B", "ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata", "ButtonSubmit": "Enviar", - "ButtonTest": "Test", + "ButtonTest": "Prueba", "ButtonUpload": "Subir", "ButtonUploadBackup": "Subir Respaldo", "ButtonUploadCover": "Subir Portada", @@ -98,12 +98,12 @@ "HeaderCurrentDownloads": "Descargando Actualmente", "HeaderDetails": "Detalles", "HeaderDownloadQueue": "Lista de Descarga", - "HeaderEbookFiles": "Ebook Files", + "HeaderEbookFiles": "Archivos de Ebook", "HeaderEmail": "Email", - "HeaderEmailSettings": "Email Settings", + "HeaderEmailSettings": "Opciones de Email", "HeaderEpisodes": "Episodios", - "HeaderEreaderDevices": "Ereader Devices", - "HeaderEreaderSettings": "Ereader Settings", + "HeaderEreaderDevices": "Dispositivos Ereader", + "HeaderEreaderSettings": "Opciones de Ereader", "HeaderFiles": "Elemento", "HeaderFindChapters": "Buscar Capitulo", "HeaderIgnoredFiles": "Ignorar Elemento", @@ -120,7 +120,7 @@ "HeaderLogs": "Logs", "HeaderManageGenres": "Administrar Géneros", "HeaderManageTags": "Administrar Etiquetas", - "HeaderMapDetails": "Map details", + "HeaderMapDetails": "Asignar Detalles", "HeaderMatch": "Encontrar", "HeaderMetadataToEmbed": "Metadatos para Insertar", "HeaderNewAccount": "Nueva Cuenta", @@ -129,7 +129,7 @@ "HeaderOpenRSSFeed": "Abrir fuente RSS", "HeaderOtherFiles": "Otros Archivos", "HeaderPermissions": "Permisos", - "HeaderPlayerQueue": "Player Queue", + "HeaderPlayerQueue": "Fila del Reproductor", "HeaderPlaylist": "Lista de Reproducción", "HeaderPlaylistItems": "Elementos de Lista de Reproducción", "HeaderPodcastsToAdd": "Podcasts para agregar", @@ -139,13 +139,13 @@ "HeaderRSSFeedGeneral": "Detalles RSS", "HeaderRSSFeedIsOpen": "Fuente RSS esta abierta", "HeaderRSSFeeds": "RSS Feeds", - "HeaderSavedMediaProgress": "Guardar Progreso de multimedia", + "HeaderSavedMediaProgress": "Guardar Progreso de Multimedia", "HeaderSchedule": "Horario", "HeaderScheduleLibraryScans": "Programar Escaneo Automático de Biblioteca", "HeaderSession": "Session", "HeaderSetBackupSchedule": "Programar Respaldo", "HeaderSettings": "Configuraciones", - "HeaderSettingsDisplay": "Display", + "HeaderSettingsDisplay": "Interfaz", "HeaderSettingsExperimental": "Funciones Experimentales", "HeaderSettingsGeneral": "General", "HeaderSettingsScanner": "Escáner", @@ -156,21 +156,21 @@ "HeaderStatsRecentSessions": "Sesiones Recientes", "HeaderStatsTop10Authors": "Top 10 Autores", "HeaderStatsTop5Genres": "Top 5 Géneros", - "HeaderTableOfContents": "Table of Contents", + "HeaderTableOfContents": "Tabla de Contenidos", "HeaderTools": "Herramientas", "HeaderUpdateAccount": "Actualizar Cuenta", "HeaderUpdateAuthor": "Actualizar Autor", "HeaderUpdateDetails": "Actualizar Detalles", "HeaderUpdateLibrary": "Actualizar Biblioteca", "HeaderUsers": "Usuarios", - "HeaderYourStats": "Tus Estáticas", - "LabelAbridged": "Abridged", + "HeaderYourStats": "Tus Estadísticas", + "LabelAbridged": "Abreviado", "LabelAccountType": "Tipo de Cuenta", "LabelAccountTypeAdmin": "Administrador", "LabelAccountTypeGuest": "Invitado", "LabelAccountTypeUser": "Usuario", "LabelActivity": "Actividad", - "LabelAdded": "Added", + "LabelAdded": "Añadido", "LabelAddedAt": "Añadido", "LabelAddToCollection": "Añadido a la Colección", "LabelAddToCollectionBatch": "Se Añadieron {0} Libros a la Colección", @@ -186,38 +186,38 @@ "LabelAuthors": "Autores", "LabelAutoDownloadEpisodes": "Descargar Episodios Automáticamente", "LabelBackToUser": "Regresar a Usuario", - "LabelBackupLocation": "Backup Location", + "LabelBackupLocation": "Ubicación del Respaldo", "LabelBackupsEnableAutomaticBackups": "Habilitar Respaldo Automático", "LabelBackupsEnableAutomaticBackupsHelp": "Respaldo Guardado en /metadata/backups", "LabelBackupsMaxBackupSize": "Tamaño Máximo de Respaldos (en GB)", - "LabelBackupsMaxBackupSizeHelp": "Como protección contra una configuración errónea, los respaldos fallaran si se excede el tamaño configurado.", + "LabelBackupsMaxBackupSizeHelp": "Como protección contra una configuración errónea, los respaldos fallarán si se excede el tamaño configurado.", "LabelBackupsNumberToKeep": "Numero de respaldos para conservar", - "LabelBackupsNumberToKeepHelp": "Solamente 1 respaldo se removerá a la vez. Si tiene mas respaldos guardados, necesita removerlos manualmente.", + "LabelBackupsNumberToKeepHelp": "Solamente 1 respaldo se removerá a la vez. Si tiene mas respaldos guardados, debe removerlos manualmente.", "LabelBitrate": "Bitrate", "LabelBooks": "Libros", "LabelChangePassword": "Cambiar Contraseña", "LabelChannels": "Canales", - "LabelChapters": "Capitulos", - "LabelChaptersFound": "Capitulo Encontrado", - "LabelChapterTitle": "Titulo del Capitulo", - "LabelClosePlayer": "Close player", + "LabelChapters": "Capítulos", + "LabelChaptersFound": "Capítulo Encontrado", + "LabelChapterTitle": "Titulo del Capítulo", + "LabelClosePlayer": "Cerrar Reproductor", "LabelCodec": "Codec", - "LabelCollapseSeries": "Colapsar Series", - "LabelCollection": "Collection", + "LabelCollapseSeries": "Colapsar Serie", + "LabelCollection": "Colección", "LabelCollections": "Colecciones", "LabelComplete": "Completo", "LabelConfirmPassword": "Confirmar Contraseña", "LabelContinueListening": "Continuar Escuchando", - "LabelContinueReading": "Continue Reading", - "LabelContinueSeries": "Continuar Series", + "LabelContinueReading": "Continuar Leyendo", + "LabelContinueSeries": "Continuar Serie", "LabelCover": "Portada", "LabelCoverImageURL": "URL de Imagen de Portada", "LabelCreatedAt": "Creado", - "LabelCronExpression": "Cron Expression", + "LabelCronExpression": "Expresión de Cron", "LabelCurrent": "Actual", "LabelCurrently": "En este momento:", - "LabelCustomCronExpression": "Custom Cron Expression:", - "LabelDatetime": "Datetime", + "LabelCustomCronExpression": "Expresión de Cron Personalizada:", + "LabelDatetime": "Hora y Fecha", "LabelDescription": "Descripción", "LabelDeselectAll": "Deseleccionar Todos", "LabelDevice": "Dispositivo", @@ -225,19 +225,19 @@ "LabelDirectory": "Directorio", "LabelDiscFromFilename": "Disco a partir del Nombre del Archivo", "LabelDiscFromMetadata": "Disco a partir de Metadata", - "LabelDiscover": "Discover", + "LabelDiscover": "Descubrir", "LabelDownload": "Descargar", - "LabelDownloadNEpisodes": "Download {0} episodes", + "LabelDownloadNEpisodes": "Descargar {0} episodios", "LabelDuration": "Duración", "LabelDurationFound": "Duración Comprobada:", "LabelEbook": "Ebook", "LabelEbooks": "Ebooks", "LabelEdit": "Editar", "LabelEmail": "Email", - "LabelEmailSettingsFromAddress": "From Address", - "LabelEmailSettingsSecure": "Secure", - "LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)", - "LabelEmailSettingsTestAddress": "Test Address", + "LabelEmailSettingsFromAddress": "Remitente", + "LabelEmailSettingsSecure": "Seguridad", + "LabelEmailSettingsSecureHelp": "Si está activado, se usará TLS para conectarse al servidor. Si está apagado, se usará TLS si su servidor tiene soporte para la extensión STARTTLS. En la mayoría de los casos, puede dejar esta opción activada si se está conectando al puerto 465. Apáguela en el caso de usar los puertos 587 o 25. (de nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "Probar Dirección", "LabelEmbeddedCover": "Portada Integrada", "LabelEnable": "Habilitar", "LabelEnd": "Fin", @@ -256,13 +256,13 @@ "LabelFinished": "Terminado", "LabelFolder": "Carpeta", "LabelFolders": "Carpetas", - "LabelFontScale": "Font scale", + "LabelFontScale": "Tamaño de Fuente", "LabelFormat": "Formato", "LabelGenre": "Genero", "LabelGenres": "Géneros", "LabelHardDeleteFile": "Eliminar Definitivamente", - "LabelHasEbook": "Has ebook", - "LabelHasSupplementaryEbook": "Has supplementary ebook", + "LabelHasEbook": "Tiene Ebook", + "LabelHasSupplementaryEbook": "Tiene Ebook Suplementario", "LabelHost": "Host", "LabelHour": "Hora", "LabelIcon": "Icono", @@ -276,34 +276,34 @@ "LabelIntervalEvery2Hours": "Cada 2 Horas", "LabelIntervalEvery30Minutes": "Cada 30 minutos", "LabelIntervalEvery6Hours": "Cada 6 Horas", - "LabelIntervalEveryDay": "Cada Dia", + "LabelIntervalEveryDay": "Cada Día", "LabelIntervalEveryHour": "Cada Hora", - "LabelInvalidParts": "Partes Invalidas", - "LabelInvert": "Invert", + "LabelInvalidParts": "Partes Inválidas", + "LabelInvert": "Invertir", "LabelItem": "Elemento", "LabelLanguage": "Lenguaje", "LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor", - "LabelLastBookAdded": "Last Book Added", - "LabelLastBookUpdated": "Last Book Updated", - "LabelLastSeen": "Ultima Vez Visto", - "LabelLastTime": "Ultima Vez", - "LabelLastUpdate": "Ultima Actualización", - "LabelLayout": "Layout", - "LabelLayoutSinglePage": "Single page", - "LabelLayoutSplitPage": "Split page", + "LabelLastBookAdded": "Último Libro Agregado", + "LabelLastBookUpdated": "Último Libro Actualizado", + "LabelLastSeen": "Última Vez Visto", + "LabelLastTime": "Última Vez", + "LabelLastUpdate": "Última Actualización", + "LabelLayout": "Distribución", + "LabelLayoutSinglePage": "Una Página", + "LabelLayoutSplitPage": "Dos Páginas", "LabelLess": "Menos", "LabelLibrariesAccessibleToUser": "Bibliotecas Disponibles para el Usuario", "LabelLibrary": "Biblioteca", "LabelLibraryItem": "Elemento de Biblioteca", "LabelLibraryName": "Nombre de Biblioteca", "LabelLimit": "Limites", - "LabelLineSpacing": "Line spacing", + "LabelLineSpacing": "Interlineado", "LabelListenAgain": "Escuchar Otra Vez", "LabelLogLevelDebug": "Debug", - "LabelLogLevelInfo": "Info", + "LabelLogLevelInfo": "Información", "LabelLogLevelWarn": "Advertencia", "LabelLookForNewEpisodesAfterDate": "Buscar Nuevos Episodios a partir de esta Fecha", - "LabelMediaPlayer": "Media Player", + "LabelMediaPlayer": "Reproductor de Medios", "LabelMediaType": "Tipo de Multimedia", "LabelMetadataProvider": "Proveedor de Metadata", "LabelMetaTag": "Meta Tag", @@ -311,8 +311,8 @@ "LabelMinute": "Minuto", "LabelMissing": "Ausente", "LabelMissingParts": "Partes Ausentes", - "LabelMore": "Mas", - "LabelMoreInfo": "Mas Información", + "LabelMore": "Más", + "LabelMoreInfo": "Más Información", "LabelName": "Nombre", "LabelNarrator": "Narrador", "LabelNarrators": "Narradores", @@ -322,17 +322,17 @@ "LabelNewPassword": "Nueva Contraseña", "LabelNextBackupDate": "Fecha del Siguiente Respaldo", "LabelNextScheduledRun": "Próxima Ejecución Programada", - "LabelNoEpisodesSelected": "No episodes selected", + "LabelNoEpisodesSelected": "Ningún Episodio Seleccionado", "LabelNotes": "Notas", "LabelNotFinished": "No Terminado", - "LabelNotificationAppriseURL": "Apprise URL(s)", + "LabelNotificationAppriseURL": "URL(s) de Apprise", "LabelNotificationAvailableVariables": "Variables Disponibles", "LabelNotificationBodyTemplate": "Plantilla de Cuerpo", "LabelNotificationEvent": "Evento de Notificación", "LabelNotificationsMaxFailedAttempts": "Máximo de Intentos Fallidos", - "LabelNotificationsMaxFailedAttemptsHelp": "Las notificaciones se desactivan después de fallar este numero de veces", - "LabelNotificationsMaxQueueSize": "Tamaño máximo de la cola de notificación", - "LabelNotificationsMaxQueueSizeHelp": "Las notificaciones están limitadas a 1 por segundo. Las notificaciones serán ignorados si llegan al numero máximo de cola para prevenir spam de eventos.", + "LabelNotificationsMaxFailedAttemptsHelp": "Las notificaciones se desactivan después de fallar este número de veces", + "LabelNotificationsMaxQueueSize": "Tamaño máximo de la cola de notificaciones", + "LabelNotificationsMaxQueueSizeHelp": "Las notificaciones están limitadas a 1 por segundo. Las notificaciones serán ignoradas si llegan al numero máximo de cola para prevenir spam de eventos.", "LabelNotificationTitleTemplate": "Plantilla de Titulo", "LabelNotStarted": "Sin Iniciar", "LabelNumberOfBooks": "Numero de Libros", @@ -354,97 +354,97 @@ "LabelPodcast": "Podcast", "LabelPodcasts": "Podcasts", "LabelPodcastType": "Tipo Podcast", - "LabelPort": "Port", + "LabelPort": "Puerto", "LabelPrefixesToIgnore": "Prefijos para Ignorar (no distingue entre mayúsculas y minúsculas.)", - "LabelPreventIndexing": "Evite que su fuente sea indexado por iTunes y Google podcast directories", - "LabelPrimaryEbook": "Primary ebook", + "LabelPreventIndexing": "Evite que su fuente sea indexada por los directorios de podcasts de iTunes y Google", + "LabelPrimaryEbook": "Ebook principal", "LabelProgress": "Progreso", "LabelProvider": "Proveedor", "LabelPubDate": "Fecha de Publicación", "LabelPublisher": "Editor", "LabelPublishYear": "Año de Publicación", - "LabelRead": "Read", - "LabelReadAgain": "Read Again", - "LabelReadEbookWithoutProgress": "Read ebook without keeping progress", - "LabelRecentlyAdded": "Agregado Reciente", + "LabelRead": "Leído", + "LabelReadAgain": "Volver a leer", + "LabelReadEbookWithoutProgress": "Leer Ebook sin guardar progreso", + "LabelRecentlyAdded": "Agregado Recientemente", "LabelRecentSeries": "Series Recientes", "LabelRecommended": "Recomendados", - "LabelRegion": "Region", + "LabelRegion": "Región", "LabelReleaseDate": "Fecha de Estreno", "LabelRemoveCover": "Remover Portada", - "LabelRSSFeedCustomOwnerEmail": "Custom owner Email", - "LabelRSSFeedCustomOwnerName": "Custom owner Name", + "LabelRSSFeedCustomOwnerEmail": "Email de dueño personalizado", + "LabelRSSFeedCustomOwnerName": "Nombre de dueño personalizado", "LabelRSSFeedOpen": "Fuente RSS Abierta", - "LabelRSSFeedPreventIndexing": "Prevenir Indaxación", + "LabelRSSFeedPreventIndexing": "Prevenir Indexado", "LabelRSSFeedSlug": "Fuente RSS Slug", "LabelRSSFeedURL": "URL de Fuente RSS", "LabelSearchTerm": "Buscar Termino", "LabelSearchTitle": "Buscar Titulo", - "LabelSearchTitleOrASIN": "Buscar Titulo o ASIN", + "LabelSearchTitleOrASIN": "Buscar Título o ASIN", "LabelSeason": "Temporada", - "LabelSelectAllEpisodes": "Select all episodes", - "LabelSelectEpisodesShowing": "Select {0} episodes showing", - "LabelSendEbookToDevice": "Send Ebook to...", + "LabelSelectAllEpisodes": "Seleccionar todos los episodios", + "LabelSelectEpisodesShowing": "Seleccionar los {0} episodios visibles", + "LabelSendEbookToDevice": "Enviar Ebook a...", "LabelSequence": "Secuencia", "LabelSeries": "Series", "LabelSeriesName": "Nombre de la Serie", "LabelSeriesProgress": "Progreso de la Serie", - "LabelSetEbookAsPrimary": "Set as primary", - "LabelSetEbookAsSupplementary": "Set as supplementary", - "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": "Diseño Skeumorphic con Estantes de Madera", + "LabelSetEbookAsPrimary": "Establecer como primario", + "LabelSetEbookAsSupplementary": "Establecer como suplementario", + "LabelSettingsAudiobooksOnly": "Sólo Audiolibros", + "LabelSettingsAudiobooksOnlyHelp": "Al activar esta opción se ignorarán los archivos de ebook a menos de que estén dentro de la carpeta de un audiolibro, en cuyo caso se marcarán como ebooks suplementarios", + "LabelSettingsBookshelfViewHelp": "Diseño Esqueuomorfo con Estantes de Madera", "LabelSettingsChromecastSupport": "Soporte para Chromecast", "LabelSettingsDateFormat": "Formato de Fecha", "LabelSettingsDisableWatcher": "Deshabilitar Watcher", "LabelSettingsDisableWatcherForLibrary": "Deshabilitar Watcher de Carpetas para esta biblioteca", - "LabelSettingsDisableWatcherHelp": "Deshabilitar la función automática de agregar/actualizar los elementos, cuando se detecta cambio en los archivos. *Require Reiniciar el Servidor", - "LabelSettingsEnableWatcher": "Enable Watcher", - "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library", - "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart", + "LabelSettingsDisableWatcherHelp": "Deshabilitar la función de agregar/actualizar elementos automáticamente cuando se detectan cambios en los archivos. *Require Reiniciar el Servidor", + "LabelSettingsEnableWatcher": "Habilitar Watcher", + "LabelSettingsEnableWatcherForLibrary": "Habilitar Watcher para la carpeta de esta biblioteca", + "LabelSettingsEnableWatcherHelp": "Permite agregar/actualizar elementos automáticamente cuando se detectan cambios en los archivos. *Requires server restart", "LabelSettingsExperimentalFeatures": "Funciones Experimentales", - "LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo sobre las que esperamos sus comentarios y experiencia. Haga click aquí para abrir una conversación en Github.", + "LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo que se beneficiarían de sus comentarios y experiencias de prueba. Haga click aquí para abrir una conversación en Github.", "LabelSettingsFindCovers": "Buscar Portadas", - "LabelSettingsFindCoversHelp": "Si tu audiolibro no tiene una portada incluida o la portada no esta dentro de la carpeta, el escaneador tratara de encontrar una portada.
Nota: Esto extenderá el tiempo de escaneo", - "LabelSettingsHideSingleBookSeries": "Hide single book series", - "LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.", - "LabelSettingsHomePageBookshelfView": "La pagina de inicio usa la vista de librero", - "LabelSettingsLibraryBookshelfView": "La biblioteca usa la vista de librero", - "LabelSettingsOverdriveMediaMarkers": "Usar Markers de multimedia en Overdrive para estos capítulos", - "LabelSettingsOverdriveMediaMarkersHelp": "Archivos MP3 de Overdrive vienen con capítulos con tiempos incrustados como metadata personalizada. Habilitar esto utilizará estas etiquetas para los tiempos de los capítulos automáticamente.", - "LabelSettingsParseSubtitles": "Parse subtitles", - "LabelSettingsParseSubtitlesHelp": "Extraiga subtítulos de los nombres de las carpetas de los audiolibros.
Los subtítulos deben estar separados por \" - \"
ejemplo. \"Titulo Libro - Este Subtitulo\" tiene el subtitulo \"Este Subtitulo\"", - "LabelSettingsPreferAudioMetadata": "Preferir metadata del audio", - "LabelSettingsPreferAudioMetadataHelp": "Archivos de Audio ID3 meta tags se utilizarán para detalles de libros en vez de los nombres de carpetas", - "LabelSettingsPreferMatchedMetadata": "Prefer matched metadata", - "LabelSettingsPreferMatchedMetadataHelp": "Los datos coincidentes anularán los detalles del elemento cuando se utilice Quick Match. Por defecto, Quick Match solo completará los detalles faltantes.", - "LabelSettingsPreferOPFMetadata": "Preferir OPF metadata", - "LabelSettingsPreferOPFMetadataHelp": "Los archivos de metadata OPF serán usados para los detalles del libro en vez de el nombre de las carpetas", + "LabelSettingsFindCoversHelp": "Si tu audiolibro no tiene una portada incluída, o la portada no esta dentro de la carpeta, el escaneador tratará de encontrar una portada.
Nota: Esto extenderá el tiempo de escaneo", + "LabelSettingsHideSingleBookSeries": "Esconder series con un solo libro", + "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", + "LabelSettingsOverdriveMediaMarkers": "Usar Overdrive Media Markers para estos capítulos", + "LabelSettingsOverdriveMediaMarkersHelp": "Los archivos MP3 de Overdrive vienen con capítulos con tiempos incrustados como metadatos personalizados. Habilitar esta opción utilizará automáticamente estas etiquetas para los tiempos de los capítulos.", + "LabelSettingsParseSubtitles": "Extraer Subtítulos", + "LabelSettingsParseSubtitlesHelp": "Extraer subtítulos de los nombres de las carpetas de los audiolibros.
Los subtítulos deben estar separados por \" - \"
Por ejemplo: \"Ejemplo de Título - Subtítulo Aquí\" tiene el subtítulo \"Subtítulo Aquí\"", + "LabelSettingsPreferAudioMetadata": "Preferir metadatos del archivo de audio", + "LabelSettingsPreferAudioMetadataHelp": "Preferir los metadatos ID3 del archivo de audio en vez de los nombres de carpetas para los detalles de libros", + "LabelSettingsPreferMatchedMetadata": "Preferir metadatos encontrados", + "LabelSettingsPreferMatchedMetadataHelp": "Los datos encontrados sobreescribirán los detalles del elemento cuando se use \"Encontrar Rápido\". Por defecto, \"Encontrar Rápido\" sólo completará los detalles faltantes.", + "LabelSettingsPreferOPFMetadata": "Preferir Metadatos OPF", + "LabelSettingsPreferOPFMetadataHelp": "Preferir los archivos de metadatos OPF en vez de los nombres de carpetas para los detalles de los libros.", "LabelSettingsSkipMatchingBooksWithASIN": "Omitir libros coincidentes que ya tengan un ASIN", "LabelSettingsSkipMatchingBooksWithISBN": "Omitir libros coincidentes que ya tengan un ISBN", - "LabelSettingsSortingIgnorePrefixes": "Ignorar prefijos al ordenando", - "LabelSettingsSortingIgnorePrefixesHelp": "ejemplo. el prefijo \"el\" del titulo \"El titulo del libro\" sera ordenado como \"Titulo del Libro, el\"", + "LabelSettingsSortingIgnorePrefixes": "Ignorar prefijos al ordenar", + "LabelSettingsSortingIgnorePrefixesHelp": "Por ejemplo: El prefijo \"el\" del titulo \"El titulo del libro\" se ordenará como \"Titulo del Libro, el\".", "LabelSettingsSquareBookCovers": "Usar portadas cuadradas", - "LabelSettingsSquareBookCoversHelp": "Prefiere usar portadas cuadradas sobre las portadas estándar 1.6:1", - "LabelSettingsStoreCoversWithItem": "Guardar portada con elemento", - "LabelSettingsStoreCoversWithItemHelp": "Por defecto, las portadas se almacenan en /metadata/items, si habilita esta configuración, las portadas se almacenará en la carpeta de elementos de su biblioteca. Solamente un archivo llamado \"cover\" sera guardado.", - "LabelSettingsStoreMetadataWithItem": "Guardar metadata con elemento", - "LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items, si habilita esta configuración, los archivos de metadata se guardaran en la carpeta de elementos de tu biblioteca. Usa la extension .abs", - "LabelSettingsTimeFormat": "Format de Tiempo", + "LabelSettingsSquareBookCoversHelp": "Prefierir usar portadas cuadradas sobre las portadas estándar 1.6:1", + "LabelSettingsStoreCoversWithItem": "Guardar portadas con elementos", + "LabelSettingsStoreCoversWithItemHelp": "Por defecto, las portadas se almacenan en /metadata/items. Si habilita esta opción, las portadas se almacenarán en la carpeta de elementos de su biblioteca. Se guardará un solo archivo llamado \"cover\".", + "LabelSettingsStoreMetadataWithItem": "Guardar metadatos con elementos", + "LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca. Usa la extensión .abs", + "LabelSettingsTimeFormat": "Formato de Tiempo", "LabelShowAll": "Mostrar Todos", "LabelSize": "Tamaño", "LabelSleepTimer": "Temporizador para Dormir", "LabelSlug": "Slug", "LabelStart": "Iniciar", - "LabelStarted": "Indiciado", + "LabelStarted": "Iniciado", "LabelStartedAt": "Iniciado En", "LabelStartTime": "Tiempo de Inicio", "LabelStatsAudioTracks": "Pistas de Audio", "LabelStatsAuthors": "Autores", - "LabelStatsBestDay": "Mejor Dia", + "LabelStatsBestDay": "Mejor Día", "LabelStatsDailyAverage": "Promedio Diario", - "LabelStatsDays": "Dias", - "LabelStatsDaysListened": "Dias Escuchando", + "LabelStatsDays": "Días", + "LabelStatsDaysListened": "Días Escuchando", "LabelStatsHours": "Horas", "LabelStatsInARow": "seguidos", "LabelStatsItemsFinished": "Elementos Terminados", @@ -453,42 +453,42 @@ "LabelStatsMinutesListening": "Minutos Escuchando", "LabelStatsOverallDays": "Total de Dias", "LabelStatsOverallHours": "Total de Horas", - "LabelStatsWeekListening": "Escuchando en la Semana", - "LabelSubtitle": "Subtitulo", - "LabelSupportedFileTypes": "Tipo de Archivos Soportados", + "LabelStatsWeekListening": "Tiempo escuchando en la Semana", + "LabelSubtitle": "Subtítulo", + "LabelSupportedFileTypes": "Tipos de Archivos Soportados", "LabelTag": "Etiqueta", "LabelTags": "Etiquetas", - "LabelTagsAccessibleToUser": "Etiquetas Accessible para el Usuario", - "LabelTagsNotAccessibleToUser": "Tags not Accessible to User", + "LabelTagsAccessibleToUser": "Etiquetas Accessibles al Usuario", + "LabelTagsNotAccessibleToUser": "Etiquetas no Accesibles al Usuario", "LabelTasks": "Tareas Corriendo", - "LabelTheme": "Theme", - "LabelThemeDark": "Dark", - "LabelThemeLight": "Light", + "LabelTheme": "Tema", + "LabelThemeDark": "Oscuro", + "LabelThemeLight": "Claro", "LabelTimeBase": "Time Base", "LabelTimeListened": "Tiempo Escuchando", "LabelTimeListenedToday": "Tiempo Escuchando Hoy", "LabelTimeRemaining": "{0} restante", "LabelTimeToShift": "Tiempo para Cambiar en Segundos", - "LabelTitle": "Titulo", - "LabelToolsEmbedMetadata": "Incorporar Metadata", - "LabelToolsEmbedMetadataDescription": "Incorpora metadata en archivos de audio incluyendo la portada y capítulos.", - "LabelToolsMakeM4b": "Hacer Archivo M4B de Audiolibro", - "LabelToolsMakeM4bDescription": "Generar archivo .M4B de audiolibro con metadata, imágenes de portada y capítulos incorporados.", + "LabelTitle": "Título", + "LabelToolsEmbedMetadata": "Incrustar Metadatos", + "LabelToolsEmbedMetadataDescription": "Incrusta metadatos en los archivos de audio, incluyendo la portada y capítulos.", + "LabelToolsMakeM4b": "Hacer Archivo de Audiolibro M4B", + "LabelToolsMakeM4bDescription": "Generar archivo de audiolibro .M4B con metadatos, imágenes de portada y capítulos incorporados.", "LabelToolsSplitM4b": "Dividir M4B en Archivos MP3", - "LabelToolsSplitM4bDescription": "Dividir M4B en Archivos MP3 y incorporar metadata, images de portada y capítulos.", + "LabelToolsSplitM4bDescription": "Dividir M4B en Archivos MP3 e incorporar metadatos, imágenes de portada y capítulos.", "LabelTotalDuration": "Duración Total", "LabelTotalTimeListened": "Tiempo Total Escuchado", "LabelTrackFromFilename": "Pista desde el Nombre del Archivo", - "LabelTrackFromMetadata": "Pista desde Metadata", + "LabelTrackFromMetadata": "Pista desde Metadatos", "LabelTracks": "Pistas", - "LabelTracksMultiTrack": "Multi-track", - "LabelTracksNone": "No tracks", - "LabelTracksSingleTrack": "Single-track", + "LabelTracksMultiTrack": "Varias pistas", + "LabelTracksNone": "Ninguna pista", + "LabelTracksSingleTrack": "Una pista", "LabelType": "Tipo", - "LabelUnabridged": "Unabridged", + "LabelUnabridged": "No Abreviado", "LabelUnknown": "Desconocido", "LabelUpdateCover": "Actualizar Portada", - "LabelUpdateCoverHelp": "Permitir sobrescribir portadas existentes de los libros seleccionados cuando sean encontrados.", + "LabelUpdateCoverHelp": "Permitir sobrescribir las portadas existentes de los libros seleccionados cuando sean encontradas.", "LabelUpdatedAt": "Actualizado En", "LabelUpdateDetails": "Actualizar Detalles", "LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados", @@ -497,79 +497,79 @@ "LabelUseChapterTrack": "Usar pista por capitulo", "LabelUseFullTrack": "Usar pista completa", "LabelUser": "Usuario", - "LabelUsername": "Nombré de Usuario", + "LabelUsername": "Nombre de Usuario", "LabelValue": "Valor", "LabelVersion": "Versión", "LabelViewBookmarks": "Ver Marcadores", "LabelViewChapters": "Ver Capítulos", - "LabelViewQueue": "Ver player queue", + "LabelViewQueue": "Ver Fila del Reproductor", "LabelVolume": "Volumen", - "LabelWeekdaysToRun": "Correr en Dias de la Semana", + "LabelWeekdaysToRun": "Correr en Días de la Semana", "LabelYourAudiobookDuration": "Duración de tu Audiolibro", - "LabelYourBookmarks": "Tus Marcadores Bookmarks", + "LabelYourBookmarks": "Tus Marcadores", "LabelYourPlaylists": "Tus Listas", "LabelYourProgress": "Tu Progreso", - "MessageAddToPlayerQueue": "Agregar a player queue", - "MessageAppriseDescription": "Para usar esta función deberás tener Apprise API corriendo o un API que maneje los mismos resultados.
El Apprise API URL debe tener la misma ruta de archivos que donde se envina las notificaciones, ejemplo, si su API esta en http://192.168.1.1:8337 entonces pondría http://192.168.1.1:8337/notify.", - "MessageBackupsDescription": "Los respaldos incluyen, usuarios, el progreso del los usuarios, detalles de los elementos de la biblioteca, configuración del servidor y las imágenes en /metadata/items & /metadata/authors. Los Respaldo NO incluyen ningún archivo guardado en la carpeta de tu biblioteca.", - "MessageBatchQuickMatchDescription": "Quick Match tratara de agregar porta y metadata faltantes de los elementos seleccionados. Habilite la opción de abajo para que Quick Match pueda sobrescribir portadas y/o metadata existentes.", + "MessageAddToPlayerQueue": "Agregar a fila del Reproductor", + "MessageAppriseDescription": "Para usar esta función deberás tener la API de Apprise corriendo o una API que maneje los mismos resultados.
La URL de la API de Apprise debe tener la misma ruta de archivos que donde se envían las notificaciones. Por ejemplo: si su API esta en http://192.168.1.1:8337 entonces pondría http://192.168.1.1:8337/notify.", + "MessageBackupsDescription": "Los respaldos incluyen: usuarios, el progreso del los usuarios, los detalles de los elementos de la biblioteca, la configuración del servidor y las imágenes en /metadata/items y /metadata/authors. Los Respaldos NO incluyen ningún archivo guardado en la carpeta de tu biblioteca.", + "MessageBatchQuickMatchDescription": "\"Encontrar Rápido\" tratará de agregar portadas y metadatos faltantes de los elementos seleccionados. Habilite la opción de abajo para que \"Encontrar Rápido\" pueda sobrescribir portadas y/o metadatos existentes.", "MessageBookshelfNoCollections": "No tienes ninguna colección.", "MessageBookshelfNoResultsForFilter": "Ningún Resultado para el filtro \"{0}: {1}\"", "MessageBookshelfNoRSSFeeds": "Ninguna Fuente RSS esta abierta", - "MessageBookshelfNoSeries": "No tienes ninguna series", + "MessageBookshelfNoSeries": "No tienes ninguna serie", "MessageChapterEndIsAfter": "El final del capítulo es después del final de su audiolibro.", "MessageChapterErrorFirstNotZero": "El primer capitulo debe iniciar en 0", - "MessageChapterErrorStartGteDuration": "El tiempo de inicio no es válida debe ser inferior a la duración del audiolibro.", - "MessageChapterErrorStartLtPrev": "El tiempo de inicio no es válida debe ser mayor o igual que la hora de inicio del capítulo anterior", + "MessageChapterErrorStartGteDuration": "El tiempo de inicio no es válido: debe ser inferior a la duración del audiolibro.", + "MessageChapterErrorStartLtPrev": "El tiempo de inicio no es válido: debe ser mayor o igual que el tiempo de inicio del capítulo anterior", "MessageChapterStartIsAfter": "El comienzo del capítulo es después del final de su audiolibro", - "MessageCheckingCron": "Checking cron...", - "MessageConfirmCloseFeed": "Are you sure you want to close this feed?", - "MessageConfirmDeleteBackup": "Esta seguro que desea eliminar el respaldo {0}?", - "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", - "MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?", - "MessageConfirmDeleteSession": "Esta seguro que desea eliminar esta session?", - "MessageConfirmForceReScan": "Esta seguro que desea forzar re-escanear?", - "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", - "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", - "MessageConfirmMarkSeriesFinished": "Esta seguro que desea marcar todos los libros en esta serie como terminados?", - "MessageConfirmMarkSeriesNotFinished": "Esta seguro que desea marcar todos los libros en esta serie como no terminados?", - "MessageConfirmRemoveAllChapters": "Esta seguro que desea remover todos los capitulos?", - "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", - "MessageConfirmRemoveCollection": "Esta seguro que desea remover la colección \"{0}\"?", - "MessageConfirmRemoveEpisode": "Esta seguro que desea remover el episodio \"{0}\"?", - "MessageConfirmRemoveEpisodes": "Esta seguro que desea remover {0} episodios?", - "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", - "MessageConfirmRemovePlaylist": "Esta seguro que desea remover su lista de reproducción \"{0}\"?", - "MessageConfirmRenameGenre": "Esta seguro que desea renombrar el genero \"{0}\" a \"{1}\" de todos los elementos?", - "MessageConfirmRenameGenreMergeNote": "Nota: Este genero ya existe por lo que se fusionarán.", - "MessageConfirmRenameGenreWarning": "Advertencia! un genero similar ya existe \"{0}\".", - "MessageConfirmRenameTag": "Esta seguro que desea renombrar la etiqueta \"{0}\" a \"{1}\" de todos los elementos?", - "MessageConfirmRenameTagMergeNote": "Nota: Esta etiqueta ya existe por lo que se fusionarán.", + "MessageCheckingCron": "Revisando cron...", + "MessageConfirmCloseFeed": "Está seguro de que desea cerrar esta fuente?", + "MessageConfirmDeleteBackup": "¿Está seguro de que desea eliminar el respaldo {0}?", + "MessageConfirmDeleteFile": "Esto eliminará el archivo de su sistema de archivos. ¿Está seguro?", + "MessageConfirmDeleteLibrary": "¿Está seguro de que desea eliminar permanentemente la biblioteca \"{0}\"?", + "MessageConfirmDeleteSession": "¿Está seguro de que desea eliminar esta sesión?", + "MessageConfirmForceReScan": "¿Está seguro de que desea forzar un re-escaneo?", + "MessageConfirmMarkAllEpisodesFinished": "¿Está seguro de que desea marcar todos los episodios como terminados?", + "MessageConfirmMarkAllEpisodesNotFinished": "¿Está seguro de que desea marcar todos los episodios como no terminados?", + "MessageConfirmMarkSeriesFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como terminados?", + "MessageConfirmMarkSeriesNotFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como no terminados?", + "MessageConfirmRemoveAllChapters": "¿Está seguro de que desea remover todos los capitulos?", + "MessageConfirmRemoveAuthor": "¿Está seguro de que desea remover el autor \"{0}\"?", + "MessageConfirmRemoveCollection": "¿Está seguro de que desea remover la colección \"{0}\"?", + "MessageConfirmRemoveEpisode": "¿Está seguro de que desea remover el episodio \"{0}\"?", + "MessageConfirmRemoveEpisodes": "¿Está seguro de que desea remover {0} episodios?", + "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?", + "MessageConfirmRenameGenreMergeNote": "Nota: Este género ya existe, por lo que se fusionarán.", + "MessageConfirmRenameGenreWarning": "Advertencia! Un genero similar ya existe \"{0}\".", + "MessageConfirmRenameTag": "¿Está seguro de que desea renombrar la etiqueta \"{0}\" a \"{1}\" de todos los elementos?", + "MessageConfirmRenameTagMergeNote": "Nota: Esta etiqueta ya existe, por lo que se fusionarán.", "MessageConfirmRenameTagWarning": "Advertencia! Una etiqueta similar ya existe \"{0}\".", - "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", + "MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?", "MessageDownloadingEpisode": "Descargando Capitulo", - "MessageDragFilesIntoTrackOrder": "Arrastras los archivos en el orden correcto de la pista.", - "MessageEmbedFinished": "Incorporación Terminada!", + "MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas.", + "MessageEmbedFinished": "Incrustación Terminada!", "MessageEpisodesQueuedForDownload": "{0} Episodio(s) en cola para descargar", - "MessageFeedURLWillBe": "Fuente URL sera {0}", + "MessageFeedURLWillBe": "URL de la fuente será {0}", "MessageFetching": "Buscando...", - "MessageForceReScanDescription": "Escaneara todos los archivos como un nuevo escaneo. Archivos de audio con etiqueta ID3, archivos OPF y archivos de texto serán escaneados como nuevos.", - "MessageImportantNotice": "Noticia importante!", + "MessageForceReScanDescription": "Escaneará todos los archivos como un nuevo escaneo. Archivos de audio con etiquetas ID3, archivos OPF y archivos de texto serán escaneados como nuevos.", + "MessageImportantNotice": "¡Notificación importante!", "MessageInsertChapterBelow": "Insertar Capítulo Abajo", "MessageItemsSelected": "{0} Elementos Seleccionados", "MessageItemsUpdated": "{0} Elementos Actualizados", - "MessageJoinUsOn": "Únete en", - "MessageListeningSessionsInTheLastYear": "{0} sesiones de escuchadas en el último año", + "MessageJoinUsOn": "Únetenos en", + "MessageListeningSessionsInTheLastYear": "{0} sesiones de escucha en el último año", "MessageLoading": "Cargando...", "MessageLoadingFolders": "Cargando archivos...", - "MessageM4BFailed": "M4B Fallo!", - "MessageM4BFinished": "M4B Terminado!", - "MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps", - "MessageMarkAllEpisodesFinished": "Mark all episodes finished", - "MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished", + "MessageM4BFailed": "¡Fallo de M4B!", + "MessageM4BFinished": "¡M4B Terminado!", + "MessageMapChapterTitles": "Asignar los nombres de capítulos a los capítulos existentes en tu audiolibro sin ajustar sus tiempos", + "MessageMarkAllEpisodesFinished": "Marcar todos los episodios como terminados", + "MessageMarkAllEpisodesNotFinished": "Marcar todos los episodios como no terminados", "MessageMarkAsFinished": "Marcar como Terminado", "MessageMarkAsNotFinished": "Marcar como No Terminado", - "MessageMatchBooksDescription": "intentará hacer coincidir los libros de la biblioteca con un libro del proveedor de búsqueda seleccionado y rellenará los detalles vacíos y la portada. No sobrescribe los detalles.", + "MessageMatchBooksDescription": "Se intentará hacer coincidir los libros de la biblioteca con un libro del proveedor de búsqueda seleccionado, y se rellenarán los detalles vacíos y la portada. No sobrescribe los detalles.", "MessageNoAudioTracks": "Sin Pista de Audio", "MessageNoAuthors": "Sin Autores", "MessageNoBackups": "Sin Respaldos", @@ -582,18 +582,18 @@ "MessageNoDownloadsQueued": "Sin Lista de Descarga", "MessageNoEpisodeMatchesFound": "No se encontraron episodios que coinciden", "MessageNoEpisodes": "Sin Episodios", - "MessageNoFoldersAvailable": "No Carpetas Disponibles", + "MessageNoFoldersAvailable": "No Hay Carpetas Disponibles", "MessageNoGenres": "Sin Géneros", "MessageNoIssues": "Sin Problemas", "MessageNoItems": "Sin Elementos", "MessageNoItemsFound": "Ningún Elemento Encontrado", "MessageNoListeningSessions": "Ninguna Session Escuchada", - "MessageNoLogs": "No Logs", - "MessageNoMediaProgress": "Multimedia sin Progreso ", + "MessageNoLogs": "No hay logs", + "MessageNoMediaProgress": "Multimedia sin Progreso", "MessageNoNotifications": "Ninguna Notificación", - "MessageNoPodcastsFound": "Ningún podcasts encontrado", + "MessageNoPodcastsFound": "Ningún podcast encontrado", "MessageNoResults": "Sin Resultados", - "MessageNoSearchResultsFor": "No hay resultados de la búsqueda para \"{0}\"", + "MessageNoSearchResultsFor": "No hay resultados para la búsqueda \"{0}\"", "MessageNoSeries": "Sin Series", "MessageNoTags": "Sin Etiquetas", "MessageNoTasksRunning": "Ninguna Tarea Corriendo", @@ -603,45 +603,45 @@ "MessageNoUserPlaylists": "No tienes lista de reproducciones", "MessageOr": "o", "MessagePauseChapter": "Pausar la reproducción del capítulo", - "MessagePlayChapter": "Escuche para comenzar el capítulo", - "MessagePlaylistCreateFromCollection": "Crear lista de reproducción a partir de colección", - "MessagePodcastHasNoRSSFeedForMatching": "El podcast no tiene una URL de fuente RSS que pueda usar que coincida", - "MessageQuickMatchDescription": "Rellenar detalles de elementos vacíos y portada con los primeros resultados de '{0}'. No sobrescribe los detalles a menos que la configuración 'Prefer matched metadata' del servidor este habilita.", + "MessagePlayChapter": "Escuchar el comienzo del capítulo", + "MessagePlaylistCreateFromCollection": "Crear una lista de reproducción a partir de una colección", + "MessagePodcastHasNoRSSFeedForMatching": "El podcast no tiene una URL de fuente RSS que pueda usar", + "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)", - "MessageRemoveFromPlayerQueue": "Romover la cola de reporduccion", - "MessageRemoveUserWarning": "Esta seguro que desea eliminar el usuario \"{0}\"?", - "MessageReportBugsAndContribute": "Reporte erres, solicite funciones y contribuye en", - "MessageResetChaptersConfirm": "Esta seguro que desea reiniciar el capitulo y deshacer los cambios que hiciste?", - "MessageRestoreBackupConfirm": "Esta seguro que desea para restaurar del respaldo creado en", - "MessageRestoreBackupWarning": "Restaurar sobrescribirá toda la base de datos localizada en /config y las imágenes de portadas en /metadata/items & /metadata/authors.

El respaldo no modifica ningún archivo en las carpetas de tu biblioteca. Si ha habilitado la configuración del servidor para almacenar portadas y metadata en las carpetas de su biblioteca, entonces esos no se respaldan o sobrescribe.

Todos los clientes que usen su servidor se actualizarán automáticamente.", + "MessageRemoveFromPlayerQueue": "Romover la cola de reproducción", + "MessageRemoveUserWarning": "¿Está seguro de que desea eliminar el usuario \"{0}\"?", + "MessageReportBugsAndContribute": "Reporte erres, solicite funciones y contribuya en", + "MessageResetChaptersConfirm": "¿Está seguro de que desea deshacer los cambios y revertir los capítulos a su estado original?", + "MessageRestoreBackupConfirm": "¿Está seguro de que desea para restaurar del respaldo creado en", + "MessageRestoreBackupWarning": "Restaurar sobrescribirá toda la base de datos localizada en /config y las imágenes de portadas en /metadata/items y /metadata/authors.

El respaldo no modifica ningún archivo en las carpetas de su biblioteca. Si ha habilitado la opción del servidor para almacenar portadas y metadata en las carpetas de su biblioteca, esos archivos no se respaldan o sobrescriben.

Todos los clientes que usen su servidor se actualizarán automáticamente.", "MessageSearchResultsFor": "Resultados de la búsqueda de", - "MessageServerCouldNotBeReached": "No se pude establecer la conexión con el servidor", + "MessageServerCouldNotBeReached": "No se pudo establecer la conexión con el servidor", "MessageSetChaptersFromTracksDescription": "Establecer capítulos usando cada archivo de audio como un capítulo y el título del capítulo como el nombre del archivo de audio", "MessageStartPlaybackAtTime": "Iniciar reproducción para \"{0}\" en {1}?", "MessageThinking": "Pensando...", "MessageUploaderItemFailed": "Error al Subir", - "MessageUploaderItemSuccess": "Éxito al Subir!", + "MessageUploaderItemSuccess": "¡Éxito al Subir!", "MessageUploading": "Subiendo...", - "MessageValidCronExpression": "Valid cron expression", - "MessageWatcherIsDisabledGlobally": "Watcher es desactivado globalmente en la configuración del servidor", - "MessageXLibraryIsEmpty": "{0} La biblioteca esta vacía!", - "MessageYourAudiobookDurationIsLonger": "La duración de tu audiolibro es más larga que la duración encontrada", + "MessageValidCronExpression": "Expresión de Cron bálida", + "MessageWatcherIsDisabledGlobally": "El watcher está desactivado globalmente en la configuración del servidor", + "MessageXLibraryIsEmpty": "La biblioteca {0} está vacía!", + "MessageYourAudiobookDurationIsLonger": "La duración de su audiolibro es más larga que la duración encontrada", "MessageYourAudiobookDurationIsShorter": "La duración de su audiolibro es más corta que la duración encontrada", "NoteChangeRootPassword": "El usuario Root es el único usuario que puede no tener una contraseña", - "NoteChapterEditorTimes": "Nota: La hora de inicio del primer capítulo debe permanecer en 0:00 y la hora de inicio del último capítulo no puede exceder la duración de este audiolibro.", - "NoteFolderPicker": "Nota: las carpetas ya asignadas no se mostrarán", - "NoteFolderPickerDebian": "Nota: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.", - "NoteRSSFeedPodcastAppsHttps": "Advertencia: La mayoría de las aplicaciones de podcast requieren que URL de la fuente RSS use HTTPS", - "NoteRSSFeedPodcastAppsPubDate": "Advertencia: 1 o más de tus episodios no tienen fecha de publicación. Algunas aplicaciones de podcast lo requieren.", + "NoteChapterEditorTimes": "Nota: El tiempo de inicio del primer capítulo debe permanecer en 0:00, y el tiempo de inicio del último capítulo no puede exceder la duración del audiolibro.", + "NoteFolderPicker": "Nota: Las carpetas ya asignadas no se mostrarán", + "NoteFolderPickerDebian": "Nota: El selector de archivos no está completamente implementado para instalaciones en Debian. Deberá ingresar la ruta de la carpeta de su biblioteca directamente.", + "NoteRSSFeedPodcastAppsHttps": "Advertencia: La mayoría de las aplicaciones de podcast requieren que la URL de la fuente RSS use HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Advertencia: 1 o más de sus episodios no tienen fecha de publicación. Algunas aplicaciones de podcast lo requieren.", "NoteUploaderFoldersWithMediaFiles": "Las carpetas con archivos multimedia se manejarán como elementos separados en la biblioteca.", - "NoteUploaderOnlyAudioFiles": "Si subes solamente un archivos de audio, cada archivo se manejara como un audiolibro.", - "NoteUploaderUnsupportedFiles": "Los archivos no soportados se ignoran. Al elegir o soltar una carpeta, los archivos que no estén en una carpeta serán ignorados.", + "NoteUploaderOnlyAudioFiles": "Si sube solamente archivos de audio, cada archivo se manejará como un audiolibro por separado.", + "NoteUploaderUnsupportedFiles": "Se ignorarán los archivos no soportados. Al elegir o arrastrar una carpeta, los archivos que no estén dentro de una subcarpeta serán ignorados.", "PlaceholderNewCollection": "Nuevo nombre de la colección", "PlaceholderNewFolderPath": "Nueva ruta de carpeta", "PlaceholderNewPlaylist": "Nuevo nombre de la lista de reproducción", - "PlaceholderSearch": "Buscando..", - "PlaceholderSearchEpisode": "Search episode..", + "PlaceholderSearch": "Buscar..", + "PlaceholderSearchEpisode": "Buscar Episodio..", "ToastAccountUpdateFailed": "Error al actualizar cuenta", "ToastAccountUpdateSuccess": "Cuenta actualizada", "ToastAuthorImageRemoveFailed": "Error al eliminar la imagen", @@ -657,16 +657,16 @@ "ToastBackupRestoreFailed": "Error al restaurar el respaldo", "ToastBackupUploadFailed": "Error al subir el respaldo", "ToastBackupUploadSuccess": "Respaldo cargado", - "ToastBatchUpdateFailed": "Batch update failed", - "ToastBatchUpdateSuccess": "Batch update success", + "ToastBatchUpdateFailed": "Subida masiva fallida", + "ToastBatchUpdateSuccess": "Subida masiva exitosa", "ToastBookmarkCreateFailed": "Error al crear marcador", - "ToastBookmarkCreateSuccess": "Marca Agregado", + "ToastBookmarkCreateSuccess": "Marcador Agregado", "ToastBookmarkRemoveFailed": "Error al eliminar marcador", "ToastBookmarkRemoveSuccess": "Marcador eliminado", - "ToastBookmarkUpdateFailed": "Error al eliminar el marcador", + "ToastBookmarkUpdateFailed": "Error al actualizar el marcador", "ToastBookmarkUpdateSuccess": "Marcador actualizado", "ToastChaptersHaveErrors": "Los capítulos tienen errores", - "ToastChaptersMustHaveTitles": "Los capítulos tienen que tener titulo", + "ToastChaptersMustHaveTitles": "Los capítulos tienen que tener un título", "ToastCollectionItemsRemoveFailed": "Error al remover elemento(s) de la colección", "ToastCollectionItemsRemoveSuccess": "Elementos(s) removidos de la colección", "ToastCollectionRemoveFailed": "Error al remover la colección", @@ -676,7 +676,7 @@ "ToastItemCoverUpdateFailed": "Error al actualizar la portada del elemento", "ToastItemCoverUpdateSuccess": "Portada del elemento actualizada", "ToastItemDetailsUpdateFailed": "Error al actualizar los detalles del elemento", - "ToastItemDetailsUpdateSuccess": "Detalles de Elemento Actualizados", + "ToastItemDetailsUpdateSuccess": "Detalles del Elemento Actualizados", "ToastItemDetailsUpdateUnneeded": "No se necesitan actualizaciones para los detalles del Elemento", "ToastItemMarkedAsFinishedFailed": "Error al marcar como Terminado", "ToastItemMarkedAsFinishedSuccess": "Elemento marcado como terminado", @@ -684,11 +684,11 @@ "ToastItemMarkedAsNotFinishedSuccess": "Elemento marcado como No Terminado", "ToastLibraryCreateFailed": "Error al crear biblioteca", "ToastLibraryCreateSuccess": "Biblioteca \"{0}\" creada", - "ToastLibraryDeleteFailed": "Error al eliminar la biblioteca", + "ToastLibraryDeleteFailed": "Error al eliminar biblioteca", "ToastLibraryDeleteSuccess": "Biblioteca eliminada", - "ToastLibraryScanFailedToStart": "Error al iniciar la exploración", + "ToastLibraryScanFailedToStart": "Error al iniciar el escaneo", "ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca", - "ToastLibraryUpdateFailed": "Error al actualizar biblioteca", + "ToastLibraryUpdateFailed": "Error al actualizar la biblioteca", "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada", "ToastPlaylistCreateFailed": "Error al crear la lista de reproducción.", "ToastPlaylistCreateSuccess": "Lista de reproducción creada", @@ -697,15 +697,15 @@ "ToastPlaylistUpdateFailed": "Error al actualizar la lista de reproducción.", "ToastPlaylistUpdateSuccess": "Lista de reproducción actualizada", "ToastPodcastCreateFailed": "Error al crear podcast", - "ToastPodcastCreateSuccess": "Podcast creada", + "ToastPodcastCreateSuccess": "Podcast creado", "ToastRemoveItemFromCollectionFailed": "Error al eliminar el elemento de la colección", "ToastRemoveItemFromCollectionSuccess": "Elemento eliminado de la colección.", "ToastRSSFeedCloseFailed": "Error al cerrar fuente RSS", "ToastRSSFeedCloseSuccess": "Fuente RSS cerrada", - "ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device", - "ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"", + "ToastSendEbookToDeviceFailed": "Error al enviar el ebook al dispositivo", + "ToastSendEbookToDeviceSuccess": "Ebook enviado al dispositivo \"{0}\"", "ToastSeriesUpdateFailed": "Error al actualizar la serie", - "ToastSeriesUpdateSuccess": "Series actualizada", + "ToastSeriesUpdateSuccess": "Serie actualizada", "ToastSessionDeleteFailed": "Error al eliminar sesión", "ToastSessionDeleteSuccess": "Sesión eliminada", "ToastSocketConnected": "Socket conectado", @@ -713,4 +713,4 @@ "ToastSocketFailedToConnect": "Error al conectar al Socket", "ToastUserDeleteFailed": "Error al eliminar el usuario", "ToastUserDeleteSuccess": "Usuario eliminado" -} \ No newline at end of file +} From 5d7c197c893d10277f59c753e2d324837185a78f Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 3 Oct 2023 19:43:37 +0000 Subject: [PATCH 09/84] [fix] Add back toLowerCase to cleanAuthor/Title (required by other uses) --- server/finders/BookFinder.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index b30510f2..aa66fb92 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -59,12 +59,12 @@ class BookFinder { // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game") cleaned = cleaned.replace(/'/g, '') - return this.replaceAccentedChars(cleaned) + return this.replaceAccentedChars(cleaned).toLowerCase() } cleanAuthorForCompares(author) { if (!author) return '' - return this.replaceAccentedChars(author) + return this.replaceAccentedChars(author).toLowerCase() } filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) { From 401bd912043c4a04bfb5b4240aabdde26e84cd3c Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 3 Oct 2023 17:16:49 -0500 Subject: [PATCH 10/84] Add:Show current book duration on match page as compared with book listed #1803 --- client/components/cards/BookMatchCard.vue | 30 ++++++++++++++------ client/components/modals/item/tabs/Match.vue | 12 +++++--- client/plugins/utils.js | 9 ++++-- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/client/components/cards/BookMatchCard.vue b/client/components/cards/BookMatchCard.vue index dd782d30..77619e55 100644 --- a/client/components/cards/BookMatchCard.vue +++ b/client/components/cards/BookMatchCard.vue @@ -15,8 +15,8 @@

by {{ book.author }}

Narrated by {{ book.narrator }}

-

Runtime: {{ $elapsedPrettyExtended(book.duration * 60) }}

-
+

Runtime: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}

+

{{ series.series }} #{{ series.sequence }} @@ -29,9 +29,7 @@

-
- {{ book.title }} -
+
{{ book.title }}

by {{ book.author }}

{{ book.genres.join(', ') }}

@@ -56,7 +54,8 @@ export default { default: () => {} }, isPodcast: Boolean, - bookCoverAspectRatio: Number + bookCoverAspectRatio: Number, + currentBookDuration: Number }, data() { return { @@ -65,12 +64,27 @@ export default { }, computed: { bookCovers() { - return this.book.covers ? this.book.covers || [] : [] + return this.book.covers || [] + }, + bookDuration() { + return (this.book.duration || 0) * 60 + }, + bookDurationComparison() { + if (!this.bookDuration || !this.currentBookDuration) return '' + let differenceInSeconds = this.currentBookDuration - this.bookDuration + // Only show seconds on difference if difference is less than an hour + if (differenceInSeconds < 0) { + differenceInSeconds = Math.abs(differenceInSeconds) + return `(${this.$elapsedPrettyExtended(differenceInSeconds, false, differenceInSeconds < 3600)} shorter)` + } else if (differenceInSeconds > 0) { + return `(${this.$elapsedPrettyExtended(differenceInSeconds, false, differenceInSeconds < 3600)} longer)` + } + return '(exact match)' } }, methods: { selectMatch() { - var book = { ...this.book } + const book = { ...this.book } book.cover = this.selectedCover this.$emit('select', book) }, diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue index 0c5d67eb..1c682919 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -22,7 +22,7 @@
@@ -205,7 +205,7 @@ export default { processing: Boolean, libraryItem: { type: Object, - default: () => { } + default: () => {} } }, data() { @@ -290,13 +290,17 @@ export default { return this.$strings.LabelSearchTitle }, media() { - return this.libraryItem ? this.libraryItem.media || {} : {} + return this.libraryItem?.media || {} }, mediaMetadata() { return this.media.metadata || {} }, + currentBookDuration() { + if (this.isPodcast) return 0 + return this.media.duration || 0 + }, mediaType() { - return this.libraryItem ? this.libraryItem.mediaType : null + return this.libraryItem?.mediaType || null }, isPodcast() { return this.mediaType == 'podcast' diff --git a/client/plugins/utils.js b/client/plugins/utils.js index 439f65c5..495a14ef 100644 --- a/client/plugins/utils.js +++ b/client/plugins/utils.js @@ -54,7 +54,7 @@ Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHo return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}` } -Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => { +Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = true) => { if (isNaN(seconds) || seconds === null) return '' seconds = Math.round(seconds) @@ -69,11 +69,16 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => { hours -= days * 24 } + // If not showing seconds then round minutes up + if (minutes && seconds && !showSeconds) { + if (seconds >= 30) minutes++ + } + const strs = [] if (days) strs.push(`${days}d`) if (hours) strs.push(`${hours}h`) if (minutes) strs.push(`${minutes}m`) - if (seconds) strs.push(`${seconds}s`) + if (seconds && showSeconds) strs.push(`${seconds}s`) return strs.join(' ') } From 10f5bc8cbeeacd3c47f7115f387dd7d5817982e7 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 4 Oct 2023 05:26:16 +0000 Subject: [PATCH 11/84] [cleanup] Make original title/author check with more readable --- server/finders/BookFinder.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index aa66fb92..6ca238ee 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -276,7 +276,6 @@ class BookFinder { // Now run up to maxFuzzySearches fuzzy searches let titleCandidates = new BookFinder.TitleCandidates(this, cleanAuthor) - titleCandidates.add(title) // remove parentheses and their contents, and replace with a separator const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}/g, " - ") @@ -285,16 +284,15 @@ class BookFinder { for (const titlePart of titleParts) { titleCandidates.add(titlePart) } - // We already searched for original title - if (author == cleanAuthor) titleCandidates.delete(title) if (titleCandidates.size > 0) { titleCandidates = titleCandidates.getCandidates() for (const titleCandidate of titleCandidates) { + if (titleCandidate == title && cleanAuthor == author) continue // We already tried this if (++numFuzzySearches > maxFuzzySearches) return books books = await this.runSearch(titleCandidate, cleanAuthor, provider, asin, maxTitleDistance, maxAuthorDistance) if (books.length) break } - if (!books.length) { + if (!books.length && cleanAuthor) { // Now try searching without the author for (const titleCandidate of titleCandidates) { if (++numFuzzySearches > maxFuzzySearches) return books From 752bfffb1109e8fadf87775ecacf588365608b03 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 4 Oct 2023 14:53:12 +0000 Subject: [PATCH 12/84] [enhamcement] Only add title candidate before and after all transforms --- server/finders/BookFinder.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 6ca238ee..1fe86718 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -206,12 +206,11 @@ class BookFinder { let candidate = cleanTitle - for (const transformer of titleTransformers) { + for (const transformer of titleTransformers) candidate = candidate.replace(transformer[0], transformer[1]).trim() - if (candidate) { - this.candidates.add(candidate) - } - } + + if (candidate) + this.candidates.add(candidate) } get size() { From bfe514b7d4683a7b8b4608a58d298ec591b88732 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 4 Oct 2023 17:05:12 -0500 Subject: [PATCH 13/84] Add:Email inputs for users --- client/components/modals/AccountModal.vue | 14 +++++++++----- client/components/tables/UsersTable.vue | 1 - server/controllers/UserController.js | 8 ++++++++ server/models/User.js | 2 ++ server/objects/user/User.js | 6 +++++- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index a09de35d..ddad3cd3 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -14,13 +14,17 @@
+
-
- +
+
-
+
+ +
+

{{ $strings.LabelEnable }}

@@ -257,7 +261,6 @@ export default { if (account.type === 'root' && !account.isActive) return this.processing = true - console.log('Calling update', account) this.$axios .$patch(`/api/users/${this.account.id}`, account) .then((data) => { @@ -329,6 +332,7 @@ export default { if (this.account) { this.newUser = { username: this.account.username, + email: this.account.email, password: this.account.password, type: this.account.type, isActive: this.account.isActive, @@ -337,9 +341,9 @@ export default { itemTagsSelected: [...(this.account.itemTagsSelected || [])] } } else { - this.fetchAllTags() this.newUser = { username: null, + email: null, password: null, type: 'user', isActive: true, diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue index cfcf3f47..863012b5 100644 --- a/client/components/tables/UsersTable.vue +++ b/client/components/tables/UsersTable.vue @@ -129,7 +129,6 @@ export default { this.users = res.users.sort((a, b) => { return a.createdAt - b.createdAt }) - console.log('Loaded users', this.users) }) .catch((error) => { console.error('Failed', error) diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index a3f70e20..2695a7a0 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -115,6 +115,13 @@ class UserController { } } + /** + * PATCH: /api/users/:id + * Update user + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async update(req, res) { const user = req.reqUser @@ -126,6 +133,7 @@ class UserController { var account = req.body var shouldUpdateToken = false + // When changing username create a new API token if (account.username !== undefined && account.username !== user.username) { const usernameExists = await Database.userModel.getUserByUsername(account.username) if (usernameExists) { diff --git a/server/models/User.js b/server/models/User.js index 6f457aa5..bf22a3a5 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -59,6 +59,7 @@ class User extends Model { id: userExpanded.id, oldUserId: userExpanded.extraData?.oldUserId || null, username: userExpanded.username, + email: userExpanded.email || null, pash: userExpanded.pash, type: userExpanded.type, token: userExpanded.token, @@ -96,6 +97,7 @@ class User extends Model { return { id: oldUser.id, username: oldUser.username, + email: oldUser.email || null, pash: oldUser.pash || null, type: oldUser.type || null, token: oldUser.token || null, diff --git a/server/objects/user/User.js b/server/objects/user/User.js index 1ed74bb2..a9c9c767 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -7,6 +7,7 @@ class User { this.id = null this.oldUserId = null // TODO: Temp for keeping old access tokens this.username = null + this.email = null this.pash = null this.type = null this.token = null @@ -76,6 +77,7 @@ class User { id: this.id, oldUserId: this.oldUserId, username: this.username, + email: this.email, pash: this.pash, type: this.type, token: this.token, @@ -97,6 +99,7 @@ class User { id: this.id, oldUserId: this.oldUserId, username: this.username, + email: this.email, type: this.type, token: (this.type === 'root' && hideRootToken) ? '' : this.token, mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [], @@ -140,6 +143,7 @@ class User { this.id = user.id this.oldUserId = user.oldUserId this.username = user.username + this.email = user.email || null this.pash = user.pash this.type = user.type this.token = user.token @@ -184,7 +188,7 @@ class User { update(payload) { var hasUpdates = false // Update the following keys: - const keysToCheck = ['pash', 'type', 'username', 'isActive'] + const keysToCheck = ['pash', 'type', 'username', 'email', 'isActive'] keysToCheck.forEach((key) => { if (payload[key] !== undefined) { if (key === 'isActive' || payload[key]) { // pash, type, username must evaluate to true (cannot be null or empty) From 8979586404a1ca4a46b0eff3d1cc23582ffbfbb5 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 10:28:55 +0000 Subject: [PATCH 14/84] [enhancement] Improve candidate sorting --- server/finders/BookFinder.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 1fe86718..2bd1c571 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -189,9 +189,11 @@ class BookFinder { this.bookFinder = bookFinder this.candidates = new Set() this.cleanAuthor = cleanAuthor + this.priorities = {} + this.positions = {} } - add(title) { + add(title, position = 0) { const titleTransformers = [ [/([,:;_]| by ).*/g, ''], // Remove subtitle [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers @@ -203,14 +205,22 @@ class BookFinder { const cleanTitle = this.bookFinder.cleanTitleForCompares(title).trim() if (!cleanTitle) return this.candidates.add(cleanTitle) + this.priorities[cleanTitle] = 0 + this.positions[cleanTitle] = position let candidate = cleanTitle for (const transformer of titleTransformers) candidate = candidate.replace(transformer[0], transformer[1]).trim() - if (candidate) - this.candidates.add(candidate) + if (candidate != cleanTitle) { + if (candidate) { + this.candidates.add(candidate) + this.priorities[candidate] = 0 + this.positions[candidate] = position + } + this.priorities[cleanTitle] = 1 + } } get size() { @@ -227,6 +237,12 @@ class BookFinder { const onlyDigits = /^\d+$/ const includesOnlyDigitsDiff = !onlyDigits.test(b) - !onlyDigits.test(a) if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff + // transformed candidates receive higher priority + const priorityDiff = this.priorities[a] - this.priorities[b] + if (priorityDiff) return priorityDiff + // if same priorirty, prefer candidates that are closer to the beginning (e.g. titles before subtitles) + const positionDiff = this.positions[a] - this.positions[b] + if (positionDiff) return positionDiff // Start with longer candidaets, as they are likely more specific const lengthDiff = b.length - a.length if (lengthDiff) return lengthDiff @@ -280,8 +296,8 @@ class BookFinder { const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}/g, " - ") // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) - for (const titlePart of titleParts) { - titleCandidates.add(titlePart) + for (const [position, titlePart] of titleParts.entries()) { + titleCandidates.add(titlePart, position) } if (titleCandidates.size > 0) { titleCandidates = titleCandidates.getCandidates() From 9eff471afaa87572bfcb312af64d756511fde2a3 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 11:39:29 +0000 Subject: [PATCH 15/84] [enhancement] AuthorCandidates, author validation --- server/finders/BookFinder.js | 100 +++++++++++++++++++++++++++++------ 1 file changed, 84 insertions(+), 16 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 2bd1c571..b29417cb 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -194,6 +194,12 @@ class BookFinder { } add(title, position = 0) { + // if title contains the author, remove it + if (this.cleanAuthor) { + const authorRe = new RegExp(`(^| | by |)${this.cleanAuthor}(?= |$)`, "g") + title = this.bookFinder.cleanAuthorForCompares(title).replace(authorRe, '').trim() + } + const titleTransformers = [ [/([,:;_]| by ).*/g, ''], // Remove subtitle [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers @@ -258,6 +264,73 @@ class BookFinder { } } + static AuthorCandidates = class { + constructor(bookFinder, cleanAuthor) { + this.bookFinder = bookFinder + this.candidates = new Set() + this.cleanAuthor = cleanAuthor + if (cleanAuthor) this.candidates.add(cleanAuthor) + } + + validateAuthor(name, region = '', maxLevenshtein = 3) { + return this.bookFinder.audnexus.authorASINsRequest(name, region).then((asins) => { + for (const asin of asins) { + let cleanName = this.bookFinder.cleanAuthorForCompares(asin.name) + if (!cleanName) continue + if (cleanName.includes(name)) return name + if (name.includes(cleanName)) return cleanName + if (levenshteinDistance(cleanName, name) <= maxLevenshtein) return cleanName + } + return '' + }) + } + + add(author) { + const authorTransformers = [] + + // Main variant + const cleanAuthor = this.bookFinder.cleanAuthorForCompares(author).trim() + if (!cleanAuthor) return false + this.candidates.add(cleanAuthor) + + let candidate = cleanAuthor + + for (const transformer of authorTransformers) { + candidate = candidate.replace(transformer[0], transformer[1]).trim() + if (candidate) { + this.candidates.add(candidate) + } + } + + return true + } + + get size() { + return this.candidates.size + } + + async getCandidates() { + var filteredCandidates = [] + var promises = [] + for (const candidate of this.candidates) { + promises.push(this.validateAuthor(candidate)) + } + const results = [...new Set(await Promise.all(promises))] + filteredCandidates = results.filter(author => author) + // if no valid candidates were found, add back the original clean author + if (!filteredCandidates.length && this.cleanAuthor) filteredCandidates.push(this.cleanAuthor) + // always add an empty author candidate + filteredCandidates.push('') + Logger.debug(`[${this.constructor.name}] Found ${filteredCandidates.length} fuzzy author candidates`) + Logger.debug(filteredCandidates) + return filteredCandidates + } + + delete(author) { + return this.candidates.delete(author) + } + } + /** * Search for books including fuzzy searches @@ -290,30 +363,25 @@ class BookFinder { const cleanAuthor = this.cleanAuthorForCompares(author) // Now run up to maxFuzzySearches fuzzy searches - let titleCandidates = new BookFinder.TitleCandidates(this, cleanAuthor) + let authorCandidates = new BookFinder.AuthorCandidates(this, cleanAuthor) // remove parentheses and their contents, and replace with a separator const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}/g, " - ") // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) - for (const [position, titlePart] of titleParts.entries()) { - titleCandidates.add(titlePart, position) - } - if (titleCandidates.size > 0) { + for (const titlePart of titleParts) + authorCandidates.add(titlePart) + authorCandidates = await authorCandidates.getCandidates() + for (const authorCandidate of authorCandidates) { + let titleCandidates = new BookFinder.TitleCandidates(this, authorCandidate) + for (const [position, titlePart] of titleParts.entries()) + titleCandidates.add(titlePart, position) titleCandidates = titleCandidates.getCandidates() for (const titleCandidate of titleCandidates) { - if (titleCandidate == title && cleanAuthor == author) continue // We already tried this + if (titleCandidate == title && authorCandidate == author) continue // We already tried this if (++numFuzzySearches > maxFuzzySearches) return books - books = await this.runSearch(titleCandidate, cleanAuthor, provider, asin, maxTitleDistance, maxAuthorDistance) - if (books.length) break - } - if (!books.length && cleanAuthor) { - // Now try searching without the author - for (const titleCandidate of titleCandidates) { - if (++numFuzzySearches > maxFuzzySearches) return books - books = await this.runSearch(titleCandidate, '', provider, asin, maxTitleDistance, maxAuthorDistance) - if (books.length) break - } + books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance) + if (books.length) return books } } } From b2acdadcea6fa52636d816166beac24cb370e127 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 12:22:02 +0000 Subject: [PATCH 16/84] [enhancement] Added a couple title transformers --- server/finders/BookFinder.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index b29417cb..8876e2bd 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -202,9 +202,11 @@ class BookFinder { const titleTransformers = [ [/([,:;_]| by ).*/g, ''], // Remove subtitle - [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers [/(^| )\d+k(bps)?( |$)/, ' '], // Remove bitrate - [/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''] // Remove edition + [/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''], // Remove edition + [/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type + [/ a novel.*$/g, ''], // Remove "a novel" + [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers ] // Main variant From f3555a12ceff25d328b7dd1637668874e181946e Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 14:50:16 +0000 Subject: [PATCH 17/84] [enhancement] Handle initials in author normalization --- server/finders/BookFinder.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 8876e2bd..70031fa3 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -64,7 +64,12 @@ class BookFinder { cleanAuthorForCompares(author) { if (!author) return '' - return this.replaceAccentedChars(author).toLowerCase() + let cleanAuthor = this.replaceAccentedChars(author).toLowerCase() + // separate initials + cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2') + // remove middle initials + cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '') + return cleanAuthor } filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) { From bf9f3895db17f2172cda4e32caab559eda9c05a1 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 17:53:54 +0000 Subject: [PATCH 18/84] [enhancement] Treat underscores as title part separators --- server/finders/BookFinder.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 70031fa3..e3e87f4a 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -372,8 +372,8 @@ class BookFinder { // Now run up to maxFuzzySearches fuzzy searches let authorCandidates = new BookFinder.AuthorCandidates(this, cleanAuthor) - // remove parentheses and their contents, and replace with a separator - const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}/g, " - ") + // remove underscores and parentheses with their contents, and replace with a separator + const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, " - ") // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) for (const titlePart of titleParts) From b0b7a0a61817671b15e2687a32399aea6f0bdb51 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 18:27:52 +0000 Subject: [PATCH 19/84] [enhancement] Reduce spurious matches in validateAuthor --- server/finders/BookFinder.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index e3e87f4a..d3192142 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -279,9 +279,10 @@ class BookFinder { if (cleanAuthor) this.candidates.add(cleanAuthor) } - validateAuthor(name, region = '', maxLevenshtein = 3) { + validateAuthor(name, region = '', maxLevenshtein = 2) { return this.bookFinder.audnexus.authorASINsRequest(name, region).then((asins) => { - for (const asin of asins) { + for (const [i, asin] of asins.entries()) { + if (i > 10) break let cleanName = this.bookFinder.cleanAuthorForCompares(asin.name) if (!cleanName) continue if (cleanName.includes(name)) return name From f44b7ed1d0f8ba538e194632f98660893d9206a6 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 18:41:18 +0000 Subject: [PATCH 20/84] [enhancement] If no valid authors, use clean author field --- server/finders/BookFinder.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index d3192142..8c420333 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -317,6 +317,14 @@ class BookFinder { return this.candidates.size } + get agressivelyCleanAuthor() { + if (this.cleanAuthor) { + const agressivelyCleanAuthor = this.cleanAuthor.replace(/[,/-].*$/, '').trim() + return agressivelyCleanAuthor ? agressivelyCleanAuthor : this.cleanAuthor + } + return '' + } + async getCandidates() { var filteredCandidates = [] var promises = [] @@ -325,9 +333,9 @@ class BookFinder { } const results = [...new Set(await Promise.all(promises))] filteredCandidates = results.filter(author => author) - // if no valid candidates were found, add back the original clean author - if (!filteredCandidates.length && this.cleanAuthor) filteredCandidates.push(this.cleanAuthor) - // always add an empty author candidate + // If no valid candidates were found, add back an aggresively cleaned author version + if (!filteredCandidates.length && this.cleanAuthor) filteredCandidates.push(this.agressivelyCleanAuthor) + // Always add an empty author candidate filteredCandidates.push('') Logger.debug(`[${this.constructor.name}] Found ${filteredCandidates.length} fuzzy author candidates`) Logger.debug(filteredCandidates) @@ -364,7 +372,7 @@ class BookFinder { books = await this.runSearch(title, author, provider, asin, maxTitleDistance, maxAuthorDistance) if (!books.length && maxFuzzySearches > 0) { - // normalize title and author + // Normalize title and author title = title.trim().toLowerCase() author = author.trim().toLowerCase() @@ -373,7 +381,7 @@ class BookFinder { // Now run up to maxFuzzySearches fuzzy searches let authorCandidates = new BookFinder.AuthorCandidates(this, cleanAuthor) - // remove underscores and parentheses with their contents, and replace with a separator + // Remove underscores and parentheses with their contents, and replace with a separator const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, " - ") // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) From b447cf5c1ccb273fd45af8a7e8b11f3844c28fe6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 5 Oct 2023 17:00:40 -0500 Subject: [PATCH 21/84] Fix:Handle non-ascii characters in global search by not lowercasing in query #2187 --- server/controllers/LibraryController.js | 5 ++-- server/utils/index.js | 23 +++++++++++++++++++ .../utils/queries/libraryItemsBookFilters.js | 4 +++- .../queries/libraryItemsPodcastFilters.js | 4 +++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index f768bb93..b25e02aa 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -9,7 +9,8 @@ const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilter const libraryItemFilters = require('../utils/queries/libraryItemFilters') const seriesFilters = require('../utils/queries/seriesFilters') const fileUtils = require('../utils/fileUtils') -const { sort, createNewSortInstance } = require('../libs/fastSort') +const { asciiOnlyToLowerCase } = require('../utils/index') +const { createNewSortInstance } = require('../libs/fastSort') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare }) @@ -555,7 +556,7 @@ class LibraryController { return res.status(400).send('No query string') } const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 - const query = req.query.q.trim().toLowerCase() + const query = asciiOnlyToLowerCase(req.query.q.trim()) const matches = await libraryItemFilters.search(req.user, req.library, query, limit) res.json(matches) diff --git a/server/utils/index.js b/server/utils/index.js index 5797b0b5..abcc626c 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -166,4 +166,27 @@ module.exports.getTitleIgnorePrefix = (title) => { module.exports.getTitlePrefixAtEnd = (title) => { let [sort, prefix] = getTitleParts(title) return prefix ? `${sort}, ${prefix}` : title +} + +/** + * to lower case for only ascii characters + * used to handle sqlite that doesnt support unicode lower + * @see https://github.com/advplyr/audiobookshelf/issues/2187 + * + * @param {string} str + * @returns {string} + */ +module.exports.asciiOnlyToLowerCase = (str) => { + if (!str) return '' + + let temp = '' + for (let chars of str) { + let value = chars.charCodeAt() + if (value >= 65 && value <= 90) { + temp += String.fromCharCode(value + 32) + } else { + temp += chars + } + } + return temp } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 10e1101d..d23459b4 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -2,6 +2,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') const Logger = require('../../Logger') const authorFilters = require('./authorFilters') +const { asciiOnlyToLowerCase } = require('../index') module.exports = { /** @@ -1013,7 +1014,8 @@ module.exports = { let matchText = null let matchKey = null for (const key of ['title', 'subtitle', 'asin', 'isbn']) { - if (book[key]?.toLowerCase().includes(query)) { + const valueToLower = asciiOnlyToLowerCase(book[key]) + if (valueToLower.includes(query)) { matchText = book[key] matchKey = key break diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 27ac3fcd..7665c89b 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -2,6 +2,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') const Logger = require('../../Logger') +const { asciiOnlyToLowerCase } = require('../index') module.exports = { /** @@ -364,7 +365,8 @@ module.exports = { let matchText = null let matchKey = null for (const key of ['title', 'author', 'itunesId', 'itunesArtistId']) { - if (podcast[key]?.toLowerCase().includes(query)) { + const valueToLower = asciiOnlyToLowerCase(podcast[key]) + if (valueToLower.includes(query)) { matchText = podcast[key] matchKey = key break From db9d5c9d4329ce7ac3614dfb1d5cd4aed15eac0a Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 6 Oct 2023 16:52:12 -0500 Subject: [PATCH 22/84] Add:Support for pasting semicolon separated strings in multi select inputs #1198 --- client/components/ui/MultiSelect.vue | 27 +++++++++++++++- .../components/ui/MultiSelectQueryInput.vue | 31 ++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/client/components/ui/MultiSelect.vue b/client/components/ui/MultiSelect.vue index f2c542eb..4fa8e394 100644 --- a/client/components/ui/MultiSelect.vue +++ b/client/components/ui/MultiSelect.vue @@ -11,7 +11,7 @@
{{ item }}
- +
@@ -145,6 +145,31 @@ export default { this.menu.style.left = boundingBox.x + 'px' this.menu.style.width = boundingBox.width + 'px' }, + inputPaste(evt) { + setTimeout(() => { + const pastedText = evt.target?.value || '' + console.log('Pasted text=', pastedText) + const pastedItems = [ + ...new Set( + pastedText + .split(';') + .map((i) => i.trim()) + .filter((i) => i) + ) + ] + + // Filter out items already selected + const itemsToAdd = pastedItems.filter((i) => !this.selected.some((_i) => _i.toLowerCase() === i.toLowerCase())) + if (pastedItems.length && !itemsToAdd.length) { + this.textInput = null + this.currentSearch = null + } else { + for (const itemToAdd of itemsToAdd) { + this.insertNewItem(itemToAdd) + } + } + }, 10) + }, inputFocus() { if (!this.menu) { this.unmountMountMenu() diff --git a/client/components/ui/MultiSelectQueryInput.vue b/client/components/ui/MultiSelectQueryInput.vue index fb9528ce..c86d3228 100644 --- a/client/components/ui/MultiSelectQueryInput.vue +++ b/client/components/ui/MultiSelectQueryInput.vue @@ -14,7 +14,7 @@
add
- +
@@ -112,6 +112,7 @@ export default { return !!this.selected.find((i) => i.id === itemValue) }, search() { + if (!this.textInput) return this.currentSearch = this.textInput const dataToSearch = this.filterData[this.filterKey] || [] @@ -165,6 +166,34 @@ export default { this.menu.style.left = boundingBox.x + 'px' this.menu.style.width = boundingBox.width + 'px' }, + inputPaste(evt) { + setTimeout(() => { + const pastedText = evt.target?.value || '' + console.log('Pasted text=', pastedText) + const pastedItems = [ + ...new Set( + pastedText + .split(';') + .map((i) => i.trim()) + .filter((i) => i) + ) + ] + + // Filter out items already selected + const itemsToAdd = pastedItems.filter((i) => !this.selected.some((_i) => _i[this.textKey].toLowerCase() === i.toLowerCase())) + if (pastedItems.length && !itemsToAdd.length) { + this.textInput = null + this.currentSearch = null + } else { + for (const [index, itemToAdd] of itemsToAdd.entries()) { + this.insertNewItem({ + id: `new-${Date.now()}-${index}`, + name: itemToAdd + }) + } + } + }, 10) + }, inputFocus() { if (!this.menu) { this.unmountMountMenu() From f8f555b4b6ce1ef64dea6913a42d37fabc0f105f Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 7 Oct 2023 21:28:25 +0000 Subject: [PATCH 23/84] Remove some unused code in AuthorCandidates.add --- server/finders/BookFinder.js | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 54ac63a4..a0b64f55 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -294,23 +294,9 @@ class BookFinder { } add(author) { - const authorTransformers = [] - - // Main variant const cleanAuthor = this.bookFinder.cleanAuthorForCompares(author).trim() - if (!cleanAuthor) return false + if (!cleanAuthor) return this.candidates.add(cleanAuthor) - - let candidate = cleanAuthor - - for (const transformer of authorTransformers) { - candidate = candidate.replace(transformer[0], transformer[1]).trim() - if (candidate) { - this.candidates.add(candidate) - } - } - - return true } get size() { From 347b49f5645619f53144e92e2dec961f1d025b32 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Oct 2023 17:10:43 -0500 Subject: [PATCH 24/84] Update:Remove scanner settings, add library scanner settings tab, add order of precedence --- .../components/modals/libraries/EditModal.vue | 16 +- .../libraries/LibraryScannerSettings.vue | 129 ++++++ .../components/tables/library/LibraryItem.vue | 13 +- client/pages/config/index.vue | 130 +++--- client/strings/de.json | 6 - client/strings/en-us.json | 8 +- client/strings/es.json | 8 +- client/strings/fr.json | 6 - client/strings/gu.json | 6 - client/strings/hi.json | 6 - client/strings/hr.json | 6 - client/strings/it.json | 6 - client/strings/lt.json | 6 - client/strings/nl.json | 8 +- client/strings/no.json | 6 - client/strings/pl.json | 6 - client/strings/ru.json | 6 - client/strings/zh-cn.json | 6 - server/controllers/LibraryController.js | 12 +- server/models/Library.js | 1 + server/objects/mediaTypes/Book.js | 211 ---------- server/objects/mediaTypes/Music.js | 4 - server/objects/mediaTypes/Podcast.js | 31 -- server/objects/settings/LibrarySettings.js | 17 +- server/objects/settings/ServerSettings.js | 9 - server/scanner/AbsMetadataFileScanner.js | 65 +++ server/scanner/AudioFileScanner.js | 202 ++++++++++ server/scanner/BookScanner.js | 379 ++++-------------- server/scanner/LibraryItemScanData.js | 36 +- server/scanner/LibraryItemScanner.js | 13 +- server/scanner/LibraryScanner.js | 33 +- server/scanner/OpfFileScanner.js | 48 +++ server/scanner/PodcastScanner.js | 19 +- .../parsers/parseOverdriveMediaMarkers.js | 33 +- server/utils/scandir.js | 82 ++-- 35 files changed, 764 insertions(+), 809 deletions(-) create mode 100644 client/components/modals/libraries/LibraryScannerSettings.vue create mode 100644 server/scanner/AbsMetadataFileScanner.js create mode 100644 server/scanner/OpfFileScanner.js diff --git a/client/components/modals/libraries/EditModal.vue b/client/components/modals/libraries/EditModal.vue index 633b7646..1fd011cf 100644 --- a/client/components/modals/libraries/EditModal.vue +++ b/client/components/modals/libraries/EditModal.vue @@ -54,6 +54,9 @@ export default { buttonText() { return this.library ? this.$strings.ButtonSave : this.$strings.ButtonCreate }, + mediaType() { + return this.libraryCopy?.mediaType + }, tabs() { return [ { @@ -66,12 +69,19 @@ export default { title: this.$strings.HeaderSettings, component: 'modals-libraries-library-settings' }, + { + id: 'scanner', + title: this.$strings.HeaderSettingsScanner, + component: 'modals-libraries-library-scanner-settings' + }, { id: 'schedule', title: this.$strings.HeaderSchedule, component: 'modals-libraries-schedule-scan' } - ] + ].filter((tab) => { + return tab.id !== 'scanner' || this.mediaType === 'book' + }) }, tabName() { var _tab = this.tabs.find((t) => t.id === this.selectedTab) @@ -105,7 +115,9 @@ export default { disableWatcher: false, skipMatchingMediaWithAsin: false, skipMatchingMediaWithIsbn: false, - autoScanCronExpression: null + autoScanCronExpression: null, + hideSingleBookSeries: false, + metadataPrecedence: ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] } } }, diff --git a/client/components/modals/libraries/LibraryScannerSettings.vue b/client/components/modals/libraries/LibraryScannerSettings.vue new file mode 100644 index 00000000..95ae801a --- /dev/null +++ b/client/components/modals/libraries/LibraryScannerSettings.vue @@ -0,0 +1,129 @@ + + + \ No newline at end of file diff --git a/client/components/tables/library/LibraryItem.vue b/client/components/tables/library/LibraryItem.vue index b84dec44..cfb30a0c 100644 --- a/client/components/tables/library/LibraryItem.vue +++ b/client/components/tables/library/LibraryItem.vue @@ -74,6 +74,11 @@ export default { } ] if (this.isBookLibrary) { + items.push({ + text: this.$strings.ButtonForceReScan, + action: 'force-rescan', + value: 'force-rescan' + }) items.push({ text: this.$strings.ButtonMatchBooks, action: 'match-books', @@ -95,8 +100,8 @@ export default { this.editClick() } else if (action === 'scan') { this.scan() - } else if (action === 'force-scan') { - this.forceScan() + } else if (action === 'force-rescan') { + this.scan(true) } else if (action === 'match-books') { this.matchAll() } else if (action === 'delete') { @@ -121,9 +126,9 @@ export default { editClick() { this.$emit('edit', this.library) }, - scan() { + scan(force = false) { this.$store - .dispatch('libraries/requestLibraryScan', { libraryId: this.library.id }) + .dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force }) .then(() => { this.$toast.success(this.$strings.ToastLibraryScanStarted) }) diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 67391141..936f6a30 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -51,6 +51,56 @@
+
+

{{ $strings.HeaderSettingsScanner }}

+
+ +
+ + +

+ {{ $strings.LabelSettingsParseSubtitles }} + info_outlined +

+
+
+ +
+ + +

+ {{ $strings.LabelSettingsFindCovers }} + info_outlined +

+
+
+
+
+ +
+ +
+ + +

+ {{ $strings.LabelSettingsPreferMatchedMetadata }} + info_outlined +

+
+
+ +
+ + +

+ {{ $strings.LabelSettingsEnableWatcher }} + info_outlined +

+
+
+
+ +

{{ $strings.HeaderSettingsDisplay }}

@@ -88,86 +138,6 @@
-
- -
-
-

{{ $strings.HeaderSettingsScanner }}

-
- -
- - -

- {{ $strings.LabelSettingsParseSubtitles }} - info_outlined -

-
-
- -
- - -

- {{ $strings.LabelSettingsFindCovers }} - info_outlined -

-
-
-
-
- -
- -
- - -

- {{ $strings.LabelSettingsOverdriveMediaMarkers }} - info_outlined -

-
-
- -
- - -

- {{ $strings.LabelSettingsPreferAudioMetadata }} - info_outlined -

-
-
- -
- - -

- {{ $strings.LabelSettingsPreferOPFMetadata }} - info_outlined -

-
-
- -
- - -

- {{ $strings.LabelSettingsPreferMatchedMetadata }} - info_outlined -

-
-
- -
- - -

- {{ $strings.LabelSettingsEnableWatcher }} - info_outlined -

-
-
-
+
delete @@ -16,15 +16,16 @@
-
+
upload
+
- - {{ $strings.ButtonSave }} + + {{ $strings.ButtonSubmit }}
@@ -64,7 +65,7 @@

{{ $strings.MessageNoCoversFound }}

@@ -165,6 +166,9 @@ export default { userCanUpload() { return this.$store.getters['user/getUserCanUpload'] }, + userCanDelete() { + return this.$store.getters['user/getUserCanDelete'] + }, userToken() { return this.$store.getters['user/getToken'] }, @@ -222,71 +226,53 @@ export default { this.coversFound = [] this.hasSearched = false } - this.imageUrl = this.media.coverPath || '' + this.imageUrl = '' this.searchTitle = this.mediaMetadata.title || '' this.searchAuthor = this.mediaMetadata.authorName || '' if (this.isPodcast) this.provider = 'itunes' else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google' }, removeCover() { - if (!this.media.coverPath) { - this.imageUrl = '' + if (!this.coverPath) { return } - this.updateCover('') + this.isProcessing = true + this.$axios + .$delete(`/api/items/${this.libraryItemId}/cover`) + .then(() => {}) + .catch((error) => { + console.error('Failed to remove cover', error) + if (error.response?.data) { + this.$toast.error(error.response.data) + } + }) + .finally(() => { + this.isProcessing = false + }) }, submitForm() { this.updateCover(this.imageUrl) }, async updateCover(cover) { - if (cover === this.coverPath) { - console.warn('Cover has not changed..', cover) + if (!cover.startsWith('http:') && !cover.startsWith('https:')) { + this.$toast.error('Invalid URL') return } this.isProcessing = true - var success = false - - if (!cover) { - // Remove cover - success = await this.$axios - .$delete(`/api/items/${this.libraryItemId}/cover`) - .then(() => true) - .catch((error) => { - console.error('Failed to remove cover', error) - if (error.response && error.response.data) { - this.$toast.error(error.response.data) - } - return false - }) - } else if (cover.startsWith('http:') || cover.startsWith('https:')) { - // Download cover from url and use - success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => { - console.error('Failed to download cover from url', error) - if (error.response && error.response.data) { - this.$toast.error(error.response.data) - } - return false + this.$axios + .$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }) + .then(() => { + this.imageUrl = '' + this.$toast.success('Update Successful') }) - } else { - // Update local cover url - const updatePayload = { - cover - } - success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => { - console.error('Failed to update', error) - if (error.response && error.response.data) { - this.$toast.error(error.response.data) - } - return false + .catch((error) => { + console.error('Failed to update cover', error) + this.$toast.error(error.response?.data || 'Failed to update cover') + }) + .finally(() => { + this.isProcessing = false }) - } - if (success) { - this.$toast.success('Update Successful') - } else if (this.media.coverPath) { - this.imageUrl = this.media.coverPath - } - this.isProcessing = false }, getSearchQuery() { var searchQuery = `provider=${this.provider}&title=${this.searchTitle}` @@ -319,7 +305,19 @@ export default { this.hasSearched = true }, setCover(coverFile) { - this.updateCover(coverFile.metadata.path) + this.isProcessing = true + this.$axios + .$patch(`/api/items/${this.libraryItemId}/cover`, { cover: coverFile.metadata.path }) + .then(() => { + this.$toast.success('Update Successful') + }) + .catch((error) => { + console.error('Failed to set local cover', error) + this.$toast.error(error.response?.data || 'Failed to set cover') + }) + .finally(() => { + this.isProcessing = false + }) } } } diff --git a/client/strings/de.json b/client/strings/de.json index ccd42ede..b72df02f 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Stunde", "LabelIcon": "Symbol", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "In die Titelliste aufnehmen", "LabelIncomplete": "Unvollständig", "LabelInProgress": "In Bearbeitung", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 37478bf0..9195265e 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Hour", "LabelIcon": "Icon", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Include in Tracklist", "LabelIncomplete": "Incomplete", "LabelInProgress": "In Progress", diff --git a/client/strings/es.json b/client/strings/es.json index f7d548ba..f03c6352 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Hora", "LabelIcon": "Icono", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Incluir en Tracklist", "LabelIncomplete": "Incompleto", "LabelInProgress": "En Proceso", diff --git a/client/strings/fr.json b/client/strings/fr.json index 5d0e4b3a..031462b2 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -266,6 +266,7 @@ "LabelHost": "Hôte", "LabelHour": "Heure", "LabelIcon": "Icone", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Inclure dans la liste des pistes", "LabelIncomplete": "Incomplet", "LabelInProgress": "En cours", diff --git a/client/strings/gu.json b/client/strings/gu.json index 5018cf4d..0803ccf4 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Hour", "LabelIcon": "Icon", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Include in Tracklist", "LabelIncomplete": "Incomplete", "LabelInProgress": "In Progress", diff --git a/client/strings/hi.json b/client/strings/hi.json index 21ed9893..1eea8495 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Hour", "LabelIcon": "Icon", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Include in Tracklist", "LabelIncomplete": "Incomplete", "LabelInProgress": "In Progress", diff --git a/client/strings/hr.json b/client/strings/hr.json index b0e0db91..47908b18 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Sat", "LabelIcon": "Ikona", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Dodaj u Tracklist", "LabelIncomplete": "Nepotpuno", "LabelInProgress": "U tijeku", diff --git a/client/strings/it.json b/client/strings/it.json index 96a24392..b60a87c1 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Ora", "LabelIcon": "Icona", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Includi nella Tracklist", "LabelIncomplete": "Incompleta", "LabelInProgress": "In Corso", diff --git a/client/strings/lt.json b/client/strings/lt.json index 4f7bf2ed..31d259e6 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -266,6 +266,7 @@ "LabelHost": "Serveris", "LabelHour": "Valanda", "LabelIcon": "Piktograma", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Įtraukti į takelių sąrašą", "LabelIncomplete": "Nebaigta", "LabelInProgress": "Vyksta", diff --git a/client/strings/nl.json b/client/strings/nl.json index ac61de96..eb6b35b3 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Uur", "LabelIcon": "Icoon", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Includeer in tracklijst", "LabelIncomplete": "Incompleet", "LabelInProgress": "Bezig", diff --git a/client/strings/no.json b/client/strings/no.json index d1f51aac..f4fe316c 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -266,6 +266,7 @@ "LabelHost": "Tjener", "LabelHour": "Time", "LabelIcon": "Ikon", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Inkluder i sporliste", "LabelIncomplete": "Ufullstendig", "LabelInProgress": "I gang", diff --git a/client/strings/pl.json b/client/strings/pl.json index c4e6ae84..a645877b 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Godzina", "LabelIcon": "Ikona", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Dołącz do listy odtwarzania", "LabelIncomplete": "Nieukończone", "LabelInProgress": "W trakcie", diff --git a/client/strings/ru.json b/client/strings/ru.json index 3c95affa..f7f56965 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -266,6 +266,7 @@ "LabelHost": "Хост", "LabelHour": "Часы", "LabelIcon": "Иконка", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Включать в список воспроизведения", "LabelIncomplete": "Не завершен", "LabelInProgress": "В процессе", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 09eb6708..1d7f90dd 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -266,6 +266,7 @@ "LabelHost": "主机", "LabelHour": "小时", "LabelIcon": "图标", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "包含在音轨列表中", "LabelIncomplete": "未听完", "LabelInProgress": "正在听", diff --git a/package-lock.json b/package-lock.json index 77948004..7178ac98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "sequelize": "^6.32.1", "socket.io": "^4.5.4", "sqlite3": "^5.1.6", + "ssrf-req-filter": "^1.1.0", "xml2js": "^0.5.0" }, "bin": { @@ -2387,6 +2388,22 @@ } } }, + "node_modules/ssrf-req-filter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ssrf-req-filter/-/ssrf-req-filter-1.1.0.tgz", + "integrity": "sha512-YUyTinAEm52NsoDvkTFN9BQIa5nURNr2aN0BwOiJxHK3tlyGUczHa+2LjcibKNugAk/losB6kXOfcRzy0LQ4uA==", + "dependencies": { + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/ssrf-req-filter/node_modules/ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "engines": { + "node": ">= 10" + } + }, "node_modules/ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -4437,6 +4454,21 @@ "tar": "^6.1.11" } }, + "ssrf-req-filter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ssrf-req-filter/-/ssrf-req-filter-1.1.0.tgz", + "integrity": "sha512-YUyTinAEm52NsoDvkTFN9BQIa5nURNr2aN0BwOiJxHK3tlyGUczHa+2LjcibKNugAk/losB6kXOfcRzy0LQ4uA==", + "requires": { + "ipaddr.js": "^2.1.0" + }, + "dependencies": { + "ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==" + } + } + }, "ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -4672,4 +4704,4 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 4a00fa59..e76147d8 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,10 @@ "sequelize": "^6.32.1", "socket.io": "^4.5.4", "sqlite3": "^5.1.6", + "ssrf-req-filter": "^1.1.0", "xml2js": "^0.5.0" }, "devDependencies": { "nodemon": "^2.0.20" } -} \ No newline at end of file +} diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index ac019a96..b0ecf446 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -182,22 +182,22 @@ class LibraryItemController { return res.sendStatus(403) } - var libraryItem = req.libraryItem + let libraryItem = req.libraryItem - var result = null - if (req.body && req.body.url) { + let result = null + if (req.body?.url) { Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`) result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url) - } else if (req.files && req.files.cover) { + } else if (req.files?.cover) { Logger.debug(`[LibraryItemController] Handling uploaded cover`) result = await CoverManager.uploadCover(libraryItem, req.files.cover) } else { return res.status(400).send('Invalid request no file or url') } - if (result && result.error) { + if (result?.error) { return res.status(400).send(result.error) - } else if (!result || !result.cover) { + } else if (!result?.cover) { return res.status(500).send('Unknown error occurred') } diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index f30c9c6d..934deaff 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -120,13 +120,16 @@ class CoverManager { await fs.ensureDir(coverDirPath) var temppath = Path.posix.join(coverDirPath, 'cover') - var success = await downloadFile(url, temppath).then(() => true).catch((err) => { - Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) + + let errorMsg = '' + let success = await downloadFile(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' + error: 'Failed to download image from url: ' + errorMsg } } diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 966c7a93..37e89029 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -1,7 +1,8 @@ -const fs = require('../libs/fsExtra') -const rra = require('../libs/recursiveReaddirAsync') const axios = require('axios') const Path = require('path') +const ssrfFilter = require('ssrf-req-filter') +const fs = require('../libs/fsExtra') +const rra = require('../libs/recursiveReaddirAsync') const Logger = require('../Logger') const { AudioMimeType } = require('./constants') @@ -210,7 +211,9 @@ module.exports.downloadFile = (url, filepath) => { url, method: 'GET', responseType: 'stream', - timeout: 30000 + timeout: 30000, + httpAgent: ssrfFilter(url), + httpsAgent: ssrfFilter(url) }).then((response) => { const writer = fs.createWriteStream(filepath) response.data.pipe(writer) From 656c81a1fa6a0d599df7fac77b5cfbe7431d6b62 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 13 Oct 2023 17:37:37 -0500 Subject: [PATCH 33/84] Update:Remove image path input from author modal, add API endpoints for uploading and removing author image --- .../components/modals/authors/EditModal.vue | 92 ++++++++++------ server/controllers/AuthorController.js | 103 +++++++++++++----- server/finders/AuthorFinder.js | 45 ++++---- server/routers/ApiRouter.js | 2 + 4 files changed, 162 insertions(+), 80 deletions(-) diff --git a/client/components/modals/authors/EditModal.vue b/client/components/modals/authors/EditModal.vue index 3af64249..a4fb48a2 100644 --- a/client/components/modals/authors/EditModal.vue +++ b/client/components/modals/authors/EditModal.vue @@ -5,18 +5,23 @@

{{ title }}

-
-
-
-
-
- -
- delete -
+
+
+
+
+ +
+ delete
-
+
+
+ + + {{ $strings.ButtonSubmit }} + + +
@@ -25,9 +30,9 @@
-
+
@@ -39,9 +44,9 @@ {{ $strings.ButtonSave }}
-
+
- +
@@ -53,9 +58,9 @@ export default { authorCopy: { name: '', asin: '', - description: '', - imagePath: '' + description: '' }, + imageUrl: '', processing: false } }, @@ -100,10 +105,10 @@ export default { }, methods: { init() { + this.imageUrl = '' this.authorCopy.name = this.author.name this.authorCopy.asin = this.author.asin this.authorCopy.description = this.author.description - this.authorCopy.imagePath = this.author.imagePath }, removeClick() { const payload = { @@ -131,7 +136,7 @@ export default { this.$store.commit('globals/setConfirmPrompt', payload) }, async submitForm() { - var keysToCheck = ['name', 'asin', 'description', 'imagePath'] + var keysToCheck = ['name', 'asin', 'description'] var updatePayload = {} keysToCheck.forEach((key) => { if (this.authorCopy[key] !== this.author[key]) { @@ -160,21 +165,46 @@ export default { } this.processing = false }, - async removeCover() { - var updatePayload = { - imagePath: null - } + removeCover() { this.processing = true - var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => { - console.error('Failed', error) - this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed) - return null - }) - if (result && result.updated) { - this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess) - this.$store.commit('globals/showEditAuthorModal', result.author) + this.$axios + .$delete(`/api/authors/${this.authorId}/image`) + .then((data) => { + this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess) + this.$store.commit('globals/showEditAuthorModal', data.author) + }) + .catch((error) => { + console.error('Failed', error) + this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed) + }) + .finally(() => { + this.processing = false + }) + }, + submitUploadCover() { + if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) { + this.$toast.error('Invalid image url') + return } - this.processing = false + + this.processing = true + const updatePayload = { + url: this.imageUrl + } + this.$axios + .$post(`/api/authors/${this.authorId}/image`, updatePayload) + .then((data) => { + this.imageUrl = '' + this.$toast.success('Author image updated') + this.$store.commit('globals/showEditAuthorModal', data.author) + }) + .catch((error) => { + console.error('Failed', error) + this.$toast.error(error.response.data || 'Failed to remove author image') + }) + .finally(() => { + this.processing = false + }) }, async searchAuthor() { if (!this.authorCopy.name && !this.authorCopy.asin) { diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 0cd243fd..62a7ebde 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -67,30 +67,10 @@ class AuthorController { const payload = req.body let hasUpdated = false - // Updating/removing cover image - if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) { - if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file - await CacheManager.purgeImageCache(req.author.id) // Purge cache - await CoverManager.removeFile(req.author.imagePath) - } else if (payload.imagePath.startsWith('http')) { // Check if image path is a url - const imageData = await AuthorFinder.saveAuthorImage(req.author.id, payload.imagePath) - if (imageData) { - if (req.author.imagePath) { - await CacheManager.purgeImageCache(req.author.id) // Purge cache - } - payload.imagePath = imageData.path - hasUpdated = true - } - } else if (payload.imagePath && payload.imagePath !== req.author.imagePath) { // Changing image path locally - if (!await fs.pathExists(payload.imagePath)) { // Make sure image path exists - Logger.error(`[AuthorController] Image path does not exist: "${payload.imagePath}"`) - return res.status(400).send('Author image path does not exist') - } - - if (req.author.imagePath) { - await CacheManager.purgeImageCache(req.author.id) // Purge cache - } - } + // author imagePath must be set through other endpoints as of v2.4.5 + if (payload.imagePath !== undefined) { + Logger.warn(`[AuthorController] Updating local author imagePath is not supported`) + delete payload.imagePath } const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name @@ -131,7 +111,7 @@ class AuthorController { Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id) // Send updated num books for merged author - const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length + const numBooks = (await Database.libraryItemModel.getForAuthor(existingAuthor)).length SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks)) res.json({ @@ -191,6 +171,75 @@ class AuthorController { res.sendStatus(200) } + /** + * POST: /api/authors/:id/image + * Upload author image from web URL + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async uploadImage(req, res) { + if (!req.user.canUpload) { + Logger.warn('User attempted to upload an image without permission', req.user) + return res.sendStatus(403) + } + if (!req.body.url) { + Logger.error(`[AuthorController] Invalid request payload. 'url' not in request body`) + return res.status(400).send(`Invalid request payload. 'url' not in request body`) + } + if (!req.body.url.startsWith?.('http:') && !req.body.url.startsWith?.('https:')) { + Logger.error(`[AuthorController] Invalid request payload. Invalid url "${req.body.url}"`) + return res.status(400).send(`Invalid request payload. Invalid url "${req.body.url}"`) + } + + Logger.debug(`[AuthorController] Requesting download author image from url "${req.body.url}"`) + const result = await AuthorFinder.saveAuthorImage(req.author.id, req.body.url) + + if (result?.error) { + return res.status(400).send(result.error) + } else if (!result?.path) { + return res.status(500).send('Unknown error occurred') + } + + if (req.author.imagePath) { + await CacheManager.purgeImageCache(req.author.id) // Purge cache + } + + req.author.imagePath = result.path + await Database.authorModel.updateFromOld(req.author) + + const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length + SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) + res.json({ + author: req.author.toJSON() + }) + } + + /** + * DELETE: /api/authors/:id/image + * Remove author image & delete image file + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async deleteImage(req, res) { + if (!req.author.imagePath) { + Logger.error(`[AuthorController] Author "${req.author.imagePath}" has no imagePath set`) + return res.status(400).send('Author has no image path set') + } + Logger.info(`[AuthorController] Removing image for author "${req.author.name}" at "${req.author.imagePath}"`) + await CacheManager.purgeImageCache(req.author.id) // Purge cache + await CoverManager.removeFile(req.author.imagePath) + req.author.imagePath = null + await Database.authorModel.updateFromOld(req.author) + + const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length + SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) + res.json({ + author: req.author.toJSON() + }) + } + async match(req, res) { let authorData = null const region = req.body.region || 'us' @@ -215,7 +264,7 @@ class AuthorController { await CacheManager.purgeImageCache(req.author.id) const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image) - if (imageData) { + if (imageData?.path) { req.author.imagePath = imageData.path hasUpdates = true } @@ -231,7 +280,7 @@ class AuthorController { await Database.updateAuthor(req.author) - const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length + const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) } diff --git a/server/finders/AuthorFinder.js b/server/finders/AuthorFinder.js index 9c2a3b4f..59c6ce16 100644 --- a/server/finders/AuthorFinder.js +++ b/server/finders/AuthorFinder.js @@ -10,13 +10,6 @@ class AuthorFinder { this.audnexus = new Audnexus() } - async downloadImage(url, outputPath) { - return downloadFile(url, outputPath).then(() => true).catch((error) => { - Logger.error('[AuthorFinder] Failed to download author image', error) - return null - }) - } - findAuthorByASIN(asin, region) { if (!asin) return null return this.audnexus.findAuthorByASIN(asin, region) @@ -33,28 +26,36 @@ class AuthorFinder { return author } + /** + * Download author image from url and save in authors folder + * + * @param {string} authorId + * @param {string} url + * @returns {Promise<{path:string, error:string}>} + */ async saveAuthorImage(authorId, url) { - var authorDir = Path.join(global.MetadataPath, 'authors') - var relAuthorDir = Path.posix.join('/metadata', 'authors') + const authorDir = Path.join(global.MetadataPath, 'authors') if (!await fs.pathExists(authorDir)) { await fs.ensureDir(authorDir) } - var imageExtension = url.toLowerCase().split('.').pop() - var ext = imageExtension === 'png' ? 'png' : 'jpg' - var filename = authorId + '.' + ext - var outputPath = Path.posix.join(authorDir, filename) - var relPath = Path.posix.join(relAuthorDir, filename) + const imageExtension = url.toLowerCase().split('.').pop() + const ext = imageExtension === 'png' ? 'png' : 'jpg' + const filename = authorId + '.' + ext + const outputPath = Path.posix.join(authorDir, filename) - var success = await this.downloadImage(url, outputPath) - if (!success) { - return null - } - return { - path: outputPath, - relPath - } + return downloadFile(url, outputPath).then(() => { + return { + path: outputPath + } + }).catch((err) => { + let errorMsg = err.message || 'Unknown error' + Logger.error(`[AuthorFinder] Download image file failed for "${url}"`, errorMsg) + return { + error: errorMsg + } + }) } } module.exports = new AuthorFinder() \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index dc816b44..a90d1873 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -202,6 +202,8 @@ class ApiRouter { 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.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)) // // Series Routes From 616ecf77b062186f43a010c00ac94258d7cc074e Mon Sep 17 00:00:00 2001 From: SunX Date: Sat, 14 Oct 2023 20:30:27 +0800 Subject: [PATCH 34/84] Update zh-cn.json Update zh-cn.json --- client/strings/zh-cn.json | 46 +++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 1d7f90dd..f5a32ff4 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -138,7 +138,7 @@ "HeaderRemoveEpisodes": "移除 {0} 剧集", "HeaderRSSFeedGeneral": "RSS 详细信息", "HeaderRSSFeedIsOpen": "RSS 源已打开", - "HeaderRSSFeeds": "RSS Feeds", + "HeaderRSSFeeds": "RSS 订阅", "HeaderSavedMediaProgress": "保存媒体进度", "HeaderSchedule": "计划任务", "HeaderScheduleLibraryScans": "自动扫描媒体库", @@ -186,7 +186,7 @@ "LabelAuthors": "作者", "LabelAutoDownloadEpisodes": "自动下载剧集", "LabelBackToUser": "返回到用户", - "LabelBackupLocation": "Backup Location", + "LabelBackupLocation": "备份位置", "LabelBackupsEnableAutomaticBackups": "启用自动备份", "LabelBackupsEnableAutomaticBackupsHelp": "备份保存到 /metadata/backups", "LabelBackupsMaxBackupSize": "最大备份大小 (GB)", @@ -203,7 +203,7 @@ "LabelClosePlayer": "关闭播放器", "LabelCodec": "编解码", "LabelCollapseSeries": "折叠系列", - "LabelCollection": "Collection", + "LabelCollection": "收藏", "LabelCollections": "收藏", "LabelComplete": "已完成", "LabelConfirmPassword": "确认密码", @@ -225,9 +225,9 @@ "LabelDirectory": "目录", "LabelDiscFromFilename": "从文件名获取光盘", "LabelDiscFromMetadata": "从元数据获取光盘", - "LabelDiscover": "Discover", + "LabelDiscover": "发现", "LabelDownload": "下载", - "LabelDownloadNEpisodes": "Download {0} episodes", + "LabelDownloadNEpisodes": "下载 {0} 集", "LabelDuration": "持续时间", "LabelDurationFound": "找到持续时间:", "LabelEbook": "电子书", @@ -266,7 +266,7 @@ "LabelHost": "主机", "LabelHour": "小时", "LabelIcon": "图标", - "LabelImageURLFromTheWeb": "Image URL from the web", + "LabelImageURLFromTheWeb": "来自 Web 图像的 URL", "LabelIncludeInTracklist": "包含在音轨列表中", "LabelIncomplete": "未听完", "LabelInProgress": "正在听", @@ -323,7 +323,7 @@ "LabelNewPassword": "新密码", "LabelNextBackupDate": "下次备份日期", "LabelNextScheduledRun": "下次任务运行", - "LabelNoEpisodesSelected": "No episodes selected", + "LabelNoEpisodesSelected": "未选择任何剧集", "LabelNotes": "注释", "LabelNotFinished": "未听完", "LabelNotificationAppriseURL": "通知 URL(s)", @@ -383,8 +383,8 @@ "LabelSearchTitle": "搜索标题", "LabelSearchTitleOrASIN": "搜索标题或 ASIN", "LabelSeason": "季", - "LabelSelectAllEpisodes": "Select all episodes", - "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectAllEpisodes": "选择所有剧集", + "LabelSelectEpisodesShowing": "选择正在播放的 {0} 剧集", "LabelSendEbookToDevice": "发送电子书到...", "LabelSequence": "序列", "LabelSeries": "系列", @@ -400,15 +400,15 @@ "LabelSettingsDisableWatcher": "禁用监视程序", "LabelSettingsDisableWatcherForLibrary": "禁用媒体库的文件夹监视程序", "LabelSettingsDisableWatcherHelp": "检测到文件更改时禁用自动添加和更新项目. *需要重启服务器", - "LabelSettingsEnableWatcher": "Enable Watcher", - "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library", - "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart", + "LabelSettingsEnableWatcher": "启用监视程序", + "LabelSettingsEnableWatcherForLibrary": "为库启用文件夹监视程序", + "LabelSettingsEnableWatcherHelp": "当检测到文件更改时, 启用项目的自动添加/更新. *需要重新启动服务器", "LabelSettingsExperimentalFeatures": "实验功能", "LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.", "LabelSettingsFindCovers": "查找封面", "LabelSettingsFindCoversHelp": "如果你的有声读物在文件夹中没有嵌入封面或封面图像, 扫描将尝试查找封面.
注意: 这将延长扫描时间", - "LabelSettingsHideSingleBookSeries": "Hide single book series", - "LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.", + "LabelSettingsHideSingleBookSeries": "隐藏单书系列", + "LabelSettingsHideSingleBookSeriesHelp": "只有一本书的系列将从系列页面和主页书架中隐藏.", "LabelSettingsHomePageBookshelfView": "首页使用书架视图", "LabelSettingsLibraryBookshelfView": "媒体库使用书架视图", "LabelSettingsParseSubtitles": "解析副标题", @@ -477,7 +477,7 @@ "LabelTrackFromMetadata": "从源数据获取音轨", "LabelTracks": "音轨", "LabelTracksMultiTrack": "多轨", - "LabelTracksNone": "No tracks", + "LabelTracksNone": "没有音轨", "LabelTracksSingleTrack": "单轨", "LabelType": "类型", "LabelUnabridged": "未删节", @@ -518,20 +518,20 @@ "MessageChapterErrorStartLtPrev": "无效的开始时间, 必须大于或等于上一章节的开始时间", "MessageChapterStartIsAfter": "章节开始是在有声读物结束之后", "MessageCheckingCron": "检查计划任务...", - "MessageConfirmCloseFeed": "Are you sure you want to close this feed?", + "MessageConfirmCloseFeed": "你确定要关闭此订阅源吗?", "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?", "MessageConfirmDeleteSession": "你确定要删除此会话吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?", - "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", - "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", + "MessageConfirmMarkAllEpisodesFinished": "你确定要将所有剧集都标记为已完成吗?", + "MessageConfirmMarkAllEpisodesNotFinished": "你确定要将所有剧集都标记为未完成吗?", "MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?", "MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?", "MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?", - "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", - "MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?", - "MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?", + "MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?", + "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", + "MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveNarrator": "你确定要删除演播者 \"{0}\"?", "MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?", @@ -560,8 +560,8 @@ "MessageM4BFailed": "M4B 失败!", "MessageM4BFinished": "M4B 完成!", "MessageMapChapterTitles": "将章节标题映射到现有的有声读物章节, 无需调整时间戳", - "MessageMarkAllEpisodesFinished": "Mark all episodes finished", - "MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished", + "MessageMarkAllEpisodesFinished": "标记所有剧集为已完成", + "MessageMarkAllEpisodesNotFinished": "标记所有剧集为未完成", "MessageMarkAsFinished": "标记为已听完", "MessageMarkAsNotFinished": "标记为未听完", "MessageMatchBooksDescription": "尝试将媒体库中的图书与所选搜索提供商的图书进行匹配, 并填写空白的详细信息和封面. 不覆盖详细信息.", From c98fac30b6040c8822f8f737ce90590f7a79d95a Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 14 Oct 2023 10:52:56 -0500 Subject: [PATCH 35/84] Update:Validate image URI content-type before writing image file --- server/finders/AuthorFinder.js | 4 ++-- server/managers/CoverManager.js | 6 +++--- server/utils/fileUtils.js | 32 +++++++++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/server/finders/AuthorFinder.js b/server/finders/AuthorFinder.js index 59c6ce16..69aa724d 100644 --- a/server/finders/AuthorFinder.js +++ b/server/finders/AuthorFinder.js @@ -3,7 +3,7 @@ const Logger = require('../Logger') const Path = require('path') const Audnexus = require('../providers/Audnexus') -const { downloadFile } = require('../utils/fileUtils') +const { downloadImageFile } = require('../utils/fileUtils') class AuthorFinder { constructor() { @@ -45,7 +45,7 @@ class AuthorFinder { const filename = authorId + '.' + ext const outputPath = Path.posix.join(authorDir, filename) - return downloadFile(url, outputPath).then(() => { + return downloadImageFile(url, outputPath).then(() => { return { path: outputPath } diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 934deaff..3cf97f33 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -5,7 +5,7 @@ const readChunk = require('../libs/readChunk') const imageType = require('../libs/imageType') const globals = require('../utils/globals') -const { downloadFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils') +const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils') const { extractCoverArt } = require('../utils/ffmpegHelpers') const CacheManager = require('../managers/CacheManager') @@ -122,7 +122,7 @@ class CoverManager { var temppath = Path.posix.join(coverDirPath, 'cover') let errorMsg = '' - let success = await downloadFile(url, temppath).then(() => true).catch((err) => { + 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 @@ -287,7 +287,7 @@ class CoverManager { await fs.ensureDir(coverDirPath) const temppath = Path.posix.join(coverDirPath, 'cover') - const success = await downloadFile(url, temppath).then(() => true).catch((err) => { + const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) return false }) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 37e89029..4df26400 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -204,7 +204,16 @@ async function recurseFiles(path, relPathToReplace = null) { } module.exports.recurseFiles = recurseFiles -module.exports.downloadFile = (url, filepath) => { +/** + * Download file from web to local file system + * Uses SSRF filter to prevent internal URLs + * + * @param {string} url + * @param {string} filepath path to download the file to + * @param {Function} [contentTypeFilter] validate content type before writing + * @returns {Promise} + */ +module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => { return new Promise(async (resolve, reject) => { Logger.debug(`[fileUtils] Downloading file to ${filepath}`) axios({ @@ -215,6 +224,12 @@ module.exports.downloadFile = (url, filepath) => { httpAgent: ssrfFilter(url), httpsAgent: ssrfFilter(url) }).then((response) => { + // Validate content type + if (contentTypeFilter && !contentTypeFilter?.(response.headers?.['content-type'])) { + return reject(new Error(`Invalid content type "${response.headers?.['content-type'] || ''}"`)) + } + + // Write to filepath const writer = fs.createWriteStream(filepath) response.data.pipe(writer) @@ -227,6 +242,21 @@ module.exports.downloadFile = (url, filepath) => { }) } +/** + * Download image file from web to local file system + * Response header must have content-type of image/ (excluding svg) + * + * @param {string} url + * @param {string} filepath + * @returns {Promise} + */ +module.exports.downloadImageFile = (url, filepath) => { + const contentTypeFilter = (contentType) => { + return contentType?.startsWith('image/') && contentType !== 'image/svg+xml' + } + return this.downloadFile(url, filepath, contentTypeFilter) +} + module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => { if (typeof filename !== 'string') { return false From dcdd4bb20b26a6830512df7498130fc0781378f0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 14 Oct 2023 12:50:48 -0500 Subject: [PATCH 36/84] Update:HLS router request validation, smooth out transcode reset logic --- server/objects/Stream.js | 22 +++++++------ server/routers/HlsRouter.js | 65 +++++++++++++++++++++++++++---------- 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/server/objects/Stream.js b/server/objects/Stream.js index 115bb96e..2ee66182 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -101,7 +101,6 @@ class Stream extends EventEmitter { return 'mpegts' } get segmentBasename() { - if (this.hlsSegmentType === 'fmp4') return 'output-%d.m4s' return 'output-%d.ts' } get segmentStartNumber() { @@ -142,19 +141,21 @@ class Stream extends EventEmitter { async checkSegmentNumberRequest(segNum) { const segStartTime = segNum * this.segmentLength - if (this.startTime > segStartTime) { - Logger.warn(`[STREAM] Segment #${segNum} Request @${secondsToTimestamp(segStartTime)} is before start time (${secondsToTimestamp(this.startTime)}) - Reset Transcode`) - await this.reset(segStartTime - (this.segmentLength * 2)) + if (this.segmentStartNumber > segNum) { + Logger.warn(`[STREAM] Segment #${segNum} Request is before starting segment number #${this.segmentStartNumber} - Reset Transcode`) + await this.reset(segStartTime - (this.segmentLength * 5)) return segStartTime } else if (this.isTranscodeComplete) { return false } - const distanceFromFurthestSegment = segNum - this.furthestSegmentCreated - if (distanceFromFurthestSegment > 10) { - Logger.info(`Segment #${segNum} requested is ${distanceFromFurthestSegment} segments from latest (${secondsToTimestamp(segStartTime)}) - Reset Transcode`) - await this.reset(segStartTime - (this.segmentLength * 2)) - return segStartTime + if (this.furthestSegmentCreated) { + const distanceFromFurthestSegment = segNum - this.furthestSegmentCreated + if (distanceFromFurthestSegment > 10) { + Logger.info(`Segment #${segNum} requested is ${distanceFromFurthestSegment} segments from latest (${secondsToTimestamp(segStartTime)}) - Reset Transcode`) + await this.reset(segStartTime - (this.segmentLength * 5)) + return segStartTime + } } return false @@ -171,7 +172,7 @@ class Stream extends EventEmitter { var files = await fs.readdir(this.streamPath) files.forEach((file) => { var extname = Path.extname(file) - if (extname === '.ts' || extname === '.m4s') { + if (extname === '.ts') { var basename = Path.basename(file, extname) var num_part = basename.split('-')[1] var part_num = Number(num_part) @@ -251,6 +252,7 @@ class Stream extends EventEmitter { Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`) this.ffmpeg = Ffmpeg() + this.furthestSegmentCreated = 0 var adjustedStartTime = Math.max(this.startTime - this.maxSeekBackTime, 0) var trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, adjustedStartTime) diff --git a/server/routers/HlsRouter.js b/server/routers/HlsRouter.js index d4f1bc60..711e360a 100644 --- a/server/routers/HlsRouter.js +++ b/server/routers/HlsRouter.js @@ -27,28 +27,60 @@ class HlsRouter { return Number(num_part) } - async streamFileRequest(req, res) { - var streamId = req.params.stream - var fullFilePath = Path.join(this.playbackSessionManager.StreamsPath, streamId, req.params.file) + /** + * Ensure filepath is inside streamDir + * Used to prevent arbitrary file reads + * @see https://nodejs.org/api/path.html#pathrelativefrom-to + * + * @param {string} streamDir + * @param {string} filepath + * @returns {boolean} + */ + validateStreamFilePath(streamDir, filepath) { + const relative = Path.relative(streamDir, filepath) + return relative && !relative.startsWith('..') && !Path.isAbsolute(relative) + } - var exists = await fs.pathExists(fullFilePath) - if (!exists) { + /** + * GET /hls/:stream/:file + * File must have extname .ts or .m3u8 + * + * @param {express.Request} req + * @param {express.Response} res + */ + async streamFileRequest(req, res) { + const streamId = req.params.stream + // Ensure stream is open + const stream = this.playbackSessionManager.getStream(streamId) + if (!stream) { + Logger.error(`[HlsRouter] Stream "${streamId}" does not exist`) + return res.sendStatus(404) + } + + // Ensure stream filepath is valid + const streamDir = Path.join(this.playbackSessionManager.StreamsPath, streamId) + const fullFilePath = Path.join(streamDir, req.params.file) + if (!this.validateStreamFilePath(streamDir, fullFilePath)) { + Logger.error(`[HlsRouter] Invalid file parameter "${req.params.file}"`) + return res.sendStatus(400) + } + + const fileExt = Path.extname(req.params.file) + if (fileExt !== '.ts' && fileExt !== '.m3u8') { + Logger.error(`[HlsRouter] Invalid file parameter "${req.params.file}" extname. Must be .ts or .m3u8`) + return res.sendStatus(400) + } + + if (!(await fs.pathExists(fullFilePath))) { Logger.warn('File path does not exist', fullFilePath) - var fileExt = Path.extname(req.params.file) - if (fileExt === '.ts' || fileExt === '.m4s') { - var segNum = this.parseSegmentFilename(req.params.file) - var stream = this.playbackSessionManager.getStream(streamId) - if (!stream) { - Logger.error(`[HlsRouter] Stream ${streamId} does not exist`) - return res.sendStatus(500) - } + if (fileExt === '.ts') { + const segNum = this.parseSegmentFilename(req.params.file) if (stream.isResetting) { Logger.info(`[HlsRouter] Stream ${streamId} is currently resetting`) - return res.sendStatus(404) } else { - var startTimeForReset = await stream.checkSegmentNumberRequest(segNum) + const startTimeForReset = await stream.checkSegmentNumberRequest(segNum) if (startTimeForReset) { // HLS.js will restart the stream at the new time Logger.info(`[HlsRouter] Resetting Stream - notify client @${startTimeForReset}s`) @@ -56,13 +88,12 @@ class HlsRouter { startTime: startTimeForReset, streamId: stream.id }) - return res.sendStatus(500) } } } + return res.sendStatus(404) } - // Logger.info('Sending file', fullFilePath) res.sendFile(fullFilePath) } } From 07ad81969ced69a94c7eabedb75e6699a182dc71 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 14 Oct 2023 15:04:16 -0500 Subject: [PATCH 37/84] Update:Scanner recognizes asin in book folder names #1852 --- server/scanner/AbsMetadataFileScanner.js | 2 +- server/scanner/LibraryItemScanData.js | 2 +- server/utils/scandir.js | 71 +++++++++++++++++++----- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/server/scanner/AbsMetadataFileScanner.js b/server/scanner/AbsMetadataFileScanner.js index d9d077c0..037726f6 100644 --- a/server/scanner/AbsMetadataFileScanner.js +++ b/server/scanner/AbsMetadataFileScanner.js @@ -55,7 +55,7 @@ class AbsMetadataFileScanner { bookMetadata.chapters = abMetadata.chapters } for (const key in abMetadata.metadata) { - if (abMetadata.metadata[key] === undefined) continue + if (abMetadata.metadata[key] === undefined || abMetadata.metadata[key] === null) continue bookMetadata[key] = abMetadata.metadata[key] } } diff --git a/server/scanner/LibraryItemScanData.js b/server/scanner/LibraryItemScanData.js index c272127f..576280c8 100644 --- a/server/scanner/LibraryItemScanData.js +++ b/server/scanner/LibraryItemScanData.js @@ -309,7 +309,7 @@ class LibraryItemScanData { * @param {Object} bookMetadata */ setBookMetadataFromFilenames(bookMetadata) { - const keysToMap = ['title', 'subtitle', 'publishedYear'] + const keysToMap = ['title', 'subtitle', 'publishedYear', 'asin'] for (const key in this.mediaMetadata) { if (keysToMap.includes(key) && this.mediaMetadata[key]) { bookMetadata[key] = this.mediaMetadata[key] diff --git a/server/utils/scandir.js b/server/utils/scandir.js index df6639e0..21c28b8c 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -8,6 +8,7 @@ const parseNameString = require('./parsers/parseNameString') * @typedef LibraryItemFilenameMetadata * @property {string} title * @property {string} subtitle Book mediaType only + * @property {string} asin Book mediaType only * @property {string[]} authors Book mediaType only * @property {string[]} narrators Book mediaType only * @property {string} seriesName Book mediaType only @@ -237,14 +238,17 @@ function getBookDataFromDir(relPath, parseSubtitle = false) { author = (splitDir.length > 0) ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/ // The may contain various other pieces of metadata, these functions extract it. + var [folder, asin] = getASIN(folder) var [folder, narrators] = getNarrator(folder) var [folder, sequence] = series ? getSequence(folder) : [folder, null] var [folder, publishedYear] = getPublishedYear(folder) var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null] + return { title, subtitle, + asin, authors: parseNameString.parse(author)?.names || [], narrators: parseNameString.parse(narrators)?.names || [], seriesName: series, @@ -254,27 +258,36 @@ function getBookDataFromDir(relPath, parseSubtitle = false) { } module.exports.getBookDataFromDir = getBookDataFromDir +/** + * Extract narrator from folder name + * + * @param {string} folder + * @returns {[string, string]} [folder, narrator] + */ function getNarrator(folder) { let pattern = /^(?.*) \{(?<narrators>.*)\}$/ let match = folder.match(pattern) return match ? [match.groups.title, match.groups.narrators] : [folder, null] } +/** + * Extract series sequence from folder name + * + * @example + * 'Book 2 - Title - Subtitle' + * 'Title - Subtitle - Vol 12' + * 'Title - volume 9 - Subtitle' + * 'Vol. 3 Title Here - Subtitle' + * '1980 - Book 2 - Title' + * 'Volume 12. Title - Subtitle' + * '100 - Book Title' + * '6. Title' + * '0.5 - Book Title' + * + * @param {string} folder + * @returns {[string, string]} [folder, sequence] + */ function getSequence(folder) { - // Valid ways of including a volume number: - // [ - // 'Book 2 - Title - Subtitle', - // 'Title - Subtitle - Vol 12', - // 'Title - volume 9 - Subtitle', - // 'Vol. 3 Title Here - Subtitle', - // '1980 - Book 2 - Title', - // 'Volume 12. Title - Subtitle', - // '100 - Book Title', - // '2 - Book Title', - // '6. Title', - // '0.5 - Book Title' - // ] - // Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later. let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{0,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?$/i @@ -295,6 +308,12 @@ function getSequence(folder) { return [folder, volumeNumber] } +/** + * Extract published year from folder name + * + * @param {string} folder + * @returns {[string, string]} [folder, publishedYear] + */ function getPublishedYear(folder) { var publishedYear = null @@ -308,12 +327,36 @@ function getPublishedYear(folder) { return [folder, publishedYear] } +/** + * Extract subtitle from folder name + * + * @param {string} folder + * @returns {[string, string]} [folder, subtitle] + */ function getSubtitle(folder) { // Subtitle is everything after " - " var splitTitle = folder.split(' - ') return [splitTitle.shift(), splitTitle.join(' - ')] } +/** + * Extract asin from folder name + * + * @param {string} folder + * @returns {[string, string]} [folder, asin] + */ +function getASIN(folder) { + let asin = null + + let pattern = /(?: |^)\[([A-Z0-9]{10})](?= |$)/ // Matches "[B0015T963C]" + const match = folder.match(pattern) + if (match) { + asin = match[1] + folder = folder.replace(match[0], '') + } + return [folder.trim(), asin] +} + /** * * @param {string} relPath From cdd740015c2daa08e870090f35a8e42a28c55042 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 15 Oct 2023 08:23:22 -0500 Subject: [PATCH 38/84] Add:Danish translations --- client/plugins/i18n.js | 1 + client/strings/da.json | 711 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 712 insertions(+) create mode 100644 client/strings/da.json diff --git a/client/plugins/i18n.js b/client/plugins/i18n.js index 5f193d7b..f404bb80 100644 --- a/client/plugins/i18n.js +++ b/client/plugins/i18n.js @@ -5,6 +5,7 @@ import { supplant } from './utils' const defaultCode = 'en-us' const languageCodeMap = { + 'da': { label: 'Dansk', dateFnsLocale: 'da' }, 'de': { label: 'Deutsch', dateFnsLocale: 'de' }, 'en-us': { label: 'English', dateFnsLocale: 'enUS' }, 'es': { label: 'Español', dateFnsLocale: 'es' }, diff --git a/client/strings/da.json b/client/strings/da.json new file mode 100644 index 00000000..359ecdd6 --- /dev/null +++ b/client/strings/da.json @@ -0,0 +1,711 @@ +{ + "ButtonAdd": "Tilføj", + "ButtonAddChapters": "Tilføj kapitler", + "ButtonAddPodcasts": "Tilføj podcasts", + "ButtonAddYourFirstLibrary": "Tilføj din første bibliotek", + "ButtonApply": "Anvend", + "ButtonApplyChapters": "Anvend kapitler", + "ButtonAuthors": "Forfattere", + "ButtonBrowseForFolder": "Gennemse mappe", + "ButtonCancel": "Annuller", + "ButtonCancelEncode": "Annuller kodning", + "ButtonChangeRootPassword": "Ændr rodadgangskode", + "ButtonCheckAndDownloadNewEpisodes": "Tjek og download nye episoder", + "ButtonChooseAFolder": "Vælg en mappe", + "ButtonChooseFiles": "Vælg filer", + "ButtonClearFilter": "Ryd filter", + "ButtonCloseFeed": "Luk feed", + "ButtonCollections": "Samlinger", + "ButtonConfigureScanner": "Konfigurer scanner", + "ButtonCreate": "Opret", + "ButtonCreateBackup": "Opret sikkerhedskopi", + "ButtonDelete": "Slet", + "ButtonDownloadQueue": "Kø", + "ButtonEdit": "Rediger", + "ButtonEditChapters": "Rediger kapitler", + "ButtonEditPodcast": "Rediger podcast", + "ButtonForceReScan": "Tvungen genindlæsning", + "ButtonFullPath": "Fuld sti", + "ButtonHide": "Skjul", + "ButtonHome": "Hjem", + "ButtonIssues": "Problemer", + "ButtonLatest": "Seneste", + "ButtonLibrary": "Bibliotek", + "ButtonLogout": "Log ud", + "ButtonLookup": "Slå op", + "ButtonManageTracks": "Administrer spor", + "ButtonMapChapterTitles": "Kortlæg kapiteloverskrifter", + "ButtonMatchAllAuthors": "Match alle forfattere", + "ButtonMatchBooks": "Match bøger", + "ButtonNevermind": "Glem det", + "ButtonOk": "OK", + "ButtonOpenFeed": "Åbn feed", + "ButtonOpenManager": "Åbn manager", + "ButtonPlay": "Afspil", + "ButtonPlaying": "Afspiller", + "ButtonPlaylists": "Afspilningslister", + "ButtonPurgeAllCache": "Ryd al cache", + "ButtonPurgeItemsCache": "Ryd elementcache", + "ButtonPurgeMediaProgress": "Ryd Medieforløb", + "ButtonQueueAddItem": "Tilføj til kø", + "ButtonQueueRemoveItem": "Fjern fra kø", + "ButtonQuickMatch": "Hurtig Match", + "ButtonRead": "Læs", + "ButtonRemove": "Fjern", + "ButtonRemoveAll": "Fjern Alle", + "ButtonRemoveAllLibraryItems": "Fjern Alle Bibliotekselementer", + "ButtonRemoveFromContinueListening": "Fjern fra Fortsæt Lytning", + "ButtonRemoveFromContinueReading": "Fjern fra Fortsæt Læsning", + "ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra Fortsæt Serie", + "ButtonReScan": "Gen-scan", + "ButtonReset": "Nulstil", + "ButtonRestore": "Gendan", + "ButtonSave": "Gem", + "ButtonSaveAndClose": "Gem & Luk", + "ButtonSaveTracklist": "Gem Sporliste", + "ButtonScan": "Scan", + "ButtonScanLibrary": "Scan Bibliotek", + "ButtonSearch": "Søg", + "ButtonSelectFolderPath": "Vælg Mappen Sti", + "ButtonSeries": "Serie", + "ButtonSetChaptersFromTracks": "Sæt kapitler fra spor", + "ButtonShiftTimes": "Skift Tider", + "ButtonShow": "Vis", + "ButtonStartM4BEncode": "Start M4B Kode", + "ButtonStartMetadataEmbed": "Start Metadata Indlejring", + "ButtonSubmit": "Send", + "ButtonTest": "Test", + "ButtonUpload": "Upload", + "ButtonUploadBackup": "Upload Backup", + "ButtonUploadCover": "Upload Omslag", + "ButtonUploadOPMLFile": "Upload OPML Fil", + "ButtonUserDelete": "Slet bruger {0}", + "ButtonUserEdit": "Rediger bruger {0}", + "ButtonViewAll": "Vis Alle", + "ButtonYes": "Ja", + "HeaderAccount": "Konto", + "HeaderAdvanced": "Avanceret", + "HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger", + "HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer", + "HeaderAudioTracks": "Lydspor", + "HeaderBackups": "Sikkerhedskopier", + "HeaderChangePassword": "Skift Adgangskode", + "HeaderChapters": "Kapitler", + "HeaderChooseAFolder": "Vælg en Mappe", + "HeaderCollection": "Samling", + "HeaderCollectionItems": "Samlingselementer", + "HeaderCover": "Omslag", + "HeaderCurrentDownloads": "Nuværende Downloads", + "HeaderDetails": "Detaljer", + "HeaderDownloadQueue": "Download Kø", + "HeaderEbookFiles": "E-bogsfiler", + "HeaderEmail": "Email", + "HeaderEmailSettings": "Email Indstillinger", + "HeaderEpisodes": "Episoder", + "HeaderEreaderDevices": "E-læser Enheder", + "HeaderEreaderSettings": "E-læser Indstillinger", + "HeaderFiles": "Filer", + "HeaderFindChapters": "Find Kapitler", + "HeaderIgnoredFiles": "Ignorerede Filer", + "HeaderItemFiles": "Emnefiler", + "HeaderItemMetadataUtils": "Emne Metadata Værktøjer", + "HeaderLastListeningSession": "Seneste Lyttesession", + "HeaderLatestEpisodes": "Seneste episoder", + "HeaderLibraries": "Biblioteker", + "HeaderLibraryFiles": "Biblioteksfiler", + "HeaderLibraryStats": "Biblioteksstatistik", + "HeaderListeningSessions": "Lyttesessioner", + "HeaderListeningStats": "Lyttestatistik", + "HeaderLogin": "Log ind", + "HeaderLogs": "Logs", + "HeaderManageGenres": "Administrer Genrer", + "HeaderManageTags": "Administrer Tags", + "HeaderMapDetails": "Kort Detaljer", + "HeaderMatch": "Match", + "HeaderMetadataToEmbed": "Metadata til indlejring", + "HeaderNewAccount": "Ny Konto", + "HeaderNewLibrary": "Nyt Bibliotek", + "HeaderNotifications": "Meddelelser", + "HeaderOpenRSSFeed": "Åbn RSS Feed", + "HeaderOtherFiles": "Andre Filer", + "HeaderPermissions": "Tilladelser", + "HeaderPlayerQueue": "Afspilningskø", + "HeaderPlaylist": "Afspilningsliste", + "HeaderPlaylistItems": "Afspilningsliste Elementer", + "HeaderPodcastsToAdd": "Podcasts til Tilføjelse", + "HeaderPreviewCover": "Forhåndsvis Omslag", + "HeaderRemoveEpisode": "Fjern Episode", + "HeaderRemoveEpisodes": "Fjern {0} Episoder", + "HeaderRSSFeedGeneral": "RSS Detaljer", + "HeaderRSSFeedIsOpen": "RSS Feed er Åben", + "HeaderRSSFeeds": "RSS Feeds", + "HeaderSavedMediaProgress": "Gemt Medieforløb", + "HeaderSchedule": "Planlæg", + "HeaderScheduleLibraryScans": "Planlæg Automatiske Biblioteksscanninger", + "HeaderSession": "Session", + "HeaderSetBackupSchedule": "Indstil Sikkerhedskopieringsplan", + "HeaderSettings": "Indstillinger", + "HeaderSettingsDisplay": "Skærm", + "HeaderSettingsExperimental": "Eksperimentelle Funktioner", + "HeaderSettingsGeneral": "Generelt", + "HeaderSettingsScanner": "Scanner", + "HeaderSleepTimer": "Søvntimer", + "HeaderStatsLargestItems": "Største Elementer", + "HeaderStatsLongestItems": "Længste Elementer (timer)", + "HeaderStatsMinutesListeningChart": "Minutter Lyttet (sidste 7 dage)", + "HeaderStatsRecentSessions": "Seneste Sessions", + "HeaderStatsTop10Authors": "Top 10 Forfattere", + "HeaderStatsTop5Genres": "Top 5 Genrer", + "HeaderTableOfContents": "Indholdsfortegnelse", + "HeaderTools": "Værktøjer", + "HeaderUpdateAccount": "Opdater Konto", + "HeaderUpdateAuthor": "Opdater Forfatter", + "HeaderUpdateDetails": "Opdater Detaljer", + "HeaderUpdateLibrary": "Opdater Bibliotek", + "HeaderUsers": "Brugere", + "HeaderYourStats": "Dine Statistikker", + "LabelAbridged": "Abridged", + "LabelAccountType": "Kontotype", + "LabelAccountTypeAdmin": "Administrator", + "LabelAccountTypeGuest": "Gæst", + "LabelAccountTypeUser": "Bruger", + "LabelActivity": "Aktivitet", + "LabelAdded": "Tilføjet", + "LabelAddedAt": "Tilføjet Kl.", + "LabelAddToCollection": "Tilføj til Samling", + "LabelAddToCollectionBatch": "Tilføj {0} Bøger til Samling", + "LabelAddToPlaylist": "Tilføj til Afspilningsliste", + "LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste", + "LabelAll": "Alle", + "LabelAllUsers": "Alle Brugere", + "LabelAlreadyInYourLibrary": "Allerede i dit bibliotek", + "LabelAppend": "Tilføj", + "LabelAuthor": "Forfatter", + "LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)", + "LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)", + "LabelAuthors": "Forfattere", + "LabelAutoDownloadEpisodes": "Auto Download Episoder", + "LabelBackToUser": "Tilbage til Bruger", + "LabelBackupLocation": "Backup Placering", + "LabelBackupsEnableAutomaticBackups": "Aktivér automatisk sikkerhedskopiering", + "LabelBackupsEnableAutomaticBackupsHelp": "Sikkerhedskopier gemt i /metadata/backups", + "LabelBackupsMaxBackupSize": "Maksimal sikkerhedskopistørrelse (i GB)", + "LabelBackupsMaxBackupSizeHelp": "Som en beskyttelse mod fejlkonfiguration fejler sikkerhedskopier, hvis de overstiger den konfigurerede størrelse.", + "LabelBackupsNumberToKeep": "Antal sikkerhedskopier at beholde", + "LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.", + "LabelBitrate": "Bitrate", + "LabelBooks": "Bøger", + "LabelChangePassword": "Ændre Adgangskode", + "LabelChannels": "Kanaler", + "LabelChapters": "Kapitler", + "LabelChaptersFound": "fundne kapitler", + "LabelChapterTitle": "Kapitel Titel", + "LabelClosePlayer": "Luk afspiller", + "LabelCodec": "Codec", + "LabelCollapseSeries": "Fold Serie Sammen", + "LabelCollection": "Samling", + "LabelCollections": "Samlinger", + "LabelComplete": "Fuldfør", + "LabelConfirmPassword": "Bekræft Adgangskode", + "LabelContinueListening": "Fortsæt Lytning", + "LabelContinueReading": "Fortsæt Læsning", + "LabelContinueSeries": "Fortsæt Serie", + "LabelCover": "Omslag", + "LabelCoverImageURL": "Omslagsbillede URL", + "LabelCreatedAt": "Oprettet Kl.", + "LabelCronExpression": "Cron Udtryk", + "LabelCurrent": "Aktuel", + "LabelCurrently": "Aktuelt:", + "LabelCustomCronExpression": "Brugerdefineret Cron Udtryk:", + "LabelDatetime": "Dato og Tid", + "LabelDescription": "Beskrivelse", + "LabelDeselectAll": "Fravælg Alle", + "LabelDevice": "Enheds", + "LabelDeviceInfo": "Enhedsinformation", + "LabelDirectory": "Mappe", + "LabelDiscFromFilename": "Disk fra Filnavn", + "LabelDiscFromMetadata": "Disk fra Metadata", + "LabelDiscover": "Opdag", + "LabelDownload": "Download", + "LabelDownloadNEpisodes": "Download {0} episoder", + "LabelDuration": "Varighed", + "LabelDurationFound": "Fundet varighed:", + "LabelEbook": "E-bog", + "LabelEbooks": "E-bøger", + "LabelEdit": "Rediger", + "LabelEmail": "Email", + "LabelEmailSettingsFromAddress": "Fra Adresse", + "LabelEmailSettingsSecure": "Sikker", + "LabelEmailSettingsSecureHelp": "Hvis sandt, vil forbindelsen bruge TLS ved tilslutning til serveren. Hvis falsk, bruges TLS, hvis serveren understøtter STARTTLS-udvidelsen. I de fleste tilfælde skal denne værdi sættes til sandt, hvis du tilslutter til port 465. Til port 587 eller 25 skal du holde det falsk. (fra nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "Test Adresse", + "LabelEmbeddedCover": "Indlejret Omslag", + "LabelEnable": "Aktivér", + "LabelEnd": "Slut", + "LabelEpisode": "Episode", + "LabelEpisodeTitle": "Episodetitel", + "LabelEpisodeType": "Episodetype", + "LabelExample": "Eksempel", + "LabelExplicit": "Eksplisit", + "LabelFeedURL": "Feed URL", + "LabelFile": "Fil", + "LabelFileBirthtime": "Fødselstidspunkt for fil", + "LabelFileModified": "Fil ændret", + "LabelFilename": "Filnavn", + "LabelFilterByUser": "Filtrér efter bruger", + "LabelFindEpisodes": "Find episoder", + "LabelFinished": "Færdig", + "LabelFolder": "Mappe", + "LabelFolders": "Mapper", + "LabelFontScale": "Skriftstørrelse", + "LabelFormat": "Format", + "LabelGenre": "Genre", + "LabelGenres": "Genrer", + "LabelHardDeleteFile": "Permanent slet fil", + "LabelHasEbook": "Har e-bog", + "LabelHasSupplementaryEbook": "Har supplerende e-bog", + "LabelHost": "Vært", + "LabelHour": "Time", + "LabelIcon": "Ikon", + "LabelImageURLFromTheWeb": "Image URL from the web", + "LabelIncludeInTracklist": "Inkluder i afspilningsliste", + "LabelIncomplete": "Ufuldstændig", + "LabelInProgress": "I gang", + "LabelInterval": "Interval", + "LabelIntervalCustomDailyWeekly": "Tilpasset dagligt/ugentligt", + "LabelIntervalEvery12Hours": "Hver 12. time", + "LabelIntervalEvery15Minutes": "Hver 15. minut", + "LabelIntervalEvery2Hours": "Hver 2. time", + "LabelIntervalEvery30Minutes": "Hver 30. minut", + "LabelIntervalEvery6Hours": "Hver 6. time", + "LabelIntervalEveryDay": "Hver dag", + "LabelIntervalEveryHour": "Hver time", + "LabelInvalidParts": "Ugyldige dele", + "LabelInvert": "Inverter", + "LabelItem": "Element", + "LabelLanguage": "Sprog", + "LabelLanguageDefaultServer": "Standard server sprog", + "LabelLastBookAdded": "Senest tilføjede bog", + "LabelLastBookUpdated": "Senest opdaterede bog", + "LabelLastSeen": "Sidst set", + "LabelLastTime": "Sidste gang", + "LabelLastUpdate": "Seneste opdatering", + "LabelLayout": "Layout", + "LabelLayoutSinglePage": "Enkeltside", + "LabelLayoutSplitPage": "Opdelt side", + "LabelLess": "Mindre", + "LabelLibrariesAccessibleToUser": "Biblioteker tilgængelige for bruger", + "LabelLibrary": "Bibliotek", + "LabelLibraryItem": "Bibliotekselement", + "LabelLibraryName": "Biblioteksnavn", + "LabelLimit": "Grænse", + "LabelLineSpacing": "Linjeafstand", + "LabelListenAgain": "Lyt igen", + "LabelLogLevelDebug": "Fejlsøgning", + "LabelLogLevelInfo": "Information", + "LabelLogLevelWarn": "Advarsel", + "LabelLookForNewEpisodesAfterDate": "Søg efter nye episoder efter denne dato", + "LabelMediaPlayer": "Medieafspiller", + "LabelMediaType": "Medietype", + "LabelMetadataProvider": "Metadataudbyder", + "LabelMetaTag": "Meta-tag", + "LabelMetaTags": "Meta-tags", + "LabelMinute": "Minut", + "LabelMissing": "Mangler", + "LabelMissingParts": "Manglende dele", + "LabelMore": "Mere", + "LabelMoreInfo": "Mere info", + "LabelName": "Navn", + "LabelNarrator": "Fortæller", + "LabelNarrators": "Fortællere", + "LabelNew": "Ny", + "LabelNewestAuthors": "Nyeste forfattere", + "LabelNewestEpisodes": "Nyeste episoder", + "LabelNewPassword": "Nyt kodeord", + "LabelNextBackupDate": "Næste sikkerhedskopi dato", + "LabelNextScheduledRun": "Næste planlagte kørsel", + "LabelNoEpisodesSelected": "Ingen episoder valgt", + "LabelNotes": "Noter", + "LabelNotFinished": "Ikke færdig", + "LabelNotificationAppriseURL": "Apprise URL'er", + "LabelNotificationAvailableVariables": "Tilgængelige variabler", + "LabelNotificationBodyTemplate": "Kropsskabelon", + "LabelNotificationEvent": "Meddelelseshændelse", + "LabelNotificationsMaxFailedAttempts": "Maksimalt antal mislykkede forsøg", + "LabelNotificationsMaxFailedAttemptsHelp": "Meddelelser deaktiveres, når de mislykkes med at sende så mange gange", + "LabelNotificationsMaxQueueSize": "Maksimal køstørrelse for meddelelseshændelser", + "LabelNotificationsMaxQueueSizeHelp": "Hændelser begrænses til at udløse en gang pr. sekund. Hændelser ignoreres, hvis køen er fyldt. Dette forhindrer meddelelsesspam.", + "LabelNotificationTitleTemplate": "Titelskabelon", + "LabelNotStarted": "Ikke påbegyndt", + "LabelNumberOfBooks": "Antal bøger", + "LabelNumberOfEpisodes": "Antal episoder", + "LabelOpenRSSFeed": "Åbn RSS-feed", + "LabelOverwrite": "Overskriv", + "LabelPassword": "Kodeord", + "LabelPath": "Sti", + "LabelPermissionsAccessAllLibraries": "Kan få adgang til alle biblioteker", + "LabelPermissionsAccessAllTags": "Kan få adgang til alle tags", + "LabelPermissionsAccessExplicitContent": "Kan få adgang til eksplicit indhold", + "LabelPermissionsDelete": "Kan slette", + "LabelPermissionsDownload": "Kan downloade", + "LabelPermissionsUpdate": "Kan opdatere", + "LabelPermissionsUpload": "Kan uploade", + "LabelPhotoPathURL": "Foto sti/URL", + "LabelPlaylists": "Afspilningslister", + "LabelPlayMethod": "Afspilningsmetode", + "LabelPodcast": "Podcast", + "LabelPodcasts": "Podcasts", + "LabelPodcastType": "Podcast type", + "LabelPort": "Port", + "LabelPrefixesToIgnore": "Præfikser der skal ignoreres (skal ikke skelne mellem store og små bogstaver)", + "LabelPreventIndexing": "Forhindrer, at dit feed bliver indekseret af iTunes og Google podcastkataloger", + "LabelPrimaryEbook": "Primær e-bog", + "LabelProgress": "Fremskridt", + "LabelProvider": "Udbyder", + "LabelPubDate": "Udgivelsesdato", + "LabelPublisher": "Forlag", + "LabelPublishYear": "Udgivelsesår", + "LabelRead": "Læst", + "LabelReadAgain": "Læs igen", + "LabelReadEbookWithoutProgress": "Læs e-bog uden at følge fremskridt", + "LabelRecentlyAdded": "Senest tilføjet", + "LabelRecentSeries": "Seneste serie", + "LabelRecommended": "Anbefalet", + "LabelRegion": "Region", + "LabelReleaseDate": "Udgivelsesdato", + "LabelRemoveCover": "Fjern omslag", + "LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail", + "LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn", + "LabelRSSFeedOpen": "Åben RSS-feed", + "LabelRSSFeedPreventIndexing": "Forhindrer indeksering", + "LabelRSSFeedSlug": "RSS-feed-slug", + "LabelRSSFeedURL": "RSS-feed-URL", + "LabelSearchTerm": "Søgeterm", + "LabelSearchTitle": "Søg efter titel", + "LabelSearchTitleOrASIN": "Søg efter titel eller ASIN", + "LabelSeason": "Sæson", + "LabelSelectAllEpisodes": "Vælg alle episoder", + "LabelSelectEpisodesShowing": "Vælg {0} episoder vist", + "LabelSendEbookToDevice": "Send e-bog til...", + "LabelSequence": "Sekvens", + "LabelSeries": "Serie", + "LabelSeriesName": "Serienavn", + "LabelSeriesProgress": "Seriefremskridt", + "LabelSetEbookAsPrimary": "Indstil som primær", + "LabelSetEbookAsSupplementary": "Indstil som supplerende", + "LabelSettingsAudiobooksOnly": "Kun lydbøger", + "LabelSettingsAudiobooksOnlyHelp": "Aktivering af denne indstilling vil ignorere e-bogsfiler, medmindre de er inde i en lydbogmappe, hvor de vil blive indstillet som supplerende e-bøger", + "LabelSettingsBookshelfViewHelp": "Skeumorfisk design med træhylder", + "LabelSettingsChromecastSupport": "Chromecast-understøttelse", + "LabelSettingsDateFormat": "Datoformat", + "LabelSettingsDisableWatcher": "Deaktiver overvågning", + "LabelSettingsDisableWatcherForLibrary": "Deaktiver mappeovervågning for bibliotek", + "LabelSettingsDisableWatcherHelp": "Deaktiverer automatisk tilføjelse/opdatering af elementer, når der registreres filændringer. *Kræver servergenstart", + "LabelSettingsEnableWatcher": "Aktiver overvågning", + "LabelSettingsEnableWatcherForLibrary": "Aktiver mappeovervågning for bibliotek", + "LabelSettingsEnableWatcherHelp": "Aktiverer automatisk tilføjelse/opdatering af elementer, når filændringer registreres. *Kræver servergenstart", + "LabelSettingsExperimentalFeatures": "Eksperimentelle funktioner", + "LabelSettingsExperimentalFeaturesHelp": "Funktioner under udvikling, der kunne bruge din feedback og hjælp til test. Klik for at åbne Github-diskussionen.", + "LabelSettingsFindCovers": "Find omslag", + "LabelSettingsFindCoversHelp": "Hvis din lydbog ikke har et indlejret omslag eller et omslagsbillede i mappen, vil skanneren forsøge at finde et omslag.<br>Bemærk: Dette vil forlænge scanntiden", + "LabelSettingsHideSingleBookSeries": "Skjul enkeltbogsserier", + "LabelSettingsHideSingleBookSeriesHelp": "Serier med en enkelt bog vil blive skjult fra serie-siden og hjemmesidehylder.", + "LabelSettingsHomePageBookshelfView": "Brug bogreolvisning på startside", + "LabelSettingsLibraryBookshelfView": "Brug bogreolvisning i biblioteket", + "LabelSettingsParseSubtitles": "Fortolk undertekster", + "LabelSettingsParseSubtitlesHelp": "Udtræk undertekster fra lydbogsmappenavne.<br>Undertitler skal adskilles af \" - \"<br>f.eks. \"Bogtitel - En undertitel her\" har undertitlen \"En undertitel her\"", + "LabelSettingsPreferMatchedMetadata": "Foretræk matchede metadata", + "LabelSettingsPreferMatchedMetadataHelp": "Matchede data vil tilsidesætte elementdetaljer ved brug af Hurtig Match. Som standard udfylder Hurtig Match kun manglende detaljer.", + "LabelSettingsSkipMatchingBooksWithASIN": "Spring over matchende bøger, der allerede har en ASIN", + "LabelSettingsSkipMatchingBooksWithISBN": "Spring over matchende bøger, der allerede har en ISBN", + "LabelSettingsSortingIgnorePrefixes": "Ignorer præfikser ved sortering", + "LabelSettingsSortingIgnorePrefixesHelp": "f.eks. for præfikset \"the\" vil bogtitlen \"The Book Title\" blive sorteret som \"Book Title, The\"", + "LabelSettingsSquareBookCovers": "Brug kvadratiske bogomslag", + "LabelSettingsSquareBookCoversHelp": "Foretræk at bruge kvadratiske omslag frem for standard 1,6:1 bogomslag", + "LabelSettingsStoreCoversWithItem": "Gem omslag med element", + "LabelSettingsStoreCoversWithItemHelp": "Som standard gemmes omslag i /metadata/items, aktivering af denne indstilling vil gemme omslag i mappen for dit bibliotekselement. Kun én fil med navnet \"cover\" vil blive bevaret", + "LabelSettingsStoreMetadataWithItem": "Gem metadata med element", + "LabelSettingsStoreMetadataWithItemHelp": "Som standard gemmes metadatafiler i /metadata/items, aktivering af denne indstilling vil gemme metadatafiler i dine bibliotekselementmapper. Bruger .abs-filudvidelsen", + "LabelSettingsTimeFormat": "Tidsformat", + "LabelShowAll": "Vis alle", + "LabelSize": "Størrelse", + "LabelSleepTimer": "Søvntimer", + "LabelSlug": "Slug", + "LabelStart": "Start", + "LabelStarted": "Startet", + "LabelStartedAt": "Startet klokken", + "LabelStartTime": "Starttid", + "LabelStatsAudioTracks": "Lydspor", + "LabelStatsAuthors": "Forfattere", + "LabelStatsBestDay": "Bedste dag", + "LabelStatsDailyAverage": "Daglig gennemsnit", + "LabelStatsDays": "Dage", + "LabelStatsDaysListened": "Dage hørt", + "LabelStatsHours": "Timer", + "LabelStatsInARow": "i træk", + "LabelStatsItemsFinished": "Elementer færdige", + "LabelStatsItemsInLibrary": "Elementer i biblioteket", + "LabelStatsMinutes": "minutter", + "LabelStatsMinutesListening": "Minutter hørt", + "LabelStatsOverallDays": "Samlede dage", + "LabelStatsOverallHours": "Samlede timer", + "LabelStatsWeekListening": "Ugens lytning", + "LabelSubtitle": "Undertekst", + "LabelSupportedFileTypes": "Understøttede filtyper", + "LabelTag": "Mærke", + "LabelTags": "Mærker", + "LabelTagsAccessibleToUser": "Mærker tilgængelige for bruger", + "LabelTagsNotAccessibleToUser": "Mærker ikke tilgængelige for bruger", + "LabelTasks": "Kører opgaver", + "LabelTheme": "Tema", + "LabelThemeDark": "Mørk", + "LabelThemeLight": "Lys", + "LabelTimeBase": "Tidsbase", + "LabelTimeListened": "Tid hørt", + "LabelTimeListenedToday": "Tid hørt i dag", + "LabelTimeRemaining": "{0} tilbage", + "LabelTimeToShift": "Tid til skift i sekunder", + "LabelTitle": "Titel", + "LabelToolsEmbedMetadata": "Indlejre metadata", + "LabelToolsEmbedMetadataDescription": "Indlejr metadata i lydfiler, inklusive omslag og kapitler.", + "LabelToolsMakeM4b": "Lav M4B lydbogsfil", + "LabelToolsMakeM4bDescription": "Generer en .M4B lydbogsfil med indlejret metadata, omslag og kapitler.", + "LabelToolsSplitM4b": "Opdel M4B til MP3'er", + "LabelToolsSplitM4bDescription": "Opret MP3'er fra en M4B opdelt efter kapitler med indlejret metadata, omslag og kapitler.", + "LabelTotalDuration": "Samlet varighed", + "LabelTotalTimeListened": "Samlet lyttetid", + "LabelTrackFromFilename": "Spor fra filnavn", + "LabelTrackFromMetadata": "Spor fra metadata", + "LabelTracks": "Spor", + "LabelTracksMultiTrack": "Flerspors", + "LabelTracksNone": "Ingen spor", + "LabelTracksSingleTrack": "Enkeltspors", + "LabelType": "Type", + "LabelUnabridged": "Uforkortet", + "LabelUnknown": "Ukendt", + "LabelUpdateCover": "Opdater omslag", + "LabelUpdateCoverHelp": "Tillad overskrivning af eksisterende omslag for de valgte bøger, når der findes en match", + "LabelUpdatedAt": "Opdateret ved", + "LabelUpdateDetails": "Opdater detaljer", + "LabelUpdateDetailsHelp": "Tillad overskrivning af eksisterende detaljer for de valgte bøger, når der findes en match", + "LabelUploaderDragAndDrop": "Træk og slip filer eller mapper", + "LabelUploaderDropFiles": "Smid filer", + "LabelUseChapterTrack": "Brug kapitel-spor", + "LabelUseFullTrack": "Brug fuldt spor", + "LabelUser": "Bruger", + "LabelUsername": "Brugernavn", + "LabelValue": "Værdi", + "LabelVersion": "Version", + "LabelViewBookmarks": "Se bogmærker", + "LabelViewChapters": "Se kapitler", + "LabelViewQueue": "Se afspilningskø", + "LabelVolume": "Volumen", + "LabelWeekdaysToRun": "Ugedage til kørsel", + "LabelYourAudiobookDuration": "Din lydbogsvarighed", + "LabelYourBookmarks": "Dine bogmærker", + "LabelYourPlaylists": "Dine spillelister", + "LabelYourProgress": "Din fremgang", + "MessageAddToPlayerQueue": "Tilføj til afspilningskø", + "MessageAppriseDescription": "For at bruge denne funktion skal du have en instans af <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kørende eller en API, der håndterer de samme anmodninger. <br /> Apprise API-webadressen skal være den fulde URL-sti for at sende underretningen, f.eks. hvis din API-instans er tilgængelig på <code>http://192.168.1.1:8337</code>, så skal du bruge <code>http://192.168.1.1:8337/notify</code>.", + "MessageBackupsDescription": "Backups inkluderer brugere, brugerfremskridt, biblioteksvareoplysninger, serverindstillinger og billeder gemt i <code>/metadata/items</code> og <code>/metadata/authors</code>. Backups inkluderer <strong>ikke</strong> nogen filer gemt i dine biblioteksmapper.", + "MessageBatchQuickMatchDescription": "Quick Match vil forsøge at tilføje manglende omslag og metadata til de valgte elementer. Aktivér indstillingerne nedenfor for at tillade Quick Match at overskrive eksisterende omslag og/eller metadata.", + "MessageBookshelfNoCollections": "Du har ikke oprettet nogen samlinger endnu", + "MessageBookshelfNoResultsForFilter": "Ingen resultater for filter \"{0}: {1}\"", + "MessageBookshelfNoRSSFeeds": "Ingen RSS-feeds er åbne", + "MessageBookshelfNoSeries": "Du har ingen serier", + "MessageChapterEndIsAfter": "Kapitelslutningen er efter slutningen af din lydbog", + "MessageChapterErrorFirstNotZero": "Første kapitel skal starte ved 0", + "MessageChapterErrorStartGteDuration": "Ugyldig starttid skal være mindre end lydbogens varighed", + "MessageChapterErrorStartLtPrev": "Ugyldig starttid skal være større end eller lig med den foregående kapitels starttid", + "MessageChapterStartIsAfter": "Kapitelstarten er efter slutningen af din lydbog", + "MessageCheckingCron": "Tjekker cron...", + "MessageConfirmCloseFeed": "Er du sikker på, at du vil lukke dette feed?", + "MessageConfirmDeleteBackup": "Er du sikker på, at du vil slette backup for {0}?", + "MessageConfirmDeleteFile": "Dette vil slette filen fra dit filsystem. Er du sikker?", + "MessageConfirmDeleteLibrary": "Er du sikker på, at du vil slette biblioteket permanent \"{0}\"?", + "MessageConfirmDeleteSession": "Er du sikker på, at du vil slette denne session?", + "MessageConfirmForceReScan": "Er du sikker på, at du vil tvinge en genindlæsning?", + "MessageConfirmMarkAllEpisodesFinished": "Er du sikker på, at du vil markere alle episoder som afsluttet?", + "MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på, at du vil markere alle episoder som ikke afsluttet?", + "MessageConfirmMarkSeriesFinished": "Er du sikker på, at du vil markere alle bøger i denne serie som afsluttet?", + "MessageConfirmMarkSeriesNotFinished": "Er du sikker på, at du vil markere alle bøger i denne serie som ikke afsluttet?", + "MessageConfirmRemoveAllChapters": "Er du sikker på, at du vil fjerne alle kapitler?", + "MessageConfirmRemoveAuthor": "Er du sikker på, at du vil fjerne forfatteren \"{0}\"?", + "MessageConfirmRemoveCollection": "Er du sikker på, at du vil fjerne samlingen \"{0}\"?", + "MessageConfirmRemoveEpisode": "Er du sikker på, at du vil fjerne episoden \"{0}\"?", + "MessageConfirmRemoveEpisodes": "Er du sikker på, at du vil fjerne {0} episoder?", + "MessageConfirmRemoveNarrator": "Er du sikker på, at du vil fjerne fortælleren \"{0}\"?", + "MessageConfirmRemovePlaylist": "Er du sikker på, at du vil fjerne din spilleliste \"{0}\"?", + "MessageConfirmRenameGenre": "Er du sikker på, at du vil omdøbe genre \"{0}\" til \"{1}\" for alle elementer?", + "MessageConfirmRenameGenreMergeNote": "Bemærk: Denne genre findes allerede, så de vil blive fusioneret.", + "MessageConfirmRenameGenreWarning": "Advarsel! En lignende genre med en anden skrivemåde eksisterer allerede \"{0}\".", + "MessageConfirmRenameTag": "Er du sikker på, at du vil omdøbe tag \"{0}\" til \"{1}\" for alle elementer?", + "MessageConfirmRenameTagMergeNote": "Bemærk: Dette tag findes allerede, så de vil blive fusioneret.", + "MessageConfirmRenameTagWarning": "Advarsel! Et lignende tag med en anden skrivemåde eksisterer allerede \"{0}\".", + "MessageConfirmSendEbookToDevice": "Er du sikker på, at du vil sende {0} e-bog \"{1}\" til enhed \"{2}\"?", + "MessageDownloadingEpisode": "Downloader episode", + "MessageDragFilesIntoTrackOrder": "Træk filer ind i korrekt spororden", + "MessageEmbedFinished": "Indlejring færdig!", + "MessageEpisodesQueuedForDownload": "{0} episoder er sat i kø til download", + "MessageFeedURLWillBe": "Feed-URL vil være {0}", + "MessageFetching": "Henter...", + "MessageForceReScanDescription": "vil scanne alle filer igen som en frisk scanning. Lydfilens ID3-tags, OPF-filer og tekstfiler scannes som nye.", + "MessageImportantNotice": "Vigtig besked!", + "MessageInsertChapterBelow": "Indsæt kapitel nedenfor", + "MessageItemsSelected": "{0} elementer valgt", + "MessageItemsUpdated": "{0} elementer opdateret", + "MessageJoinUsOn": "Deltag i os på", + "MessageListeningSessionsInTheLastYear": "{0} lyttesessioner i det sidste år", + "MessageLoading": "Indlæser...", + "MessageLoadingFolders": "Indlæser mapper...", + "MessageM4BFailed": "M4B mislykkedes!", + "MessageM4BFinished": "M4B afsluttet!", + "MessageMapChapterTitles": "Tilknyt kapiteloverskrifter til dine eksisterende lydbogskapitler uden at justere tidsstempler", + "MessageMarkAllEpisodesFinished": "Markér alle episoder som afsluttet", + "MessageMarkAllEpisodesNotFinished": "Markér alle episoder som ikke afsluttet", + "MessageMarkAsFinished": "Markér som afsluttet", + "MessageMarkAsNotFinished": "Markér som ikke afsluttet", + "MessageMatchBooksDescription": "vil forsøge at matche bøger i biblioteket med en bog fra den valgte søgeudbyder og udfylde tomme detaljer og omslag. Overskriver ikke detaljer.", + "MessageNoAudioTracks": "Ingen lydspor", + "MessageNoAuthors": "Ingen forfattere", + "MessageNoBackups": "Ingen sikkerhedskopier", + "MessageNoBookmarks": "Ingen bogmærker", + "MessageNoChapters": "Ingen kapitler", + "MessageNoCollections": "Ingen samlinger", + "MessageNoCoversFound": "Ingen omslag fundet", + "MessageNoDescription": "Ingen beskrivelse", + "MessageNoDownloadsInProgress": "Ingen downloads i gang lige nu", + "MessageNoDownloadsQueued": "Ingen downloads i kø", + "MessageNoEpisodeMatchesFound": "Ingen episode-matcher fundet", + "MessageNoEpisodes": "Ingen episoder", + "MessageNoFoldersAvailable": "Ingen mapper tilgængelige", + "MessageNoGenres": "Ingen genrer", + "MessageNoIssues": "Ingen problemer", + "MessageNoItems": "Ingen elementer", + "MessageNoItemsFound": "Ingen elementer fundet", + "MessageNoListeningSessions": "Ingen lyttesessioner", + "MessageNoLogs": "Ingen logfiler", + "MessageNoMediaProgress": "Ingen medieforløb", + "MessageNoNotifications": "Ingen meddelelser", + "MessageNoPodcastsFound": "Ingen podcasts fundet", + "MessageNoResults": "Ingen resultater", + "MessageNoSearchResultsFor": "Ingen søgeresultater for \"{0}\"", + "MessageNoSeries": "Ingen serier", + "MessageNoTags": "Ingen tags", + "MessageNoTasksRunning": "Ingen opgaver kører", + "MessageNotYetImplemented": "Endnu ikke implementeret", + "MessageNoUpdateNecessary": "Ingen opdatering nødvendig", + "MessageNoUpdatesWereNecessary": "Ingen opdateringer var nødvendige", + "MessageNoUserPlaylists": "Du har ingen afspilningslister", + "MessageOr": "eller", + "MessagePauseChapter": "Pause kapitelafspilning", + "MessagePlayChapter": "Lyt til begyndelsen af kapitlet", + "MessagePlaylistCreateFromCollection": "Opret afspilningsliste fra samling", + "MessagePodcastHasNoRSSFeedForMatching": "Podcast har ingen RSS-feed-URL at bruge til matchning", + "MessageQuickMatchDescription": "Udfyld tomme elementoplysninger og omslag med første matchresultat fra '{0}'. Overskriver ikke oplysninger, medmindre serverindstillingen 'Foretræk matchet metadata' er aktiveret.", + "MessageRemoveChapter": "Fjern kapitel", + "MessageRemoveEpisodes": "Fjern {0} episode(r)", + "MessageRemoveFromPlayerQueue": "Fjern fra afspillingskøen", + "MessageRemoveUserWarning": "Er du sikker på, at du vil slette brugeren permanent \"{0}\"?", + "MessageReportBugsAndContribute": "Rapporter fejl, anmod om funktioner og bidrag på", + "MessageResetChaptersConfirm": "Er du sikker på, at du vil nulstille kapitler og annullere ændringerne, du har foretaget?", + "MessageRestoreBackupConfirm": "Er du sikker på, at du vil gendanne sikkerhedskopien oprettet den", + "MessageRestoreBackupWarning": "Gendannelse af en sikkerhedskopi vil overskrive hele databasen, som er placeret på /config, og omslagsbilleder i /metadata/items & /metadata/authors.<br /><br />Sikkerhedskopier ændrer ikke nogen filer i dine biblioteksmapper. Hvis du har aktiveret serverindstillinger for at gemme omslagskunst og metadata i dine biblioteksmapper, sikkerhedskopieres eller overskrives disse ikke.<br /><br />Alle klienter, der bruger din server, opdateres automatisk.", + "MessageSearchResultsFor": "Søgeresultater for", + "MessageServerCouldNotBeReached": "Serveren kunne ikke nås", + "MessageSetChaptersFromTracksDescription": "Indstil kapitler ved at bruge hver lydfil som et kapitel og kapiteloverskrift som lydfilnavn", + "MessageStartPlaybackAtTime": "Start afspilning for \"{0}\" kl. {1}?", + "MessageThinking": "Tænker...", + "MessageUploaderItemFailed": "Fejl ved upload", + "MessageUploaderItemSuccess": "Uploadet med succes!", + "MessageUploading": "Uploader...", + "MessageValidCronExpression": "Gyldigt cron-udtryk", + "MessageWatcherIsDisabledGlobally": "Watcher er deaktiveret globalt i serverindstillinger", + "MessageXLibraryIsEmpty": "{0} bibliotek er tomt!", + "MessageYourAudiobookDurationIsLonger": "Din lydbogsvarighed er længere end den fundne varighed", + "MessageYourAudiobookDurationIsShorter": "Din lydbogsvarighed er kortere end den fundne varighed", + "NoteChangeRootPassword": "Root-brugeren er den eneste bruger, der kan have en tom adgangskode", + "NoteChapterEditorTimes": "Bemærk: Første kapitel starttidspunkt skal forblive kl. 0:00, og det sidste kapitel starttidspunkt må ikke overstige denne lydbogs varighed.", + "NoteFolderPicker": "Bemærk: Mapper, der allerede er mappet, vises ikke", + "NoteFolderPickerDebian": "Bemærk: Mappicker for Debian-installationen er ikke fuldt implementeret. Du bør indtaste stien til dit bibliotek direkte.", + "NoteRSSFeedPodcastAppsHttps": "Advarsel: De fleste podcast-apps kræver, at RSS-feedets URL bruger HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Advarsel: En eller flere af dine episoder har ikke en Pub Date. Nogle podcast-apps kræver dette.", + "NoteUploaderFoldersWithMediaFiles": "Mapper med mediefiler håndteres som separate bibliotekselementer.", + "NoteUploaderOnlyAudioFiles": "Hvis du kun uploader lydfiler, håndteres hver lydfil som en separat lydbog.", + "NoteUploaderUnsupportedFiles": "Ikke-understøttede filer ignoreres. Når du vælger eller slipper en mappe, ignoreres andre filer, der ikke er i en emnemappe.", + "PlaceholderNewCollection": "Nyt samlingnavn", + "PlaceholderNewFolderPath": "Ny mappes sti", + "PlaceholderNewPlaylist": "Nyt afspilningslistnavn", + "PlaceholderSearch": "Søg..", + "PlaceholderSearchEpisode": "Søg efter episode..", + "ToastAccountUpdateFailed": "Mislykkedes opdatering af konto", + "ToastAccountUpdateSuccess": "Konto opdateret", + "ToastAuthorImageRemoveFailed": "Mislykkedes fjernelse af forfatterbillede", + "ToastAuthorImageRemoveSuccess": "Forfatterbillede fjernet", + "ToastAuthorUpdateFailed": "Mislykkedes opdatering af forfatter", + "ToastAuthorUpdateMerged": "Forfatter fusioneret", + "ToastAuthorUpdateSuccess": "Forfatter opdateret", + "ToastAuthorUpdateSuccessNoImageFound": "Forfatter opdateret (ingen billede fundet)", + "ToastBackupCreateFailed": "Mislykkedes oprettelse af sikkerhedskopi", + "ToastBackupCreateSuccess": "Sikkerhedskopi oprettet", + "ToastBackupDeleteFailed": "Mislykkedes sletning af sikkerhedskopi", + "ToastBackupDeleteSuccess": "Sikkerhedskopi slettet", + "ToastBackupRestoreFailed": "Mislykkedes gendannelse af sikkerhedskopi", + "ToastBackupUploadFailed": "Mislykkedes upload af sikkerhedskopi", + "ToastBackupUploadSuccess": "Sikkerhedskopi uploadet", + "ToastBatchUpdateFailed": "Mislykkedes batchopdatering", + "ToastBatchUpdateSuccess": "Batchopdatering lykkedes", + "ToastBookmarkCreateFailed": "Mislykkedes oprettelse af bogmærke", + "ToastBookmarkCreateSuccess": "Bogmærke tilføjet", + "ToastBookmarkRemoveFailed": "Mislykkedes fjernelse af bogmærke", + "ToastBookmarkRemoveSuccess": "Bogmærke fjernet", + "ToastBookmarkUpdateFailed": "Mislykkedes opdatering af bogmærke", + "ToastBookmarkUpdateSuccess": "Bogmærke opdateret", + "ToastChaptersHaveErrors": "Kapitler har fejl", + "ToastChaptersMustHaveTitles": "Kapitler skal have titler", + "ToastCollectionItemsRemoveFailed": "Mislykkedes fjernelse af element(er) fra samlingen", + "ToastCollectionItemsRemoveSuccess": "Element(er) fjernet fra samlingen", + "ToastCollectionRemoveFailed": "Mislykkedes fjernelse af samling", + "ToastCollectionRemoveSuccess": "Samling fjernet", + "ToastCollectionUpdateFailed": "Mislykkedes opdatering af samling", + "ToastCollectionUpdateSuccess": "Samling opdateret", + "ToastItemCoverUpdateFailed": "Mislykkedes opdatering af varens omslag", + "ToastItemCoverUpdateSuccess": "Varens omslag opdateret", + "ToastItemDetailsUpdateFailed": "Mislykkedes opdatering af varedetaljer", + "ToastItemDetailsUpdateSuccess": "Varedetaljer opdateret", + "ToastItemDetailsUpdateUnneeded": "Ingen opdateringer er nødvendige for varedetaljer", + "ToastItemMarkedAsFinishedFailed": "Mislykkedes markering som afsluttet", + "ToastItemMarkedAsFinishedSuccess": "Vare markeret som afsluttet", + "ToastItemMarkedAsNotFinishedFailed": "Mislykkedes markering som ikke afsluttet", + "ToastItemMarkedAsNotFinishedSuccess": "Vare markeret som ikke afsluttet", + "ToastLibraryCreateFailed": "Mislykkedes oprettelse af bibliotek", + "ToastLibraryCreateSuccess": "Bibliotek \"{0}\" oprettet", + "ToastLibraryDeleteFailed": "Mislykkedes sletning af bibliotek", + "ToastLibraryDeleteSuccess": "Bibliotek slettet", + "ToastLibraryScanFailedToStart": "Mislykkedes start af skanning", + "ToastLibraryScanStarted": "Biblioteksskanning startet", + "ToastLibraryUpdateFailed": "Mislykkedes opdatering af bibliotek", + "ToastLibraryUpdateSuccess": "Bibliotek \"{0}\" opdateret", + "ToastPlaylistCreateFailed": "Mislykkedes oprettelse af afspilningsliste", + "ToastPlaylistCreateSuccess": "Afspilningsliste oprettet", + "ToastPlaylistRemoveFailed": "Mislykkedes fjernelse af afspilningsliste", + "ToastPlaylistRemoveSuccess": "Afspilningsliste fjernet", + "ToastPlaylistUpdateFailed": "Mislykkedes opdatering af afspilningsliste", + "ToastPlaylistUpdateSuccess": "Afspilningsliste opdateret", + "ToastPodcastCreateFailed": "Mislykkedes oprettelse af podcast", + "ToastPodcastCreateSuccess": "Podcast oprettet med succes", + "ToastRemoveItemFromCollectionFailed": "Mislykkedes fjernelse af element fra samling", + "ToastRemoveItemFromCollectionSuccess": "Element fjernet fra samling", + "ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed", + "ToastRSSFeedCloseSuccess": "RSS-feed lukket", + "ToastSendEbookToDeviceFailed": "Mislykkedes afsendelse af e-bog til enhed", + "ToastSendEbookToDeviceSuccess": "E-bog afsendt til enhed \"{0}\"", + "ToastSeriesUpdateFailed": "Mislykkedes opdatering af serie", + "ToastSeriesUpdateSuccess": "Serieopdatering lykkedes", + "ToastSessionDeleteFailed": "Mislykkedes sletning af session", + "ToastSessionDeleteSuccess": "Session slettet", + "ToastSocketConnected": "Socket forbundet", + "ToastSocketDisconnected": "Socket afbrudt", + "ToastSocketFailedToConnect": "Socket kunne ikke oprettes", + "ToastUserDeleteFailed": "Mislykkedes sletning af bruger", + "ToastUserDeleteSuccess": "Bruger slettet" +} \ No newline at end of file From c2643329945e1d6b853a22ac56e3ce3cbe5e16fb Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 15 Oct 2023 12:55:22 -0500 Subject: [PATCH 39/84] Fix:Scanner detecting library item folder renames #1161 --- server/scanner/LibraryItemScanner.js | 7 ++++--- server/scanner/LibraryScanner.js | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/server/scanner/LibraryItemScanner.js b/server/scanner/LibraryItemScanner.js index e9ca3302..588b7744 100644 --- a/server/scanner/LibraryItemScanner.js +++ b/server/scanner/LibraryItemScanner.js @@ -21,9 +21,10 @@ class LibraryItemScanner { * Scan single library item * * @param {string} libraryItemId + * @param {{relPath:string, path:string}} [renamedPaths] used by watcher when item folder was renamed * @returns {number} ScanResult */ - async scanLibraryItem(libraryItemId) { + async scanLibraryItem(libraryItemId, renamedPaths = null) { // TODO: Add task manager const libraryItem = await Database.libraryItemModel.findByPk(libraryItemId) if (!libraryItem) { @@ -50,9 +51,9 @@ class LibraryItemScanner { const scanLogger = new ScanLogger() scanLogger.verbose = true - scanLogger.setData('libraryItem', libraryItem.relPath) + scanLogger.setData('libraryItem', renamedPaths?.relPath || libraryItem.relPath) - const libraryItemPath = fileUtils.filePathToPOSIX(libraryItem.path) + const libraryItemPath = renamedPaths?.path || fileUtils.filePathToPOSIX(libraryItem.path) const folder = library.libraryFolders[0] const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, false) diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 64977e2d..44ccdd05 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -483,6 +483,7 @@ class LibraryScanner { path: potentialChildDirs }) + let renamedPaths = {} if (!existingLibraryItem) { const dirIno = await fileUtils.getIno(fullPath) existingLibraryItem = await Database.libraryItemModel.findOneOld({ @@ -493,6 +494,8 @@ class LibraryScanner { // Update library item paths for scan existingLibraryItem.path = fullPath existingLibraryItem.relPath = itemDir + renamedPaths.path = fullPath + renamedPaths.relPath = itemDir } } if (existingLibraryItem) { @@ -512,7 +515,7 @@ class LibraryScanner { // Scan library item for updates Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" - scan for updates`) - itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id) + itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, renamedPaths) continue } else if (library.settings.audiobooksOnly && !fileUpdateGroup[itemDir].some?.(scanUtils.checkFilepathIsAudioFile)) { Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`) From 48a590df4a7765b03fde1e4fd78477181ea0913a Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 16 Oct 2023 17:08:50 -0500 Subject: [PATCH 40/84] Fix:Authors page description shows line breaks #2218 --- client/pages/author/_id.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pages/author/_id.vue b/client/pages/author/_id.vue index 61cfa715..c9834d8d 100644 --- a/client/pages/author/_id.vue +++ b/client/pages/author/_id.vue @@ -17,7 +17,7 @@ </div> <p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p> - <p class="text-white max-w-3xl text-sm leading-5">{{ author.description }}</p> + <p class="text-white max-w-3xl text-sm leading-5 whitespace-pre-wrap">{{ author.description }}</p> </div> </div> From 0d5792405f54efa75ad098acf17de0113aad178e Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 16 Oct 2023 17:47:44 -0500 Subject: [PATCH 41/84] Fix:Podcast episodes store RSS feed guid so they can be matched if the RSS feed changes the episode URL #2207 --- .../components/modals/podcast/EpisodeFeed.vue | 38 +++++++++---------- server/controllers/PodcastController.js | 7 ++-- server/managers/PodcastManager.js | 10 ++--- server/models/PodcastEpisode.js | 4 ++ server/objects/entities/PodcastEpisode.js | 5 +++ server/utils/podcastUtils.js | 34 +++++++++++------ 6 files changed, 59 insertions(+), 39 deletions(-) diff --git a/client/components/modals/podcast/EpisodeFeed.vue b/client/components/modals/podcast/EpisodeFeed.vue index 0f75644b..1378dbe5 100644 --- a/client/components/modals/podcast/EpisodeFeed.vue +++ b/client/components/modals/podcast/EpisodeFeed.vue @@ -16,11 +16,11 @@ v-for="(episode, index) in episodesList" :key="index" class="relative" - :class="itemEpisodeMap[episode.cleanUrl] ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'" + :class="getIsEpisodeDownloaded(episode) ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'" @click="toggleSelectEpisode(episode)" > <div class="absolute top-0 left-0 h-full flex items-center p-2"> - <span v-if="itemEpisodeMap[episode.cleanUrl]" class="material-icons text-success text-xl">download_done</span> + <span v-if="getIsEpisodeDownloaded(episode)" class="material-icons text-success text-xl">download_done</span> <ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" /> </div> <div class="px-8 py-2"> @@ -93,7 +93,7 @@ export default { return this.libraryItem.media.metadata.title || 'Unknown' }, allDownloaded() { - return !this.episodesCleaned.some((episode) => !this.itemEpisodeMap[episode.cleanUrl]) + return !this.episodesCleaned.some((episode) => this.getIsEpisodeDownloaded(episode)) }, episodesSelected() { return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key]) @@ -104,18 +104,7 @@ export default { return this.$getString('LabelDownloadNEpisodes', [this.episodesSelected.length]) }, itemEpisodes() { - if (!this.libraryItem) return [] - return this.libraryItem.media.episodes || [] - }, - itemEpisodeMap() { - const map = {} - this.itemEpisodes.forEach((item) => { - if (item.enclosure) { - const cleanUrl = this.getCleanEpisodeUrl(item.enclosure.url) - map[cleanUrl] = true - } - }) - return map + return this.libraryItem?.media.episodes || [] }, episodesList() { return this.episodesCleaned.filter((episode) => { @@ -127,12 +116,23 @@ export default { if (this.episodesList.length === this.episodesCleaned.length) { return this.$strings.LabelSelectAllEpisodes } - const episodesNotDownloaded = this.episodesList.filter((ep) => !this.itemEpisodeMap[ep.cleanUrl]).length + const episodesNotDownloaded = this.episodesList.filter((ep) => !this.getIsEpisodeDownloaded(ep)).length return this.$getString('LabelSelectEpisodesShowing', [episodesNotDownloaded]) } }, methods: { + getIsEpisodeDownloaded(episode) { + return this.itemEpisodes.some((downloadedEpisode) => { + if (episode.guid && downloadedEpisode.guid === episode.guid) return true + if (!downloadedEpisode.enclosure?.url) return false + return this.getCleanEpisodeUrl(downloadedEpisode.enclosure.url) === episode.cleanUrl + }) + }, /** + * UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed. + * Fallback to checking the clean url + * @see https://github.com/advplyr/audiobookshelf/issues/2207 + * * RSS feed episode url is used for matching with existing downloaded episodes. * Some RSS feeds include timestamps in the episode url (e.g. patreon) that can change on requests. * These need to be removed in order to detect the same episode each time the feed is pulled. @@ -169,13 +169,13 @@ export default { }, toggleSelectAll(val) { for (const episode of this.episodesList) { - if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false + if (this.getIsEpisodeDownloaded(episode)) this.selectedEpisodes[episode.cleanUrl] = false else this.$set(this.selectedEpisodes, episode.cleanUrl, val) } }, checkSetIsSelectedAll() { for (const episode of this.episodesList) { - if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) { + if (!this.getIsEpisodeDownloaded(episode) && !this.selectedEpisodes[episode.cleanUrl]) { this.selectAll = false return } @@ -183,7 +183,7 @@ export default { this.selectAll = true }, toggleSelectEpisode(episode) { - if (this.itemEpisodeMap[episode.cleanUrl]) return + if (this.getIsEpisodeDownloaded(episode)) return this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl]) this.checkSetIsSelectedAll() }, diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index c4112db6..22c3cafa 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -184,10 +184,9 @@ class PodcastController { Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user) return res.sendStatus(403) } - var libraryItem = req.libraryItem - - var episodes = req.body - if (!episodes || !episodes.length) { + const libraryItem = req.libraryItem + const episodes = req.body + if (!episodes?.length) { return res.sendStatus(400) } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 5dec2152..b88a38af 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -201,7 +201,7 @@ class PodcastManager { }) // TODO: Should we check for open playback sessions for this episode? // TODO: remove all user progress for this episode - if (oldestEpisode && oldestEpisode.audioFile) { + if (oldestEpisode?.audioFile) { Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`) const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path) if (successfullyDeleted) { @@ -246,7 +246,7 @@ class PodcastManager { Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`) var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload) - Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes ? newEpisodes.length : 'N/A'} episodes found`) + Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`) if (!newEpisodes) { // Failed // Allow up to MaxFailedEpisodeChecks failed attempts before disabling auto download @@ -280,14 +280,14 @@ class PodcastManager { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`) return false } - var feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl) - if (!feed || !feed.episodes) { + const feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl) + if (!feed?.episodes) { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed) return false } // Filter new and not already has - var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url)) + let newEpisodes = feed.episodes.filter(ep => ep.publishedAt > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url)) if (maxNewEpisodes > 0) { newEpisodes = newEpisodes.slice(0, maxNewEpisodes) diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 6416627a..55b2f9d4 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -79,6 +79,7 @@ class PodcastEpisode extends Model { subtitle: this.subtitle, description: this.description, enclosure, + guid: this.extraData?.guid || null, pubDate: this.pubDate, chapters: this.chapters, audioFile: this.audioFile, @@ -98,6 +99,9 @@ class PodcastEpisode extends Model { if (oldEpisode.oldEpisodeId) { extraData.oldEpisodeId = oldEpisode.oldEpisodeId } + if (oldEpisode.guid) { + extraData.guid = oldEpisode.guid + } return { id: oldEpisode.id, index: oldEpisode.index, diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 2b91aeb6..0a8f3349 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -20,6 +20,7 @@ class PodcastEpisode { this.subtitle = null this.description = null this.enclosure = null + this.guid = null this.pubDate = null this.chapters = [] @@ -46,6 +47,7 @@ class PodcastEpisode { this.subtitle = episode.subtitle this.description = episode.description this.enclosure = episode.enclosure ? { ...episode.enclosure } : null + this.guid = episode.guid || null this.pubDate = episode.pubDate this.chapters = episode.chapters?.map(ch => ({ ...ch })) || [] this.audioFile = new AudioFile(episode.audioFile) @@ -70,6 +72,7 @@ class PodcastEpisode { subtitle: this.subtitle, description: this.description, enclosure: this.enclosure ? { ...this.enclosure } : null, + guid: this.guid, pubDate: this.pubDate, chapters: this.chapters.map(ch => ({ ...ch })), audioFile: this.audioFile.toJSON(), @@ -93,6 +96,7 @@ class PodcastEpisode { subtitle: this.subtitle, description: this.description, enclosure: this.enclosure ? { ...this.enclosure } : null, + guid: this.guid, pubDate: this.pubDate, chapters: this.chapters.map(ch => ({ ...ch })), audioFile: this.audioFile.toJSON(), @@ -133,6 +137,7 @@ class PodcastEpisode { this.pubDate = data.pubDate || '' this.description = data.description || '' this.enclosure = data.enclosure ? { ...data.enclosure } : null + this.guid = data.guid || null this.season = data.season || '' this.episode = data.episode || '' this.episodeType = data.episodeType || 'full' diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 2fd684ea..0e68a0a4 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -4,7 +4,7 @@ const { xmlToJSON, levenshteinDistance } = require('./index') const htmlSanitizer = require('../utils/htmlSanitizer') function extractFirstArrayItem(json, key) { - if (!json[key] || !json[key].length) return null + if (!json[key]?.length) return null return json[key][0] } @@ -110,13 +110,24 @@ function extractEpisodeData(item) { const pubDate = extractFirstArrayItem(item, 'pubDate') if (typeof pubDate === 'string') { episode.pubDate = pubDate - } else if (pubDate && typeof pubDate._ === 'string') { + } else if (typeof pubDate?._ === 'string') { episode.pubDate = pubDate._ } else { Logger.error(`[podcastUtils] Invalid pubDate ${item['pubDate']} for ${episode.enclosure.url}`) } } + if (item['guid']) { + const guidItem = extractFirstArrayItem(item, 'guid') + if (typeof guidItem === 'string') { + episode.guid = guidItem + } else if (typeof guidItem?._ === 'string') { + episode.guid = guidItem._ + } else { + Logger.error(`[podcastUtils] Invalid guid ${item['guid']} for ${episode.enclosure.url}`) + } + } + const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle'] arrayFields.forEach((key) => { const cleanKey = key.split(':').pop() @@ -142,6 +153,7 @@ function cleanEpisodeData(data) { explicit: data.explicit || '', publishedAt, enclosure: data.enclosure, + guid: data.guid || null, chaptersUrl: data.chaptersUrl || null, chaptersType: data.chaptersType || null } @@ -159,16 +171,16 @@ function extractPodcastEpisodes(items) { } function cleanPodcastJson(rssJson, excludeEpisodeMetadata) { - if (!rssJson.channel || !rssJson.channel.length) { + if (!rssJson.channel?.length) { Logger.error(`[podcastUtil] Invalid podcast no channel object`) return null } - var channel = rssJson.channel[0] - if (!channel.item || !channel.item.length) { + const channel = rssJson.channel[0] + if (!channel.item?.length) { Logger.error(`[podcastUtil] Invalid podcast no episodes`) return null } - var podcast = { + const podcast = { metadata: extractPodcastMetadata(channel) } if (!excludeEpisodeMetadata) { @@ -181,8 +193,8 @@ function cleanPodcastJson(rssJson, excludeEpisodeMetadata) { module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = false, includeRaw = false) => { if (!xml) return null - var json = await xmlToJSON(xml) - if (!json || !json.rss) { + const json = await xmlToJSON(xml) + if (!json?.rss) { Logger.error('[podcastUtils] Invalid XML or RSS feed') return null } @@ -215,12 +227,12 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { data.data = data.data.toString() } - if (!data || !data.data) { + if (!data?.data) { Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`) return false } Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}" success - parsing xml`) - var payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata) + const payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata) if (!payload) { return false } @@ -246,7 +258,7 @@ module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => { module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => { searchTitle = searchTitle.toLowerCase().trim() - if (!feed || !feed.episodes) { + if (!feed?.episodes) { return null } From b4ce5342c0add31936d722127987eb706fa86d8f Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 17 Oct 2023 17:46:43 -0500 Subject: [PATCH 42/84] Add:Tools tab on library modal, api endpoint to remove all metadata files from library item folders --- .../components/modals/libraries/EditModal.vue | 12 +++- .../modals/libraries/LibraryTools.vue | 70 +++++++++++++++++++ server/controllers/LibraryController.js | 50 +++++++++++++ server/routers/ApiRouter.js | 1 + 4 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 client/components/modals/libraries/LibraryTools.vue diff --git a/client/components/modals/libraries/EditModal.vue b/client/components/modals/libraries/EditModal.vue index 1fd011cf..09c0fc1d 100644 --- a/client/components/modals/libraries/EditModal.vue +++ b/client/components/modals/libraries/EditModal.vue @@ -12,9 +12,9 @@ </div> <div class="px-2 md:px-4 w-full text-sm pt-2 md:pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh"> - <component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" /> + <component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :library-id="libraryId" :processing.sync="processing" @update="updateLibrary" @close="show = false" /> - <div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10"> + <div v-show="selectedTab !== 'tools'" class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10"> <div class="flex justify-end"> <ui-btn @click="submit">{{ buttonText }}</ui-btn> </div> @@ -57,6 +57,9 @@ export default { mediaType() { return this.libraryCopy?.mediaType }, + libraryId() { + return this.library?.id + }, tabs() { return [ { @@ -78,6 +81,11 @@ export default { id: 'schedule', title: this.$strings.HeaderSchedule, component: 'modals-libraries-schedule-scan' + }, + { + id: 'tools', + title: this.$strings.HeaderTools, + component: 'modals-libraries-library-tools' } ].filter((tab) => { return tab.id !== 'scanner' || this.mediaType === 'book' diff --git a/client/components/modals/libraries/LibraryTools.vue b/client/components/modals/libraries/LibraryTools.vue new file mode 100644 index 00000000..d1e62dd4 --- /dev/null +++ b/client/components/modals/libraries/LibraryTools.vue @@ -0,0 +1,70 @@ +<template> + <div class="w-full h-full px-1 md:px-4 py-1 mb-4"> + <ui-btn class="mb-4" @click.stop="removeAllMetadataClick('json')">Remove all metadata.json files in library item folders</ui-btn> + <ui-btn @click.stop="removeAllMetadataClick('abs')">Remove all metadata.abs files in library item folders</ui-btn> + </div> +</template> + +<script> +export default { + props: { + library: { + type: Object, + default: () => null + }, + libraryId: String, + processing: Boolean + }, + data() { + return {} + }, + computed: { + librarySettings() { + return this.library.settings || {} + }, + mediaType() { + return this.library.mediaType + }, + isBookLibrary() { + return this.mediaType === 'book' + } + }, + methods: { + removeAllMetadataClick(ext) { + const payload = { + message: `Are you sure you want to remove all metadata.${ext} files in your library item folders?`, + persistent: true, + callback: (confirmed) => { + if (confirmed) { + this.removeAllMetadataInLibrary(ext) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, + removeAllMetadataInLibrary(ext) { + this.$emit('update:processing', true) + this.$axios + .$post(`/api/libraries/${this.libraryId}/remove-metadata?ext=${ext}`) + .then((data) => { + if (!data.found) { + this.$toast.info(`No metadata.${ext} files were found in library`) + } else if (!data.removed) { + this.$toast.success(`No metadata.${ext} files removed`) + } else { + this.$toast.success(`Successfully removed ${data.removed} metadata.${ext} files`) + } + }) + .catch((error) => { + console.error('Failed to remove metadata files', error) + this.$toast.error('Failed to remove metadata files') + }) + .finally(() => { + this.$emit('update:processing', false) + }) + } + }, + mounted() {} +} +</script> \ No newline at end of file diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 2b76d6b3..9c593ff2 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -854,6 +854,56 @@ class LibraryController { res.send(opmlText) } + /** + * Remove all metadata.json or metadata.abs files in library item folders + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async removeAllMetadataFiles(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[LibraryController] Non-admin user attempted to remove all metadata files`, req.user) + return res.sendStatus(403) + } + + const fileExt = req.query.ext === 'abs' ? 'abs' : 'json' + const metadataFilename = `metadata.${fileExt}` + const libraryItemsWithMetadata = await Database.libraryItemModel.findAll({ + attributes: ['id', 'libraryFiles'], + where: [ + { + libraryId: req.library.id + }, + Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(libraryFiles) AND json_extract(json_each.value, "$.metadata.filename") = "${metadataFilename}")`), { + [Sequelize.Op.gte]: 1 + }) + ] + }) + if (!libraryItemsWithMetadata.length) { + Logger.info(`[LibraryController] No ${metadataFilename} files found to remove`) + return res.json({ + found: 0 + }) + } + + Logger.info(`[LibraryController] Found ${libraryItemsWithMetadata.length} ${metadataFilename} files to remove`) + + let numRemoved = 0 + for (const libraryItem of libraryItemsWithMetadata) { + const metadataFilepath = libraryItem.libraryFiles.find(lf => lf.metadata.filename === metadataFilename)?.metadata.path + if (!metadataFilepath) continue + Logger.debug(`[LibraryController] Removing file "${metadataFilepath}"`) + if ((await fileUtils.removeFile(metadataFilepath))) { + numRemoved++ + } + } + + res.json({ + found: libraryItemsWithMetadata.length, + removed: numRemoved + }) + } + /** * Middleware that is not using libraryItems from memory * @param {import('express').Request} req diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index a90d1873..03a0696c 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -84,6 +84,7 @@ class ApiRouter { this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this)) this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this)) this.router.post('/libraries/order', LibraryController.reorder.bind(this)) + this.router.post('/libraries/:id/remove-metadata', LibraryController.middleware.bind(this), LibraryController.removeAllMetadataFiles.bind(this)) // // Item Routes From d22052c612dfb1e1ab0ebbccd97757d461af4287 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 18 Oct 2023 16:47:56 -0500 Subject: [PATCH 43/84] Update UI for library tools tab --- .../components/modals/libraries/EditModal.vue | 2 +- .../modals/libraries/LibraryTools.vue | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/client/components/modals/libraries/EditModal.vue b/client/components/modals/libraries/EditModal.vue index 09c0fc1d..03b66931 100644 --- a/client/components/modals/libraries/EditModal.vue +++ b/client/components/modals/libraries/EditModal.vue @@ -1,5 +1,5 @@ <template> - <modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing"> + <modals-modal v-model="show" name="edit-library" :width="800" :height="'unset'" :processing="processing"> <template #outer> <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <p class="text-xl md:text-3xl text-white truncate">{{ title }}</p> diff --git a/client/components/modals/libraries/LibraryTools.vue b/client/components/modals/libraries/LibraryTools.vue index d1e62dd4..7297c1ae 100644 --- a/client/components/modals/libraries/LibraryTools.vue +++ b/client/components/modals/libraries/LibraryTools.vue @@ -1,7 +1,18 @@ <template> - <div class="w-full h-full px-1 md:px-4 py-1 mb-4"> - <ui-btn class="mb-4" @click.stop="removeAllMetadataClick('json')">Remove all metadata.json files in library item folders</ui-btn> - <ui-btn @click.stop="removeAllMetadataClick('abs')">Remove all metadata.abs files in library item folders</ui-btn> + <div class="w-full h-full px-1 md:px-2 py-1 mb-4"> + <div class="w-full border border-black-200 p-4 my-8"> + <div class="flex flex-wrap items-center"> + <div> + <p class="text-lg">Remove metadata files in library item folders</p> + <p class="max-w-sm text-sm pt-2 text-gray-300">Remove all metadata.json or metadata.abs files in your {{ mediaType }} folders</p> + </div> + <div class="flex-grow" /> + <div> + <ui-btn class="mb-4 block" @click.stop="removeAllMetadataClick('json')">Remove all metadata.json</ui-btn> + <ui-btn @click.stop="removeAllMetadataClick('abs')">Remove all metadata.abs</ui-btn> + </div> + </div> + </div> </div> </template> From 516b0b44642cc79005f6278bc2bd5078b3ee1f9a Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 18 Oct 2023 17:02:15 -0500 Subject: [PATCH 44/84] Fix:Book scanner set item as missing if no media files are found #2226 --- server/scanner/BookScanner.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index e579bcc9..f752417c 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -339,6 +339,19 @@ class BookScanner { libraryItemUpdated = global.ServerSettings.storeMetadataWithItem && !existingLibraryItem.isFile } + // If book has no audio files and no ebook then it is considered missing + if (!media.audioFiles.length && !media.ebookFile) { + if (!existingLibraryItem.isMissing) { + libraryScan.addLog(LogLevel.INFO, `Book "${bookMetadata.title}" has no audio files and no ebook file. Setting library item as missing`) + existingLibraryItem.isMissing = true + libraryItemUpdated = true + } + } else if (existingLibraryItem.isMissing) { + libraryScan.addLog(LogLevel.INFO, `Book "${bookMetadata.title}" was missing but now has media files. Setting library item as NOT missing`) + existingLibraryItem.isMissing = false + libraryItemUpdated = true + } + // Check/update the isSupplementary flag on libraryFiles for the LibraryItem for (const libraryFile of existingLibraryItem.libraryFiles) { if (globals.SupportedEbookTypes.includes(libraryFile.metadata.ext.slice(1).toLowerCase())) { From 8c5ce6149f79b276991496606551339da82add69 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 18 Oct 2023 17:10:53 -0500 Subject: [PATCH 45/84] Fix:Aspect ratio of authors image on authors landing page #2227 --- client/components/app/StreamContainer.vue | 2 +- client/components/cards/AuthorCard.vue | 2 +- client/components/tables/collection/BookTableRow.vue | 2 +- client/components/tables/playlist/ItemTableRow.vue | 2 +- client/pages/author/_id.vue | 4 ++-- client/pages/item/_id/index.vue | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/components/app/StreamContainer.vue b/client/components/app/StreamContainer.vue index d40ce0da..1aecbf4e 100644 --- a/client/components/app/StreamContainer.vue +++ b/client/components/app/StreamContainer.vue @@ -15,7 +15,7 @@ <div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div> <div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div> <div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base"> - <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link> + <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link> </div> <div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div> <widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator> diff --git a/client/components/cards/AuthorCard.vue b/client/components/cards/AuthorCard.vue index c06c5333..db4e7e9a 100644 --- a/client/components/cards/AuthorCard.vue +++ b/client/components/cards/AuthorCard.vue @@ -1,5 +1,5 @@ <template> - <nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`"> + <nuxt-link :to="`/author/${author.id}`"> <div @mouseover="mouseover" @mouseleave="mouseleave"> <div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden"> <!-- Image or placeholder --> diff --git a/client/components/tables/collection/BookTableRow.vue b/client/components/tables/collection/BookTableRow.vue index 834088d9..399c429a 100644 --- a/client/components/tables/collection/BookTableRow.vue +++ b/client/components/tables/collection/BookTableRow.vue @@ -26,7 +26,7 @@ </div> <div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300"> <template v-for="(author, index) in bookAuthors"> - <nuxt-link :key="author.id" :to="`/author/${author.id}?library=${book.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link + <nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link ><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span> </template> </div> diff --git a/client/components/tables/playlist/ItemTableRow.vue b/client/components/tables/playlist/ItemTableRow.vue index ff986a33..e5486461 100644 --- a/client/components/tables/playlist/ItemTableRow.vue +++ b/client/components/tables/playlist/ItemTableRow.vue @@ -21,7 +21,7 @@ </div> <div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300"> <template v-for="(author, index) in bookAuthors"> - <nuxt-link :key="author.id" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link + <nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link ><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span> </template> <nuxt-link v-if="episode" :to="`/item/${libraryItem.id}`" class="truncate hover:underline">{{ mediaMetadata.title }}</nuxt-link> diff --git a/client/pages/author/_id.vue b/client/pages/author/_id.vue index c9834d8d..5ef1bef2 100644 --- a/client/pages/author/_id.vue +++ b/client/pages/author/_id.vue @@ -3,7 +3,7 @@ <div class="max-w-6xl mx-auto"> <div class="flex flex-wrap sm:flex-nowrap justify-center mb-6"> <div class="w-48 min-w-48"> - <div class="w-full h-52"> + <div class="w-full h-60"> <covers-author-image :author="author" rounded="0" /> </div> </div> @@ -44,7 +44,7 @@ <script> export default { async asyncData({ store, app, params, redirect, query }) { - const author = await app.$axios.$get(`/api/authors/${params.id}?library=${query.library || store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => { + const author = await app.$axios.$get(`/api/authors/${params.id}?include=items,series`).catch((error) => { console.error('Failed to get author', error) return null }) diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 176725b9..43adc727 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -42,7 +42,7 @@ <nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">, </span></nuxt-link> </p> <p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis"> - by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link> + by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link> </p> <p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p> </template> From 22361d785d35e43adf517907c0812c377d7139b0 Mon Sep 17 00:00:00 2001 From: JBlond <leet31337@web.de> Date: Thu, 19 Oct 2023 15:22:00 +0200 Subject: [PATCH 46/84] Translate new string for DE language. --- 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 b72df02f..50d0dbeb 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -266,7 +266,7 @@ "LabelHost": "Host", "LabelHour": "Stunde", "LabelIcon": "Symbol", - "LabelImageURLFromTheWeb": "Image URL from the web", + "LabelImageURLFromTheWeb": "Bild-URL vom Internet", "LabelIncludeInTracklist": "In die Titelliste aufnehmen", "LabelIncomplete": "Unvollständig", "LabelInProgress": "In Bearbeitung", From 4a5f534a658c17509ee4a9b9a8b8965986cdbccd Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 19 Oct 2023 16:30:33 -0500 Subject: [PATCH 47/84] Update:Description width of item page to match width of tables --- client/pages/item/_id/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 43adc727..7e0bc3dd 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -124,7 +124,7 @@ </ui-context-menu-dropdown> </div> - <div class="my-4 max-w-2xl"> + <div class="my-4 w-full"> <p class="text-base text-gray-100 whitespace-pre-line">{{ description }}</p> </div> From 920ddf43d721e9be823146e6154b89b36c550da8 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 19 Oct 2023 17:20:12 -0500 Subject: [PATCH 48/84] Remove unused old model functions --- server/Server.js | 1 - server/controllers2/libraryItem.controller.js | 16 -- server/db/libraryItem.db.js | 80 ------ server/objects/LibraryItem.js | 98 +------- server/objects/entities/PodcastEpisode.js | 92 +------ server/objects/mediaTypes/Book.js | 68 +---- server/objects/mediaTypes/Music.js | 19 -- server/objects/mediaTypes/Podcast.js | 51 +--- server/objects/mediaTypes/Video.js | 9 - server/objects/metadata/BookMetadata.js | 237 +----------------- server/objects/metadata/MusicMetadata.js | 15 +- server/objects/metadata/PodcastMetadata.js | 84 +------ server/objects/metadata/VideoMetadata.js | 13 - server/routers/ApiRouter.js | 1 - server/routes/index.js | 8 - server/routes/libraries.js | 7 - 16 files changed, 15 insertions(+), 784 deletions(-) delete mode 100644 server/controllers2/libraryItem.controller.js delete mode 100644 server/db/libraryItem.db.js delete mode 100644 server/routes/index.js delete mode 100644 server/routes/libraries.js diff --git a/server/Server.js b/server/Server.js index d1d36d0b..36780df4 100644 --- a/server/Server.js +++ b/server/Server.js @@ -153,7 +153,6 @@ class Server { // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) - // router.use('/api/v1', routes) // TODO: New routes router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router) router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) diff --git a/server/controllers2/libraryItem.controller.js b/server/controllers2/libraryItem.controller.js deleted file mode 100644 index 83b1776e..00000000 --- a/server/controllers2/libraryItem.controller.js +++ /dev/null @@ -1,16 +0,0 @@ -const itemDb = require('../db/item.db') - -const getLibraryItem = async (req, res) => { - let libraryItem = null - if (req.query.expanded == 1) { - libraryItem = await itemDb.getLibraryItemExpanded(req.params.id) - } else { - libraryItem = await itemDb.getLibraryItemMinified(req.params.id) - } - - res.json(libraryItem) -} - -module.exports = { - getLibraryItem -} \ No newline at end of file diff --git a/server/db/libraryItem.db.js b/server/db/libraryItem.db.js deleted file mode 100644 index 335a52a1..00000000 --- a/server/db/libraryItem.db.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * TODO: Unused for testing - */ -const { Sequelize } = require('sequelize') -const Database = require('../Database') - -const getLibraryItemMinified = (libraryItemId) => { - return Database.libraryItemModel.findByPk(libraryItemId, { - include: [ - { - model: Database.bookModel, - attributes: [ - 'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags' - ], - include: [ - { - model: Database.authorModel, - attributes: ['id', 'name'], - through: { - attributes: [] - } - }, - { - model: Database.seriesModel, - attributes: ['id', 'name'], - through: { - attributes: ['sequence'] - } - } - ] - }, - { - model: Database.podcastModel, - attributes: [ - 'id', 'title', 'author', 'releaseDate', 'feedURL', 'imageURL', 'description', 'itunesPageURL', 'itunesId', 'itunesArtistId', 'language', 'podcastType', 'explicit', 'autoDownloadEpisodes', 'genres', 'tags', - [Sequelize.literal('(SELECT COUNT(*) FROM "podcastEpisodes" WHERE "podcastEpisodes"."podcastId" = podcast.id)'), 'numPodcastEpisodes'] - ] - } - ] - }) -} - -const getLibraryItemExpanded = (libraryItemId) => { - return Database.libraryItemModel.findByPk(libraryItemId, { - include: [ - { - model: Database.bookModel, - include: [ - { - model: Database.authorModel, - through: { - attributes: [] - } - }, - { - model: Database.seriesModel, - through: { - attributes: ['sequence'] - } - } - ] - }, - { - model: Database.podcastModel, - include: [ - { - model: Database.podcastEpisodeModel - } - ] - }, - 'libraryFolder', - 'library' - ] - }) -} - -module.exports = { - getLibraryItemMinified, - getLibraryItemExpanded -} \ No newline at end of file diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index e36a5a92..bb91e2d6 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -1,7 +1,6 @@ const uuidv4 = require("uuid").v4 const fs = require('../libs/fsExtra') const Path = require('path') -const { version } = require('../../package.json') const Logger = require('../Logger') const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') const LibraryFile = require('./files/LibraryFile') @@ -9,7 +8,7 @@ const Book = require('./mediaTypes/Book') const Podcast = require('./mediaTypes/Podcast') const Video = require('./mediaTypes/Video') const Music = require('./mediaTypes/Music') -const { areEquivalent, copyValue, cleanStringForSearch } = require('../utils/index') +const { areEquivalent, copyValue } = require('../utils/index') const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') class LibraryItem { @@ -180,34 +179,23 @@ class LibraryItem { this.libraryFiles.forEach((lf) => total += lf.metadata.size) return total } - get audioFileTotalSize() { - let total = 0 - this.libraryFiles.filter(lf => lf.fileType == 'audio').forEach((lf) => total += lf.metadata.size) - return total - } get hasAudioFiles() { return this.libraryFiles.some(lf => lf.fileType === 'audio') } get hasMediaEntities() { return this.media.hasMediaEntities } - get hasIssues() { - if (this.isMissing || this.isInvalid) return true - return this.media.hasIssues - } // Data comes from scandir library item data + // TODO: Remove this function. Only used when creating a new podcast now setData(libraryMediaType, payload) { this.id = uuidv4() this.mediaType = libraryMediaType - if (libraryMediaType === 'video') { - this.media = new Video() - } else if (libraryMediaType === 'podcast') { + if (libraryMediaType === 'podcast') { this.media = new Podcast() - } else if (libraryMediaType === 'book') { - this.media = new Book() - } else if (libraryMediaType === 'music') { - this.media = new Music() + } else { + Logger.error(`[LibraryItem] setData called with unsupported media type "${libraryMediaType}"`) + return } this.media.id = uuidv4() this.media.libraryItemId = this.id @@ -270,85 +258,13 @@ class LibraryItem { this.updatedAt = Date.now() } - setInvalid() { - this.isInvalid = true - this.updatedAt = Date.now() - } - - setLastScan() { - this.lastScan = Date.now() - this.updatedAt = Date.now() - this.scanVersion = version - } - - // Returns null if file not found, true if file was updated, false if up to date - // updates existing LibraryFile, AudioFile, EBookFile's - checkFileFound(fileFound) { - let hasUpdated = false - - let existingFile = this.libraryFiles.find(lf => lf.ino === fileFound.ino) - let mediaFile = null - if (!existingFile) { - existingFile = this.libraryFiles.find(lf => lf.metadata.path === fileFound.metadata.path) - if (existingFile) { - // Update media file ino - mediaFile = this.media.findFileWithInode(existingFile.ino) - if (mediaFile) { - mediaFile.ino = fileFound.ino - } - - // file inode was updated - existingFile.ino = fileFound.ino - hasUpdated = true - } else { - // file not found - return null - } - } else { - mediaFile = this.media.findFileWithInode(existingFile.ino) - } - - if (existingFile.metadata.path !== fileFound.metadata.path) { - existingFile.metadata.path = fileFound.metadata.path - existingFile.metadata.relPath = fileFound.metadata.relPath - if (mediaFile) { - mediaFile.metadata.path = fileFound.metadata.path - mediaFile.metadata.relPath = fileFound.metadata.relPath - } - hasUpdated = true - } - - // FileMetadata keys - ['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size'].forEach((key) => { - if (existingFile.metadata[key] !== fileFound.metadata[key]) { - // Add modified flag on file data object if exists and was changed - if (key === 'mtimeMs' && existingFile.metadata[key]) { - fileFound.metadata.wasModified = true - } - - existingFile.metadata[key] = fileFound.metadata[key] - if (mediaFile) { - if (key === 'mtimeMs') mediaFile.metadata.wasModified = true - mediaFile.metadata[key] = fileFound.metadata[key] - } - hasUpdated = true - } - }) - - return hasUpdated - } - - searchQuery(query) { - query = cleanStringForSearch(query) - return this.media.searchQuery(query) - } - getDirectPlayTracklist(episodeId) { return this.media.getDirectPlayTracklist(episodeId) } /** * Save metadata.json/metadata.abs file + * TODO: Move to new LibraryItem model * @returns {Promise<LibraryFile>} null if not saved */ async saveMetadata() { diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 0a8f3349..1452b7b5 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -1,7 +1,5 @@ const uuidv4 = require("uuid").v4 -const Path = require('path') -const Logger = require('../../Logger') -const { cleanStringForSearch, areEquivalent, copyValue } = require('../../utils/index') +const { areEquivalent, copyValue } = require('../../utils/index') const AudioFile = require('../files/AudioFile') const AudioTrack = require('../files/AudioTrack') @@ -146,19 +144,6 @@ class PodcastEpisode { this.updatedAt = Date.now() } - setDataFromAudioFile(audioFile, index) { - this.id = uuidv4() - this.audioFile = audioFile - this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename)) - this.index = index - - this.setDataFromAudioMetaTags(audioFile.metaTags, true) - - this.chapters = audioFile.chapters?.map((c) => ({ ...c })) - this.addedAt = Date.now() - this.updatedAt = Date.now() - } - update(payload) { let hasUpdates = false for (const key in this.toJSON()) { @@ -192,80 +177,5 @@ class PodcastEpisode { if (!this.enclosure || !this.enclosure.url) return false return this.enclosure.url == url } - - searchQuery(query) { - return cleanStringForSearch(this.title).includes(query) - } - - setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) { - if (!audioFileMetaTags) return false - - const MetadataMapArray = [ - { - tag: 'tagComment', - altTag: 'tagSubtitle', - key: 'description' - }, - { - tag: 'tagSubtitle', - key: 'subtitle' - }, - { - tag: 'tagDate', - key: 'pubDate' - }, - { - tag: 'tagDisc', - key: 'season', - }, - { - tag: 'tagTrack', - altTag: 'tagSeriesPart', - key: 'episode' - }, - { - tag: 'tagTitle', - key: 'title' - }, - { - tag: 'tagEpisodeType', - key: 'episodeType' - } - ] - - MetadataMapArray.forEach((mapping) => { - let value = audioFileMetaTags[mapping.tag] - let tagToUse = mapping.tag - if (!value && mapping.altTag) { - tagToUse = mapping.altTag - value = audioFileMetaTags[mapping.altTag] - } - - if (value && typeof value === 'string') { - value = value.trim() // Trim whitespace - - if (mapping.key === 'pubDate' && (!this.pubDate || overrideExistingDetails)) { - const pubJsDate = new Date(value) - if (pubJsDate && !isNaN(pubJsDate)) { - this.publishedAt = pubJsDate.valueOf() - this.pubDate = value - Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`) - } else { - Logger.warn(`[PodcastEpisode] Mapping pubDate with tag ${tagToUse} has invalid date "${value}"`) - } - } else if (mapping.key === 'episodeType' && (!this.episodeType || overrideExistingDetails)) { - if (['full', 'trailer', 'bonus'].includes(value)) { - this.episodeType = value - Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`) - } else { - Logger.warn(`[PodcastEpisode] Mapping episodeType with invalid value "${value}". Must be one of [full, trailer, bonus].`) - } - } else if (!this[mapping.key] || overrideExistingDetails) { - this[mapping.key] = value - Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`) - } - } - }) - } } module.exports = PodcastEpisode diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index 33cbc016..afbf1622 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -1,9 +1,7 @@ const Logger = require('../../Logger') const BookMetadata = require('../metadata/BookMetadata') -const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index') -const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata') -const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator') -const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils') +const { areEquivalent, copyValue } = require('../../utils/index') +const { filePathToPOSIX } = require('../../utils/fileUtils') const AudioFile = require('../files/AudioFile') const AudioTrack = require('../files/AudioTrack') const EBookFile = require('../files/EBookFile') @@ -111,23 +109,12 @@ class Book { get hasMediaEntities() { return !!this.tracks.length || this.ebookFile } - get shouldSearchForCover() { - if (this.coverPath) return false - if (!this.lastCoverSearch || this.metadata.coverSearchQuery !== this.lastCoverSearchQuery) return true - return (Date.now() - this.lastCoverSearch) > 1000 * 60 * 60 * 24 * 7 // 7 day - } - get hasEmbeddedCoverArt() { - return this.audioFiles.some(af => af.embeddedCoverArt) - } get invalidAudioFiles() { return this.audioFiles.filter(af => af.invalid) } get includedAudioFiles() { return this.audioFiles.filter(af => !af.exclude && !af.invalid) } - get hasIssues() { - return this.missingParts.length || this.invalidAudioFiles.length - } get tracks() { let startOffset = 0 return this.includedAudioFiles.map((af) => { @@ -226,57 +213,6 @@ class Book { return null } - updateLastCoverSearch(coverWasFound) { - this.lastCoverSearch = coverWasFound ? null : Date.now() - this.lastCoverSearchQuery = coverWasFound ? null : this.metadata.coverSearchQuery - } - - // Audio file metadata tags map to book details (will not overwrite) - setMetadataFromAudioFile(overrideExistingDetails = false) { - if (!this.audioFiles.length) return false - var audioFile = this.audioFiles[0] - if (!audioFile.metaTags) return false - return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails) - } - - setData(mediaPayload) { - this.metadata = new BookMetadata() - if (mediaPayload.metadata) { - this.metadata.setData(mediaPayload.metadata) - } - } - - searchQuery(query) { - const payload = { - tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)), - series: this.metadata.searchSeries(query), - authors: this.metadata.searchAuthors(query), - narrators: this.metadata.searchNarrators(query), - matchKey: null, - matchText: null - } - const metadataMatch = this.metadata.searchQuery(query) - if (metadataMatch) { - payload.matchKey = metadataMatch.matchKey - payload.matchText = metadataMatch.matchText - } else { - if (payload.authors.length) { - payload.matchKey = 'authors' - payload.matchText = this.metadata.authorName - } else if (payload.series.length) { - payload.matchKey = 'series' - payload.matchText = this.metadata.seriesName - } else if (payload.tags.length) { - payload.matchKey = 'tags' - payload.matchText = this.tags.join(', ') - } else if (payload.narrators.length) { - payload.matchKey = 'narrators' - payload.matchText = this.metadata.narratorName - } - } - return payload - } - /** * Set the EBookFile from a LibraryFile * If null then ebookFile will be removed from the book diff --git a/server/objects/mediaTypes/Music.js b/server/objects/mediaTypes/Music.js index 7512c4ec..d4b8a518 100644 --- a/server/objects/mediaTypes/Music.js +++ b/server/objects/mediaTypes/Music.js @@ -65,15 +65,6 @@ class Music { get hasMediaEntities() { return !!this.audioFile } - get shouldSearchForCover() { - return false - } - get hasEmbeddedCoverArt() { - return this.audioFile.embeddedCoverArt - } - get hasIssues() { - return false - } get duration() { return this.audioFile.duration || 0 } @@ -134,16 +125,6 @@ class Music { this.audioFile = audioFile } - setMetadataFromAudioFile(overrideExistingDetails = false) { - if (!this.audioFile) return false - if (!this.audioFile.metaTags) return false - return this.metadata.setDataFromAudioMetaTags(this.audioFile.metaTags, overrideExistingDetails) - } - - searchQuery(query) { - return {} - } - // Only checks container format checkCanDirectPlay(payload) { return true diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 9ddb3412..969e2548 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -1,9 +1,8 @@ const Logger = require('../../Logger') const PodcastEpisode = require('../entities/PodcastEpisode') const PodcastMetadata = require('../metadata/PodcastMetadata') -const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index') -const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator') -const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils') +const { areEquivalent, copyValue } = require('../../utils/index') +const { filePathToPOSIX } = require('../../utils/fileUtils') class Podcast { constructor(podcast) { @@ -110,15 +109,6 @@ class Podcast { get hasMediaEntities() { return !!this.episodes.length } - get shouldSearchForCover() { - return false - } - get hasEmbeddedCoverArt() { - return this.episodes.some(ep => ep.audioFile.embeddedCoverArt) - } - get hasIssues() { - return false - } get duration() { let total = 0 this.episodes.forEach((ep) => total += ep.duration) @@ -187,10 +177,6 @@ class Podcast { return null } - findEpisodeWithInode(inode) { - return this.episodes.find(ep => ep.audioFile.ino === inode) - } - setData(mediaData) { this.metadata = new PodcastMetadata() if (mediaData.metadata) { @@ -203,31 +189,6 @@ class Podcast { this.lastEpisodeCheck = Date.now() // Makes sure new episodes are after this } - searchEpisodes(query) { - return this.episodes.filter(ep => ep.searchQuery(query)) - } - - searchQuery(query) { - const payload = { - tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)), - matchKey: null, - matchText: null - } - const metadataMatch = this.metadata.searchQuery(query) - if (metadataMatch) { - payload.matchKey = metadataMatch.matchKey - payload.matchText = metadataMatch.matchText - } else { - const matchingEpisodes = this.searchEpisodes(query) - if (matchingEpisodes.length) { - payload.matchKey = 'episode' - payload.matchText = matchingEpisodes[0].title - } - } - - return payload - } - checkHasEpisode(episodeId) { return this.episodes.some(ep => ep.id === episodeId) } @@ -294,14 +255,6 @@ class Podcast { return this.episodes.find(ep => ep.id == episodeId) } - // Audio file metadata tags map to podcast details - setMetadataFromAudioFile(overrideExistingDetails = false) { - if (!this.episodes.length) return false - const audioFile = this.episodes[0].audioFile - if (!audioFile?.metaTags) return false - return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails) - } - getChapters(episodeId) { return this.getEpisode(episodeId)?.chapters?.map(ch => ({ ...ch })) || [] } diff --git a/server/objects/mediaTypes/Video.js b/server/objects/mediaTypes/Video.js index dae834c1..940eab0b 100644 --- a/server/objects/mediaTypes/Video.js +++ b/server/objects/mediaTypes/Video.js @@ -69,15 +69,6 @@ class Video { get hasMediaEntities() { return true } - get shouldSearchForCover() { - return false - } - get hasEmbeddedCoverArt() { - return false - } - get hasIssues() { - return false - } get duration() { return 0 } diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index 9fb07bc8..490b9949 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -1,5 +1,5 @@ const Logger = require('../../Logger') -const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') +const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') const parseNameString = require('../../utils/parsers/parseNameString') class BookMetadata { constructor(metadata) { @@ -144,20 +144,6 @@ class BookMetadata { return `${se.name} #${se.sequence}` }).join(', ') } - get seriesNameIgnorePrefix() { - if (!this.series.length) return '' - return this.series.map(se => { - if (!se.sequence) return getTitleIgnorePrefix(se.name) - return `${getTitleIgnorePrefix(se.name)} #${se.sequence}` - }).join(', ') - } - get seriesNamePrefixAtEnd() { - if (!this.series.length) return '' - return this.series.map(se => { - if (!se.sequence) return getTitlePrefixAtEnd(se.name) - return `${getTitlePrefixAtEnd(se.name)} #${se.sequence}` - }).join(', ') - } get firstSeriesName() { if (!this.series.length) return '' return this.series[0].name @@ -169,36 +155,15 @@ class BookMetadata { get narratorName() { return this.narrators.join(', ') } - get coverSearchQuery() { - if (!this.authorName) return this.title - return this.title + '&' + this.authorName - } - hasAuthor(id) { - return !!this.authors.find(au => au.id == id) - } - hasSeries(seriesId) { - return !!this.series.find(se => se.id == seriesId) - } - hasNarrator(narratorName) { - return this.narrators.includes(narratorName) - } getSeries(seriesId) { return this.series.find(se => se.id == seriesId) } - getFirstSeries() { - return this.series.length ? this.series[0] : null - } getSeriesSequence(seriesId) { const series = this.series.find(se => se.id == seriesId) if (!series) return null return series.sequence || '' } - getSeriesSortTitle(series) { - if (!series) return '' - if (!series.sequence) return series.name - return `${series.name} #${series.sequence}` - } update(payload) { const json = this.toJSON() @@ -231,205 +196,5 @@ class BookMetadata { name: newAuthor.name }) } - - /** - * Update narrator name if narrator is in book - * @param {String} oldNarratorName - Narrator name to get updated - * @param {String} newNarratorName - Updated narrator name - * @return {Boolean} True if narrator was updated - */ - updateNarrator(oldNarratorName, newNarratorName) { - if (!this.hasNarrator(oldNarratorName)) return false - this.narrators = this.narrators.filter(n => n !== oldNarratorName) - if (newNarratorName && !this.hasNarrator(newNarratorName)) { - this.narrators.push(newNarratorName) - } - return true - } - - /** - * Remove narrator name if narrator is in book - * @param {String} narratorName - Narrator name to remove - * @return {Boolean} True if narrator was updated - */ - removeNarrator(narratorName) { - if (!this.hasNarrator(narratorName)) return false - this.narrators = this.narrators.filter(n => n !== narratorName) - return true - } - - setData(scanMediaData = {}) { - this.title = scanMediaData.title || null - this.subtitle = scanMediaData.subtitle || null - this.narrators = this.parseNarratorsTag(scanMediaData.narrators) - this.publishedYear = scanMediaData.publishedYear || null - this.description = scanMediaData.description || null - this.isbn = scanMediaData.isbn || null - this.asin = scanMediaData.asin || null - this.language = scanMediaData.language || null - this.genres = [] - this.explicit = !!scanMediaData.explicit - - if (scanMediaData.author) { - this.authors = this.parseAuthorsTag(scanMediaData.author) - } - if (scanMediaData.series) { - this.series = this.parseSeriesTag(scanMediaData.series, scanMediaData.sequence) - } - } - - setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) { - const MetadataMapArray = [ - { - tag: 'tagComposer', - key: 'narrators' - }, - { - tag: 'tagDescription', - altTag: 'tagComment', - key: 'description' - }, - { - tag: 'tagPublisher', - key: 'publisher' - }, - { - tag: 'tagDate', - key: 'publishedYear' - }, - { - tag: 'tagSubtitle', - key: 'subtitle' - }, - { - tag: 'tagAlbum', - altTag: 'tagTitle', - key: 'title', - }, - { - tag: 'tagArtist', - altTag: 'tagAlbumArtist', - key: 'authors' - }, - { - tag: 'tagGenre', - key: 'genres' - }, - { - tag: 'tagSeries', - key: 'series' - }, - { - tag: 'tagIsbn', - key: 'isbn' - }, - { - tag: 'tagLanguage', - key: 'language' - }, - { - tag: 'tagASIN', - key: 'asin' - } - ] - - const updatePayload = {} - - // Metadata is only mapped to the book if it is empty - MetadataMapArray.forEach((mapping) => { - let value = audioFileMetaTags[mapping.tag] - // let tagToUse = mapping.tag - if (!value && mapping.altTag) { - value = audioFileMetaTags[mapping.altTag] - // tagToUse = mapping.altTag - } - - if (value && typeof value === 'string') { - value = value.trim() // Trim whitespace - - if (mapping.key === 'narrators' && (!this.narrators.length || overrideExistingDetails)) { - updatePayload.narrators = this.parseNarratorsTag(value) - } else if (mapping.key === 'authors' && (!this.authors.length || overrideExistingDetails)) { - updatePayload.authors = this.parseAuthorsTag(value) - } else if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) { - updatePayload.genres = this.parseGenresTag(value) - } else if (mapping.key === 'series' && (!this.series.length || overrideExistingDetails)) { - const sequenceTag = audioFileMetaTags.tagSeriesPart || null - updatePayload.series = this.parseSeriesTag(value, sequenceTag) - } else if (!this[mapping.key] || overrideExistingDetails) { - updatePayload[mapping.key] = value - // Logger.debug(`[Book] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`) - } - } - }) - - if (Object.keys(updatePayload).length) { - return this.update(updatePayload) - } - return false - } - - // Returns array of names in First Last format - parseNarratorsTag(narratorsTag) { - const parsed = parseNameString.parse(narratorsTag) - return parsed ? parsed.names : [] - } - - // Return array of authors minified with placeholder id - parseAuthorsTag(authorsTag) { - const parsed = parseNameString.parse(authorsTag) - if (!parsed) return [] - return (parsed.names || []).map((au) => { - const findAuthor = this.authors.find(_au => _au.name == au) - - return { - id: findAuthor?.id || `new-${Math.floor(Math.random() * 1000000)}`, - name: au - } - }) - } - - parseGenresTag(genreTag) { - if (!genreTag || !genreTag.length) return [] - const separators = ['/', '//', ';'] - for (let i = 0; i < separators.length; i++) { - if (genreTag.includes(separators[i])) { - return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g) - } - } - return [genreTag] - } - - // Return array with series with placeholder id - parseSeriesTag(seriesTag, sequenceTag) { - if (!seriesTag) return [] - return [{ - id: `new-${Math.floor(Math.random() * 1000000)}`, - name: seriesTag, - sequence: sequenceTag || '' - }] - } - - searchSeries(query) { - return this.series.filter(se => cleanStringForSearch(se.name).includes(query)) - } - searchAuthors(query) { - return this.authors.filter(au => cleanStringForSearch(au.name).includes(query)) - } - searchNarrators(query) { - return this.narrators.filter(n => cleanStringForSearch(n).includes(query)) - } - searchQuery(query) { // Returns key if match is found - const keysToCheck = ['title', 'asin', 'isbn', 'subtitle'] - for (const key of keysToCheck) { - if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) { - return { - matchKey: key, - matchText: this[key] - } - } - } - return null - } } module.exports = BookMetadata diff --git a/server/objects/metadata/MusicMetadata.js b/server/objects/metadata/MusicMetadata.js index 7da47314..90a887e0 100644 --- a/server/objects/metadata/MusicMetadata.js +++ b/server/objects/metadata/MusicMetadata.js @@ -1,5 +1,5 @@ const Logger = require('../../Logger') -const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') +const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') class MusicMetadata { constructor(metadata) { @@ -133,19 +133,6 @@ class MusicMetadata { return getTitlePrefixAtEnd(this.title) } - searchQuery(query) { // Returns key if match is found - const keysToCheck = ['title', 'album'] - for (const key of keysToCheck) { - if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) { - return { - matchKey: key, - matchText: this[key] - } - } - } - return null - } - setData(mediaMetadata = {}) { this.title = mediaMetadata.title || null this.artist = mediaMetadata.artist || null diff --git a/server/objects/metadata/PodcastMetadata.js b/server/objects/metadata/PodcastMetadata.js index 2c371c6c..8300e93a 100644 --- a/server/objects/metadata/PodcastMetadata.js +++ b/server/objects/metadata/PodcastMetadata.js @@ -1,5 +1,5 @@ const Logger = require('../../Logger') -const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') +const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') class PodcastMetadata { constructor(metadata) { @@ -91,19 +91,6 @@ class PodcastMetadata { return getTitlePrefixAtEnd(this.title) } - searchQuery(query) { // Returns key if match is found - const keysToCheck = ['title', 'author', 'itunesId', 'itunesArtistId'] - for (const key of keysToCheck) { - if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) { - return { - matchKey: key, - matchText: this[key] - } - } - } - return null - } - setData(mediaMetadata = {}) { this.title = mediaMetadata.title || null this.author = mediaMetadata.author || null @@ -136,74 +123,5 @@ class PodcastMetadata { } return hasUpdates } - - setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) { - const MetadataMapArray = [ - { - tag: 'tagAlbum', - altTag: 'tagSeries', - key: 'title' - }, - { - tag: 'tagArtist', - key: 'author' - }, - { - tag: 'tagGenre', - key: 'genres' - }, - { - tag: 'tagLanguage', - key: 'language' - }, - { - tag: 'tagItunesId', - key: 'itunesId' - }, - { - tag: 'tagPodcastType', - key: 'type', - } - ] - - const updatePayload = {} - - MetadataMapArray.forEach((mapping) => { - let value = audioFileMetaTags[mapping.tag] - let tagToUse = mapping.tag - if (!value && mapping.altTag) { - value = audioFileMetaTags[mapping.altTag] - tagToUse = mapping.altTag - } - - if (value && typeof value === 'string') { - value = value.trim() // Trim whitespace - - if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) { - updatePayload.genres = this.parseGenresTag(value) - Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload.genres.join(', ')}`) - } else if (!this[mapping.key] || overrideExistingDetails) { - updatePayload[mapping.key] = value - Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`) - } - } - }) - - if (Object.keys(updatePayload).length) { - return this.update(updatePayload) - } - return false - } - - parseGenresTag(genreTag) { - if (!genreTag || !genreTag.length) return [] - const separators = ['/', '//', ';'] - for (let i = 0; i < separators.length; i++) { - if (genreTag.includes(separators[i])) { - return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g) - } - } - return [genreTag] - } } module.exports = PodcastMetadata diff --git a/server/objects/metadata/VideoMetadata.js b/server/objects/metadata/VideoMetadata.js index 15d57fbe..a2194d15 100644 --- a/server/objects/metadata/VideoMetadata.js +++ b/server/objects/metadata/VideoMetadata.js @@ -55,19 +55,6 @@ class VideoMetadata { return getTitlePrefixAtEnd(this.title) } - searchQuery(query) { // Returns key if match is found - var keysToCheck = ['title'] - for (var key of keysToCheck) { - if (this[key] && String(this[key]).toLowerCase().includes(query)) { - return { - matchKey: key, - matchText: this[key] - } - } - } - return null - } - setData(mediaMetadata = {}) { this.title = mediaMetadata.title || null this.description = mediaMetadata.description || null diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 03a0696c..034951df 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -1,4 +1,3 @@ -const sequelize = require('sequelize') const express = require('express') const Path = require('path') diff --git a/server/routes/index.js b/server/routes/index.js deleted file mode 100644 index e638a9c5..00000000 --- a/server/routes/index.js +++ /dev/null @@ -1,8 +0,0 @@ -const express = require('express') -const libraries = require('./libraries') - -const router = express.Router() - -router.use('/libraries', libraries) - -module.exports = router \ No newline at end of file diff --git a/server/routes/libraries.js b/server/routes/libraries.js deleted file mode 100644 index 07ab5ccd..00000000 --- a/server/routes/libraries.js +++ /dev/null @@ -1,7 +0,0 @@ -const express = require('express') - -const router = express.Router() - -// TODO: Add library routes - -module.exports = router \ No newline at end of file From 5644a40a031879e39c35ff324bca8a76e02e03c7 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 20 Oct 2023 16:08:57 -0500 Subject: [PATCH 49/84] Update:Add missing tranlations #2217 --- client/components/app/Appbar.vue | 8 ++++---- client/components/cards/LazyBookCard.vue | 4 ++-- client/pages/item/_id/index.vue | 4 ++-- client/strings/da.json | 5 +++++ client/strings/de.json | 5 +++++ client/strings/en-us.json | 5 +++++ client/strings/es.json | 5 +++++ client/strings/fr.json | 5 +++++ client/strings/gu.json | 5 +++++ client/strings/hi.json | 5 +++++ client/strings/hr.json | 5 +++++ client/strings/it.json | 5 +++++ client/strings/lt.json | 5 +++++ client/strings/nl.json | 5 +++++ client/strings/no.json | 5 +++++ client/strings/pl.json | 5 +++++ client/strings/ru.json | 5 +++++ client/strings/zh-cn.json | 5 +++++ 18 files changed, 83 insertions(+), 8 deletions(-) diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 879d50a3..92599c7a 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -186,7 +186,7 @@ export default { methods: { requestBatchQuickEmbed() { const payload = { - message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?', + message: this.$strings.MessageConfirmQuickEmbed, callback: (confirmed) => { if (confirmed) { this.$axios @@ -219,7 +219,7 @@ export default { }, async batchRescan() { const payload = { - message: `Are you sure you want to re-scan ${this.selectedMediaItems.length} items?`, + message: this.$getString('MessageConfirmReScanLibraryItems', [this.selectedMediaItems.length]), callback: (confirmed) => { if (confirmed) { this.$axios @@ -316,8 +316,8 @@ export default { }, batchDeleteClick() { const payload = { - message: `This will delete ${this.numMediaItemsSelected} library items from the database and your file system. Are you sure?`, - checkboxLabel: 'Delete from file system. Uncheck to only remove from database.', + message: this.$getString('MessageConfirmDeleteLibraryItems', [this.numMediaItemsSelected]), + checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox, yesButtonText: this.$strings.ButtonDelete, yesButtonColor: 'error', checkboxDefaultValue: true, diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index d51ed208..d3f956ec 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -843,8 +843,8 @@ export default { }, deleteLibraryItem() { const payload = { - message: 'This will delete the library item from the database and your file system. Are you sure?', - checkboxLabel: 'Delete from file system. Uncheck to only remove from database.', + message: this.$strings.MessageConfirmDeleteLibraryItem, + checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox, yesButtonText: this.$strings.ButtonDelete, yesButtonColor: 'error', checkboxDefaultValue: true, diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 7e0bc3dd..0f4f17b2 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -682,8 +682,8 @@ export default { }, deleteLibraryItem() { const payload = { - message: 'This will delete the library item from the database and your file system. Are you sure?', - checkboxLabel: 'Delete from file system. Uncheck to only remove from database.', + message: this.$strings.MessageConfirmDeleteLibraryItem, + checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox, yesButtonText: this.$strings.ButtonDelete, yesButtonColor: 'error', checkboxDefaultValue: true, diff --git a/client/strings/da.json b/client/strings/da.json index 359ecdd6..905beb26 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -218,6 +218,7 @@ "LabelCurrently": "Aktuelt:", "LabelCustomCronExpression": "Brugerdefineret Cron Udtryk:", "LabelDatetime": "Dato og Tid", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Beskrivelse", "LabelDeselectAll": "Fravælg Alle", "LabelDevice": "Enheds", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Er du sikker på, at du vil slette backup for {0}?", "MessageConfirmDeleteFile": "Dette vil slette filen fra dit filsystem. Er du sikker?", "MessageConfirmDeleteLibrary": "Er du sikker på, at du vil slette biblioteket permanent \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Er du sikker på, at du vil slette denne session?", "MessageConfirmForceReScan": "Er du sikker på, at du vil tvinge en genindlæsning?", "MessageConfirmMarkAllEpisodesFinished": "Er du sikker på, at du vil markere alle episoder som afsluttet?", "MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på, at du vil markere alle episoder som ikke afsluttet?", "MessageConfirmMarkSeriesFinished": "Er du sikker på, at du vil markere alle bøger i denne serie som afsluttet?", "MessageConfirmMarkSeriesNotFinished": "Er du sikker på, at du vil markere alle bøger i denne serie som ikke afsluttet?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Er du sikker på, at du vil fjerne alle kapitler?", "MessageConfirmRemoveAuthor": "Er du sikker på, at du vil fjerne forfatteren \"{0}\"?", "MessageConfirmRemoveCollection": "Er du sikker på, at du vil fjerne samlingen \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Er du sikker på, at du vil omdøbe tag \"{0}\" til \"{1}\" for alle elementer?", "MessageConfirmRenameTagMergeNote": "Bemærk: Dette tag findes allerede, så de vil blive fusioneret.", "MessageConfirmRenameTagWarning": "Advarsel! Et lignende tag med en anden skrivemåde eksisterer allerede \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Er du sikker på, at du vil sende {0} e-bog \"{1}\" til enhed \"{2}\"?", "MessageDownloadingEpisode": "Downloader episode", "MessageDragFilesIntoTrackOrder": "Træk filer ind i korrekt spororden", diff --git a/client/strings/de.json b/client/strings/de.json index 50d0dbeb..7bcc2df9 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -218,6 +218,7 @@ "LabelCurrently": "Aktuell:", "LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck", "LabelDatetime": "Datum & Uhrzeit", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Beschreibung", "LabelDeselectAll": "Alles abwählen", "LabelDevice": "Gerät", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?", "MessageConfirmDeleteFile": "Es wird die Datei vom System löschen. Sind Sie sicher?", "MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?", "MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?", "MessageConfirmMarkAllEpisodesFinished": "Sind Sie sicher, dass Sie alle Episoden als abgeschlossen markieren möchten?", "MessageConfirmMarkAllEpisodesNotFinished": "Sind Sie sicher, dass Sie alle Episoden als nicht abgeschlossen markieren möchten?", "MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?", "MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Sind Sie sicher, dass Sie alle Kapitel entfernen möchten?", "MessageConfirmRemoveAuthor": "Sind Sie sicher, dass Sie den Autor \"{0}\" enfernen möchten?", "MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?", "MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.", "MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Sind Sie sicher, dass sie {0} ebook \"{1}\" auf das Gerät \"{2}\" senden wollen?", "MessageDownloadingEpisode": "Episode herunterladen", "MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 9195265e..6befd10d 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -218,6 +218,7 @@ "LabelCurrently": "Currently:", "LabelCustomCronExpression": "Custom Cron Expression:", "LabelDatetime": "Datetime", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Description", "LabelDeselectAll": "Deselect All", "LabelDevice": "Device", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "MessageDownloadingEpisode": "Downloading episode", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order", diff --git a/client/strings/es.json b/client/strings/es.json index f03c6352..57d09a72 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -218,6 +218,7 @@ "LabelCurrently": "En este momento:", "LabelCustomCronExpression": "Expresión de Cron Personalizada:", "LabelDatetime": "Hora y Fecha", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Descripción", "LabelDeselectAll": "Deseleccionar Todos", "LabelDevice": "Dispositivo", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "¿Está seguro de que desea eliminar el respaldo {0}?", "MessageConfirmDeleteFile": "Esto eliminará el archivo de su sistema de archivos. ¿Está seguro?", "MessageConfirmDeleteLibrary": "¿Está seguro de que desea eliminar permanentemente la biblioteca \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "¿Está seguro de que desea eliminar esta sesión?", "MessageConfirmForceReScan": "¿Está seguro de que desea forzar un re-escaneo?", "MessageConfirmMarkAllEpisodesFinished": "¿Está seguro de que desea marcar todos los episodios como terminados?", "MessageConfirmMarkAllEpisodesNotFinished": "¿Está seguro de que desea marcar todos los episodios como no terminados?", "MessageConfirmMarkSeriesFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como terminados?", "MessageConfirmMarkSeriesNotFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como no terminados?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "¿Está seguro de que desea remover todos los capitulos?", "MessageConfirmRemoveAuthor": "¿Está seguro de que desea remover el autor \"{0}\"?", "MessageConfirmRemoveCollection": "¿Está seguro de que desea remover la colección \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "¿Está seguro de que desea renombrar la etiqueta \"{0}\" a \"{1}\" de todos los elementos?", "MessageConfirmRenameTagMergeNote": "Nota: Esta etiqueta ya existe, por lo que se fusionarán.", "MessageConfirmRenameTagWarning": "Advertencia! Una etiqueta similar ya existe \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?", "MessageDownloadingEpisode": "Descargando Capitulo", "MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas.", diff --git a/client/strings/fr.json b/client/strings/fr.json index 031462b2..25b3261e 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -218,6 +218,7 @@ "LabelCurrently": "En ce moment :", "LabelCustomCronExpression": "Expression cron personnalisée:", "LabelDatetime": "Datetime", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Description", "LabelDeselectAll": "Tout déselectionner", "LabelDevice": "Appareil", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la sauvegarde de « {0} » ?", "MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?", "MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?", "MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une analyse forcée ?", "MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?", "MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr de vouloir marquer tous les épisodes comme non terminés ?", "MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme terminées ?", "MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme comme non terminés ?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les articles ?", "MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.", "MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer le livre numérique {0} « {1} » à l’appareil « {2} »?", "MessageDownloadingEpisode": "Téléchargement de l’épisode", "MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l’ordre correct", diff --git a/client/strings/gu.json b/client/strings/gu.json index 0803ccf4..7716773d 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -218,6 +218,7 @@ "LabelCurrently": "Currently:", "LabelCustomCronExpression": "Custom Cron Expression:", "LabelDatetime": "Datetime", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Description", "LabelDeselectAll": "Deselect All", "LabelDevice": "Device", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "MessageDownloadingEpisode": "Downloading episode", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order", diff --git a/client/strings/hi.json b/client/strings/hi.json index 1eea8495..3cc25ae6 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -218,6 +218,7 @@ "LabelCurrently": "Currently:", "LabelCustomCronExpression": "Custom Cron Expression:", "LabelDatetime": "Datetime", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Description", "LabelDeselectAll": "Deselect All", "LabelDevice": "Device", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "MessageDownloadingEpisode": "Downloading episode", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order", diff --git a/client/strings/hr.json b/client/strings/hr.json index 47908b18..1a97f0f4 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -218,6 +218,7 @@ "LabelCurrently": "Trenutno:", "LabelCustomCronExpression": "Custom Cron Expression:", "LabelDatetime": "Datetime", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Opis", "LabelDeselectAll": "Odznači sve", "LabelDevice": "Uređaj", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Jeste li sigurni da želite obrisati ovu sesiju?", "MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?", "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "MessageDownloadingEpisode": "Preuzimam epizodu", "MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.", diff --git a/client/strings/it.json b/client/strings/it.json index b60a87c1..003e167c 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -218,6 +218,7 @@ "LabelCurrently": "Attualmente:", "LabelCustomCronExpression": "Custom Cron Expression:", "LabelDatetime": "Data & Ora", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Descrizione", "LabelDeselectAll": "Deseleziona Tutto", "LabelDevice": "Dispositivo", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?", "MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?", "MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?", "MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?", "MessageConfirmMarkAllEpisodesFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come finiti?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?", "MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Sei sicuro di voler rimuovere tutti i capitoli?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?", "MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.", "MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Sei sicuro di voler inviare {0} ebook \"{1}\" al Device \"{2}\"?", "MessageDownloadingEpisode": "Download episodio in corso", "MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto", diff --git a/client/strings/lt.json b/client/strings/lt.json index 31d259e6..3266e978 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -218,6 +218,7 @@ "LabelCurrently": "Šiuo metu:", "LabelCustomCronExpression": "Nestandartinė Cron išraiška:", "LabelDatetime": "Data ir laikas", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Aprašymas", "LabelDeselectAll": "Išvalyti pasirinktus", "LabelDevice": "Įrenginys", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Ar tikrai norite ištrinti atsarginę kopiją, skirtą {0}?", "MessageConfirmDeleteFile": "Tai ištrins failą iš jūsų failų sistemos. Ar tikrai?", "MessageConfirmDeleteLibrary": "Ar tikrai norite visam laikui ištrinti biblioteką \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Ar tikrai norite ištrinti šią sesiją?", "MessageConfirmForceReScan": "Ar tikrai norite priversti perskenavimą?", "MessageConfirmMarkAllEpisodesFinished": "Ar tikrai norite pažymėti visus epizodus kaip užbaigtus?", "MessageConfirmMarkAllEpisodesNotFinished": "Ar tikrai norite pažymėti visus epizodus kaip nebaigtus?", "MessageConfirmMarkSeriesFinished": "Ar tikrai norite pažymėti visas knygas šioje serijoje kaip užbaigtas?", "MessageConfirmMarkSeriesNotFinished": "Ar tikrai norite pažymėti visas knygas šioje serijoje kaip nebaigtas?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Ar tikrai norite pašalinti visus skyrius?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Ar tikrai norite pašalinti kolekciją \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Ar tikrai norite pervadinti žymą \"{0}\" į \"{1}\" visiems elementams?", "MessageConfirmRenameTagMergeNote": "Pastaba: ši žyma jau egzistuoja, todėl jos bus sujungtos.", "MessageConfirmRenameTagWarning": "Įspėjimas! Panaši žyma jau egzistuoja \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Ar tikrai norite nusiųsti {0} el. knygą \"{1}\" į įrenginį \"{2}\"?", "MessageDownloadingEpisode": "Epizodas atsisiunčiamas", "MessageDragFilesIntoTrackOrder": "Surikiuokite takelius vilkdami failus", diff --git a/client/strings/nl.json b/client/strings/nl.json index eb6b35b3..da0b8046 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -218,6 +218,7 @@ "LabelCurrently": "Op dit moment:", "LabelCustomCronExpression": "Aangepaste Cron-uitdrukking:", "LabelDatetime": "Datum-tijd", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Beschrijving", "LabelDeselectAll": "Deselecteer alle", "LabelDevice": "Apparaat", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?", "MessageConfirmDeleteFile": "Dit verwijdert het bestand uit het bestandssysteem. Weet je het zeker?", "MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Weet je zeker dat je deze sessie wil verwijderen?", "MessageConfirmForceReScan": "Weet je zeker dat je geforceerd opnieuw wil scannen?", "MessageConfirmMarkAllEpisodesFinished": "Weet je zeker dat je alle afleveringen als voltooid wil markeren?", "MessageConfirmMarkAllEpisodesNotFinished": "Weet je zeker dat je alle afleveringen als niet-voltooid wil markeren?", "MessageConfirmMarkSeriesFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als voltooid?", "MessageConfirmMarkSeriesNotFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als niet voltooid?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Weet je zeker dat je alle hoofdstukken wil verwijderen?", "MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?", "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Weet je zeker dat je tag \"{0}\" wil hernoemen naar\"{1}\" voor alle onderdelen?", "MessageConfirmRenameTagMergeNote": "Opmerking: Deze tag bestaat al, dus zullen ze worden samengevoegd.", "MessageConfirmRenameTagWarning": "Waarschuwing! Een gelijknamige tag met ander hoofdlettergebruik bestaat al: \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Weet je zeker dat je {0} ebook \"{1}\" naar apparaat \"{2}\" wil sturen?", "MessageDownloadingEpisode": "Aflevering aan het dowloaden", "MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde", diff --git a/client/strings/no.json b/client/strings/no.json index f4fe316c..90e8758f 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -218,6 +218,7 @@ "LabelCurrently": "Nåværende:", "LabelCustomCronExpression": "Tilpasset Cron utrykk:", "LabelDatetime": "Dato tid", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Beskrivelse", "LabelDeselectAll": "Fjern valg", "LabelDevice": "Enhet", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Er du sikker på at du vil slette sikkerhetskopi for {0}?", "MessageConfirmDeleteFile": "Dette vil slette filen fra filsystemet. Er du sikker?", "MessageConfirmDeleteLibrary": "Er du sikker på at du vil slette biblioteket \"{0}\" for godt?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Er du sikker på at du vil slette denne sesjonen?", "MessageConfirmForceReScan": "Er du sikker på at du vil tvinge en ny skann?", "MessageConfirmMarkAllEpisodesFinished": "Er du sikker på at du vil markere alle episodene som fullført?", "MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på at du vil markere alle episodene som ikke fullført?", "MessageConfirmMarkSeriesFinished": "Er du sikker på at du vil markere alle bøkene i serien som fullført?", "MessageConfirmMarkSeriesNotFinished": "Er du sikker på at du vil markere alle bøkene i serien som ikke fullført?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Er du sikker på at du vil fjerne alle kapitler?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Er du sikker på at du vil endre tag \"{0}\" til \"{1}\" for alle gjenstandene?", "MessageConfirmRenameTagMergeNote": "Notis: Denne taggen finnes allerede så de vil bli slått sammen.", "MessageConfirmRenameTagWarning": "Advarsel! En lignende tag eksisterer allerede (med forsjellige store / små bokstaver) \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Er du sikker på at du vil sende {0} ebok \"{1}\" til enhet \"{2}\"?", "MessageDownloadingEpisode": "Laster ned episode", "MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge", diff --git a/client/strings/pl.json b/client/strings/pl.json index a645877b..82167c08 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -218,6 +218,7 @@ "LabelCurrently": "Obecnie:", "LabelCustomCronExpression": "Custom Cron Expression:", "LabelDatetime": "Data i godzina", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Opis", "LabelDeselectAll": "Odznacz wszystko", "LabelDevice": "Urządzenie", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?", "MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?", "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "MessageDownloadingEpisode": "Pobieranie odcinka", "MessageDragFilesIntoTrackOrder": "przeciągnij pliki aby ustawić właściwą kolejność utworów", diff --git a/client/strings/ru.json b/client/strings/ru.json index f7f56965..d4d258d3 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -218,6 +218,7 @@ "LabelCurrently": "Текущее:", "LabelCustomCronExpression": "Пользовательское выражение Cron:", "LabelDatetime": "Дата и время", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Описание", "LabelDeselectAll": "Снять выделение", "LabelDevice": "Устройство", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?", "MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?", "MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?", "MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?", "MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все эпизоды как завершенные?", "MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все эпизоды как не завершенные?", "MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как завершенные?", "MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как не завершенные?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?", "MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.", "MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?", "MessageDownloadingEpisode": "Эпизод скачивается", "MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index f5a32ff4..b76e7949 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -218,6 +218,7 @@ "LabelCurrently": "当前:", "LabelCustomCronExpression": "自定义计划任务表达式:", "LabelDatetime": "日期时间", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "描述", "LabelDeselectAll": "全部取消选择", "LabelDevice": "设备", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "你确定要删除此会话吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?", "MessageConfirmMarkAllEpisodesFinished": "你确定要将所有剧集都标记为已完成吗?", "MessageConfirmMarkAllEpisodesNotFinished": "你确定要将所有剧集都标记为未完成吗?", "MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?", "MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?", "MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?", "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?", "MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.", "MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?", "MessageDownloadingEpisode": "正在下载剧集", "MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序", From 6f653502694717644f9d6fe4c6bdb3d582186b17 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 20 Oct 2023 16:39:32 -0500 Subject: [PATCH 50/84] Update:JSDocs for task manager --- server/Server.js | 8 +++---- server/controllers/MiscController.js | 4 +++- server/managers/AbMergeManager.js | 9 ++++--- server/managers/AudioMetadataManager.js | 10 ++++---- server/managers/PodcastManager.js | 9 +++---- server/managers/TaskManager.js | 14 ++++++++++- server/objects/Task.js | 32 ++++++++++++++++++++++++- server/routers/ApiRouter.js | 1 - 8 files changed, 64 insertions(+), 23 deletions(-) diff --git a/server/Server.js b/server/Server.js index 36780df4..f5bb85a0 100644 --- a/server/Server.js +++ b/server/Server.js @@ -31,7 +31,6 @@ const PodcastManager = require('./managers/PodcastManager') const AudioMetadataMangaer = require('./managers/AudioMetadataManager') const RssFeedManager = require('./managers/RssFeedManager') const CronManager = require('./managers/CronManager') -const TaskManager = require('./managers/TaskManager') const LibraryScanner = require('./scanner/LibraryScanner') class Server { @@ -58,15 +57,14 @@ class Server { this.auth = new Auth() // Managers - this.taskManager = new TaskManager() this.notificationManager = new NotificationManager() this.emailManager = new EmailManager() this.backupManager = new BackupManager() this.logManager = new LogManager() - this.abMergeManager = new AbMergeManager(this.taskManager) + this.abMergeManager = new AbMergeManager() this.playbackSessionManager = new PlaybackSessionManager() - this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager) - this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager) + this.podcastManager = new PodcastManager(this.watcher, this.notificationManager) + this.audioMetadataManager = new AudioMetadataMangaer() this.rssFeedManager = new RssFeedManager() this.cronManager = new CronManager(this.podcastManager) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 0fa1c62f..ffa4e2c2 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -9,6 +9,8 @@ const libraryItemFilters = require('../utils/queries/libraryItemFilters') const patternValidation = require('../libs/nodeCron/pattern-validation') const { isObject, getTitleIgnorePrefix } = require('../utils/index') +const TaskManager = require('../managers/TaskManager') + // // This is a controller for routes that don't have a home yet :( // @@ -102,7 +104,7 @@ class MiscController { const includeArray = (req.query.include || '').split(',') const data = { - tasks: this.taskManager.tasks.map(t => t.toJSON()) + tasks: TaskManager.tasks.map(t => t.toJSON()) } if (includeArray.includes('queue')) { diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js index 4ced1390..8a87df2e 100644 --- a/server/managers/AbMergeManager.js +++ b/server/managers/AbMergeManager.js @@ -4,14 +4,13 @@ const fs = require('../libs/fsExtra') const workerThreads = require('worker_threads') const Logger = require('../Logger') +const TaskManager = require('./TaskManager') const Task = require('../objects/Task') const { writeConcatFile } = require('../utils/ffmpegHelpers') const toneHelpers = require('../utils/toneHelpers') class AbMergeManager { - constructor(taskManager) { - this.taskManager = taskManager - + constructor() { this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items') this.pendingTasks = [] @@ -45,7 +44,7 @@ class AbMergeManager { } const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.` task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData) - this.taskManager.addTask(task) + TaskManager.addTask(task) Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`) if (!await fs.pathExists(taskData.itemCachePath)) { @@ -234,7 +233,7 @@ class AbMergeManager { } } - this.taskManager.taskFinished(task) + TaskManager.taskFinished(task) } } module.exports = AbMergeManager diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index 2f74bcbe..11c82822 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -7,12 +7,12 @@ const fs = require('../libs/fsExtra') const toneHelpers = require('../utils/toneHelpers') +const TaskManager = require('./TaskManager') + const Task = require('../objects/Task') class AudioMetadataMangaer { - constructor(taskManager) { - this.taskManager = taskManager - + constructor() { this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items') this.MAX_CONCURRENT_TASKS = 1 @@ -101,7 +101,7 @@ class AudioMetadataMangaer { async runMetadataEmbed(task) { this.tasksRunning.push(task) - this.taskManager.addTask(task) + TaskManager.addTask(task) Logger.info(`[AudioMetadataManager] Starting metadata embed task`, task.description) @@ -176,7 +176,7 @@ class AudioMetadataMangaer { } handleTaskFinished(task) { - this.taskManager.taskFinished(task) + TaskManager.taskFinished(task) this.tasksRunning = this.tasksRunning.filter(t => t.id !== task.id) if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) { diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index b88a38af..7b6ff845 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -12,6 +12,8 @@ const opmlGenerator = require('../utils/generators/opmlGenerator') const prober = require('../utils/prober') const ffmpegHelpers = require('../utils/ffmpegHelpers') +const TaskManager = require('./TaskManager') + const LibraryFile = require('../objects/files/LibraryFile') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') const PodcastEpisode = require('../objects/entities/PodcastEpisode') @@ -19,10 +21,9 @@ const AudioFile = require('../objects/files/AudioFile') const Task = require("../objects/Task") class PodcastManager { - constructor(watcher, notificationManager, taskManager) { + constructor(watcher, notificationManager) { this.watcher = watcher this.notificationManager = notificationManager - this.taskManager = taskManager this.downloadQueue = [] this.currentDownload = null @@ -76,7 +77,7 @@ class PodcastManager { libraryItemId: podcastEpisodeDownload.libraryItemId, } task.setData('download-podcast-episode', 'Downloading Episode', taskDescription, false, taskData) - this.taskManager.addTask(task) + TaskManager.addTask(task) SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient()) this.currentDownload = podcastEpisodeDownload @@ -128,7 +129,7 @@ class PodcastManager { this.currentDownload.setFinished(false) } - this.taskManager.taskFinished(task) + TaskManager.taskFinished(task) SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient()) SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) diff --git a/server/managers/TaskManager.js b/server/managers/TaskManager.js index 38e8b580..747ded08 100644 --- a/server/managers/TaskManager.js +++ b/server/managers/TaskManager.js @@ -1,15 +1,27 @@ const SocketAuthority = require('../SocketAuthority') +const Task = require('../objects/Task') class TaskManager { constructor() { + /** @type {Task[]} */ this.tasks = [] } + /** + * Add task and emit socket task_started event + * + * @param {Task} task + */ addTask(task) { this.tasks.push(task) SocketAuthority.emitter('task_started', task.toJSON()) } + /** + * Remove task and emit task_finished event + * + * @param {Task} task + */ taskFinished(task) { if (this.tasks.some(t => t.id === task.id)) { this.tasks = this.tasks.filter(t => t.id !== task.id) @@ -17,4 +29,4 @@ class TaskManager { } } } -module.exports = TaskManager \ No newline at end of file +module.exports = new TaskManager() \ No newline at end of file diff --git a/server/objects/Task.js b/server/objects/Task.js index 04c83d17..db7e490e 100644 --- a/server/objects/Task.js +++ b/server/objects/Task.js @@ -2,19 +2,30 @@ const uuidv4 = require("uuid").v4 class Task { constructor() { + /** @type {string} */ this.id = null + /** @type {string} */ this.action = null // e.g. embed-metadata, encode-m4b, etc + /** @type {Object} custom data */ this.data = null // additional info for the action like libraryItemId + /** @type {string} */ this.title = null + /** @type {string} */ this.description = null + /** @type {string} */ this.error = null - this.showSuccess = false // If true client side should keep the task visible after success + /** @type {boolean} client should keep the task visible after success */ + this.showSuccess = false + /** @type {boolean} */ this.isFailed = false + /** @type {boolean} */ this.isFinished = false + /** @type {number} */ this.startedAt = null + /** @type {number} */ this.finishedAt = null } @@ -34,6 +45,15 @@ class Task { } } + /** + * Set initial task data + * + * @param {string} action + * @param {string} title + * @param {string} description + * @param {boolean} showSuccess + * @param {Object} [data] + */ setData(action, title, description, showSuccess, data = {}) { this.id = uuidv4() this.action = action @@ -44,6 +64,11 @@ class Task { this.startedAt = Date.now() } + /** + * Set task as failed + * + * @param {string} message error message + */ setFailed(message) { this.error = message this.isFailed = true @@ -51,6 +76,11 @@ class Task { this.setFinished() } + /** + * Set task as finished + * + * @param {string} [newDescription] update description + */ setFinished(newDescription = null) { if (newDescription) { this.description = newDescription diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 034951df..b40c3d80 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -46,7 +46,6 @@ class ApiRouter { this.cronManager = Server.cronManager this.notificationManager = Server.notificationManager this.emailManager = Server.emailManager - this.taskManager = Server.taskManager this.router = express() this.router.disable('x-powered-by') From bef6549805eba03afd6d68887f4614939a876474 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 20 Oct 2023 17:46:18 -0500 Subject: [PATCH 51/84] Update:Replace library scan toast with task manager #1279 --- .../components/cards/ItemTaskRunningCard.vue | 23 +++++++++++++++---- client/layouts/default.vue | 7 ------ server/managers/PodcastManager.js | 5 +--- server/managers/TaskManager.js | 16 +++++++++++++ server/scanner/LibraryScan.js | 11 ++++++++- server/scanner/LibraryScanner.js | 15 +++++++++--- server/utils/index.js | 3 +++ 7 files changed, 61 insertions(+), 19 deletions(-) diff --git a/client/components/cards/ItemTaskRunningCard.vue b/client/components/cards/ItemTaskRunningCard.vue index 63a3644d..c9de1a87 100644 --- a/client/components/cards/ItemTaskRunningCard.vue +++ b/client/components/cards/ItemTaskRunningCard.vue @@ -1,10 +1,8 @@ <template> <div class="flex items-center px-1 overflow-hidden"> <div class="w-8 flex items-center justify-center"> - <!-- <div class="text-lg"> --> <span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span> <widgets-loading-spinner v-else /> - <!-- </div> --> </div> <div class="flex-grow px-2 taskRunningCardContent"> <p class="truncate text-sm">{{ title }}</p> @@ -12,7 +10,9 @@ <p class="truncate text-xs text-gray-300">{{ description }}</p> <p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p> + <p v-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p> </div> + <ui-btn v-if="userIsAdminOrUp && !isFinished && action === 'library-scan' && !cancelingScan" color="primary" :padding-y="1" :padding-x="1" class="text-xs w-16 max-w-16 truncate mr-1" @click.stop="cancelScan">{{ this.$strings.ButtonCancel }}</ui-btn> </div> </template> @@ -25,9 +25,14 @@ export default { } }, data() { - return {} + return { + cancelingScan: false + } }, computed: { + userIsAdminOrUp() { + return this.$store.getters['user/getIsAdminOrUp'] + }, title() { return this.task.title || 'No Title' }, @@ -78,7 +83,17 @@ export default { return '' } }, - methods: {}, + methods: { + cancelScan() { + const libraryId = this.task?.data?.libraryId + if (!libraryId) { + console.error('No library id in library-scan task', this.task) + return + } + this.cancelingScan = true + this.$root.socket.emit('cancel_scan', libraryId) + } + }, mounted() {} } </script> diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 2817f23f..5ff34439 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -123,13 +123,6 @@ export default { init(payload) { console.log('Init Payload', payload) - // Start scans currently running - if (payload.librariesScanning) { - payload.librariesScanning.forEach((libraryScan) => { - this.scanStart(libraryScan) - }) - } - // Remove any current scans that are no longer running var currentScans = [...this.$store.state.scanners.libraryScans] currentScans.forEach((ls) => { diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 7b6ff845..6e91a0aa 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -18,7 +18,6 @@ const LibraryFile = require('../objects/files/LibraryFile') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') const PodcastEpisode = require('../objects/entities/PodcastEpisode') const AudioFile = require('../objects/files/AudioFile') -const Task = require("../objects/Task") class PodcastManager { constructor(watcher, notificationManager) { @@ -70,14 +69,12 @@ class PodcastManager { return } - const task = new Task() const taskDescription = `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".` const taskData = { libraryId: podcastEpisodeDownload.libraryId, libraryItemId: podcastEpisodeDownload.libraryItemId, } - task.setData('download-podcast-episode', 'Downloading Episode', taskDescription, false, taskData) - TaskManager.addTask(task) + const task = TaskManager.createAndAddTask('download-podcast-episode', 'Downloading Episode', taskDescription, false, taskData) SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient()) this.currentDownload = podcastEpisodeDownload diff --git a/server/managers/TaskManager.js b/server/managers/TaskManager.js index 747ded08..31cf06a1 100644 --- a/server/managers/TaskManager.js +++ b/server/managers/TaskManager.js @@ -28,5 +28,21 @@ class TaskManager { SocketAuthority.emitter('task_finished', task.toJSON()) } } + + /** + * Create new task and add + * + * @param {string} action + * @param {string} title + * @param {string} description + * @param {boolean} showSuccess + * @param {Object} [data] + */ + createAndAddTask(action, title, description, showSuccess, data = {}) { + const task = new Task() + task.setData(action, title, description, showSuccess, data) + this.addTask(task) + return task + } } module.exports = new TaskManager() \ No newline at end of file diff --git a/server/scanner/LibraryScan.js b/server/scanner/LibraryScan.js index 88562e2c..1dc945fb 100644 --- a/server/scanner/LibraryScan.js +++ b/server/scanner/LibraryScan.js @@ -6,7 +6,7 @@ const date = require('../libs/dateAndTime') const Logger = require('../Logger') const Library = require('../objects/Library') const { LogLevel } = require('../utils/constants') -const { secondsToTimestamp } = require('../utils/index') +const { secondsToTimestamp, elapsedPretty } = require('../utils/index') class LibraryScan { constructor() { @@ -67,6 +67,15 @@ class LibraryScan { get logFilename() { return date.format(new Date(), 'YYYY-MM-DD') + '_' + this.id + '.txt' } + get scanResultsString() { + if (this.error) return this.error + const strs = [] + if (this.resultsAdded) strs.push(`${this.resultsAdded} added`) + if (this.resultsUpdated) strs.push(`${this.resultsUpdated} updated`) + if (this.resultsMissing) strs.push(`${this.resultsMissing} missing`) + if (!strs.length) return `Everything was up to date (${elapsedPretty(this.elapsed / 1000)})` + return strs.join(', ') + ` (${elapsedPretty(this.elapsed / 1000)})` + } toJSON() { return { diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 44ccdd05..b7f9093e 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -9,6 +9,7 @@ const fileUtils = require('../utils/fileUtils') const scanUtils = require('../utils/scandir') const { LogLevel, ScanResult } = require('../utils/constants') const libraryFilters = require('../utils/queries/libraryFilters') +const TaskManager = require('../managers/TaskManager') const LibraryItemScanner = require('./LibraryItemScanner') const LibraryScan = require('./LibraryScan') const LibraryItemScanData = require('./LibraryItemScanData') @@ -68,7 +69,12 @@ class LibraryScanner { libraryScan.verbose = true this.librariesScanning.push(libraryScan.getScanEmitData) - SocketAuthority.emitter('scan_start', libraryScan.getScanEmitData) + const taskData = { + libraryId: library.id, + libraryName: library.name, + libraryMediaType: library.mediaType + } + const task = TaskManager.createAndAddTask('library-scan', `Scanning "${library.name}" library`, null, true, taskData) Logger.info(`[LibraryScanner] Starting${forceRescan ? ' (forced)' : ''} library scan ${libraryScan.id} for ${libraryScan.libraryName}`) @@ -85,9 +91,11 @@ class LibraryScanner { this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id) if (canceled && !libraryScan.totalResults) { + task.setFinished('Scan canceled') + TaskManager.taskFinished(task) + const emitData = libraryScan.getScanEmitData emitData.results = null - SocketAuthority.emitter('scan_complete', emitData) return } @@ -98,7 +106,8 @@ class LibraryScanner { } await Database.libraryModel.updateFromOld(library) - SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData) + task.setFinished(libraryScan.scanResultsString) + TaskManager.taskFinished(task) if (libraryScan.totalResults) { libraryScan.saveLog() diff --git a/server/utils/index.js b/server/utils/index.js index abcc626c..84167229 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -65,6 +65,9 @@ module.exports.getId = (prepend = '') => { } function elapsedPretty(seconds) { + if (seconds > 0 && seconds < 1) { + return `${Math.floor(seconds * 1000)} ms` + } if (seconds < 60) { return `${Math.floor(seconds)} sec` } From d7264f8c2247d32d8f23cfce2b0d72d3dfdf2725 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 21 Oct 2023 12:25:45 -0500 Subject: [PATCH 52/84] Update watcher scanner to show task notification --- .../components/tables/library/LibraryItem.vue | 2 +- server/Watcher.js | 16 ++++++- server/scanner/LibraryScanner.js | 43 ++++++++++++++++--- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/client/components/tables/library/LibraryItem.vue b/client/components/tables/library/LibraryItem.vue index 25b581c9..8dd3e260 100644 --- a/client/components/tables/library/LibraryItem.vue +++ b/client/components/tables/library/LibraryItem.vue @@ -125,7 +125,7 @@ export default { this.$store .dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force }) .then(() => { - this.$toast.success(this.$strings.ToastLibraryScanStarted) + // this.$toast.success(this.$strings.ToastLibraryScanStarted) }) .catch((error) => { console.error('Failed to start scan', error) diff --git a/server/Watcher.js b/server/Watcher.js index 577460a4..3ce6a5f5 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -3,6 +3,8 @@ 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') const { filePathToPOSIX } = require('./utils/fileUtils') @@ -22,7 +24,10 @@ class FolderWatcher extends EventEmitter { /** @type {PendingFileUpdate[]} */ this.pendingFileUpdates = [] this.pendingDelay = 4000 + /** @type {NodeJS.Timeout} */ this.pendingTimeout = null + /** @type {Task} */ + this.pendingTask = null /** @type {string[]} */ this.ignoreDirs = [] @@ -202,6 +207,13 @@ class FolderWatcher extends EventEmitter { Logger.debug(`[Watcher] Modified file in library "${libwatcher.name}" and folder "${folder.id}" with relPath "${relPath}"`) + if (!this.pendingTask) { + const taskData = { + libraryId, + libraryName: libwatcher.name + } + this.pendingTask = TaskManager.createAndAddTask('watcher-scan', `Scanning file changes in "${libwatcher.name}"`, null, true, taskData) + } this.pendingFileUpdates.push({ path, relPath, @@ -213,8 +225,8 @@ class FolderWatcher extends EventEmitter { // Notify server of update after "pendingDelay" clearTimeout(this.pendingTimeout) this.pendingTimeout = setTimeout(() => { - // this.emit('files', this.pendingFileUpdates) - LibraryScanner.scanFilesChanged(this.pendingFileUpdates) + LibraryScanner.scanFilesChanged(this.pendingFileUpdates, this.pendingTask) + this.pendingTask = null this.pendingFileUpdates = [] }, this.pendingDelay) } diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index b7f9093e..11a88bd4 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -13,6 +13,7 @@ const TaskManager = require('../managers/TaskManager') const LibraryItemScanner = require('./LibraryItemScanner') const LibraryScan = require('./LibraryScan') const LibraryItemScanData = require('./LibraryItemScanData') +const Task = require('../objects/Task') class LibraryScanner { constructor() { @@ -20,7 +21,7 @@ class LibraryScanner { this.librariesScanning = [] this.scanningFilesChanged = false - /** @type {import('../Watcher').PendingFileUpdate[][]} */ + /** @type {[import('../Watcher').PendingFileUpdate[], Task][]} */ this.pendingFileUpdatesToScan = [] } @@ -335,18 +336,25 @@ class LibraryScanner { /** * Scan files changed from Watcher * @param {import('../Watcher').PendingFileUpdate[]} fileUpdates + * @param {Task} pendingTask */ - async scanFilesChanged(fileUpdates) { + async scanFilesChanged(fileUpdates, pendingTask) { if (!fileUpdates?.length) return // If already scanning files from watcher then add these updates to queue if (this.scanningFilesChanged) { - this.pendingFileUpdatesToScan.push(fileUpdates) + this.pendingFileUpdatesToScan.push([fileUpdates, pendingTask]) Logger.debug(`[LibraryScanner] Already scanning files from watcher - file updates pushed to queue (size ${this.pendingFileUpdatesToScan.length})`) return } this.scanningFilesChanged = true + const results = { + added: 0, + updated: 0, + removed: 0 + } + // files grouped by folder const folderGroups = this.getFileUpdatesGrouped(fileUpdates) @@ -377,17 +385,42 @@ class LibraryScanner { const folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup) Logger.debug(`[LibraryScanner] Folder scan results`, folderScanResults) + // Tally results to share with client + let resetFilterData = false + Object.values(folderScanResults).forEach((scanResult) => { + if (scanResult === ScanResult.ADDED) { + resetFilterData = true + results.added++ + } else if (scanResult === ScanResult.REMOVED) { + resetFilterData = true + results.removed++ + } else if (scanResult === ScanResult.UPDATED) { + resetFilterData = true + results.updated++ + } + }) + // If something was updated then reset numIssues filter data for library - if (Object.values(folderScanResults).some(scanResult => scanResult !== ScanResult.NOTHING && scanResult !== ScanResult.UPTODATE)) { + if (resetFilterData) { await Database.resetLibraryIssuesFilterData(libraryId) } } + // Complete task and send results to client + const resultStrs = [] + if (results.added) resultStrs.push(`${results.added} added`) + if (results.updated) resultStrs.push(`${results.updated} updated`) + if (results.removed) resultStrs.push(`${results.removed} missing`) + let scanResultStr = 'Scan finished with no changes' + if (resultStrs.length) scanResultStr = resultStrs.join(', ') + pendingTask.setFinished(scanResultStr) + TaskManager.taskFinished(pendingTask) + this.scanningFilesChanged = false if (this.pendingFileUpdatesToScan.length) { Logger.debug(`[LibraryScanner] File updates finished scanning with more updates in queue (${this.pendingFileUpdatesToScan.length})`) - this.scanFilesChanged(this.pendingFileUpdatesToScan.shift()) + this.scanFilesChanged(...this.pendingFileUpdatesToScan.shift()) } } From 58b9a42c843a9c95de85af40eb8a493f1af0c2a4 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 21 Oct 2023 12:56:35 -0500 Subject: [PATCH 53/84] Add:Scan button on libraries table --- client/components/tables/library/LibraryItem.vue | 16 +++++++++++----- client/layouts/default.vue | 9 --------- client/store/scanners.js | 2 +- client/store/tasks.js | 5 ++++- server/Server.js | 4 ---- server/SocketAuthority.js | 3 +-- 6 files changed, 17 insertions(+), 22 deletions(-) diff --git a/client/components/tables/library/LibraryItem.vue b/client/components/tables/library/LibraryItem.vue index 8dd3e260..6a0cb36f 100644 --- a/client/components/tables/library/LibraryItem.vue +++ b/client/components/tables/library/LibraryItem.vue @@ -1,7 +1,7 @@ <template> <div class="w-full pl-2 pr-4 md:px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false"> <div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" /> - <ui-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" font-size="lg md:text-xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" /> + <ui-library-icon v-if="!isScanning" :icon="library.icon" :size="6" font-size="lg md:text-xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" /> <svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin"> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" /> </svg> @@ -9,11 +9,14 @@ <div class="flex-grow" /> + <!-- Scan button only shown on desktop --> + <ui-btn v-if="!isScanning && !isDeleting" color="bg" class="hidden md:block mx-2 text-xs" :padding-y="1" :padding-x="3" @click.stop="scanBtnClick">{{ this.$strings.ButtonScan }}</ui-btn> + <!-- Desktop context menu icon --> - <ui-context-menu-dropdown v-if="!libraryScan && !isDeleting" :items="contextMenuItems" :icon-class="`text-1.5xl text-gray-${isHovering ? 50 : 400}`" class="!hidden md:!block" @action="contextMenuAction" /> + <ui-context-menu-dropdown v-if="!isScanning && !isDeleting" :items="contextMenuItems" :icon-class="`text-1.5xl text-gray-${isHovering ? 50 : 400}`" class="!hidden md:!block" @action="contextMenuAction" /> <!-- Mobile context menu icon --> - <span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span> + <span v-if="!isScanning && !isDeleting" class="!block md:!hidden material-icons text-xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span> <div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin"> <svg viewBox="0 0 24 24" class="w-6 h-6"> @@ -48,8 +51,8 @@ export default { isHovering() { return this.mouseover && !this.dragging }, - libraryScan() { - return this.$store.getters['scanners/getLibraryScan'](this.library.id) + isScanning() { + return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.library.id) }, mediaType() { return this.library.mediaType @@ -89,6 +92,9 @@ export default { } }, methods: { + scanBtnClick() { + this.scan() + }, contextMenuAction({ action }) { this.showMobileMenu = false if (action === 'edit') { diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 5ff34439..1f2acbd3 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -123,15 +123,6 @@ export default { init(payload) { console.log('Init Payload', payload) - // Remove any current scans that are no longer running - var currentScans = [...this.$store.state.scanners.libraryScans] - currentScans.forEach((ls) => { - if (!payload.librariesScanning || !payload.librariesScanning.find((_ls) => _ls.id === ls.id)) { - this.$toast.dismiss(ls.toastId) - this.$store.commit('scanners/remove', ls) - } - }) - if (payload.usersOnline) { this.$store.commit('users/setUsersOnline', payload.usersOnline) } diff --git a/client/store/scanners.js b/client/store/scanners.js index 9a330f4f..de154c3d 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -84,7 +84,7 @@ export const actions = { export const mutations = { addUpdate(state, data) { - var index = state.libraryScans.findIndex(lib => lib.id === data.id) + const index = state.libraryScans.findIndex(lib => lib.id === data.id) if (index >= 0) { state.libraryScans.splice(index, 1, data) } else { diff --git a/client/store/tasks.js b/client/store/tasks.js index e8422c77..9277d412 100644 --- a/client/store/tasks.js +++ b/client/store/tasks.js @@ -6,7 +6,10 @@ export const state = () => ({ export const getters = { getTasksByLibraryItemId: (state) => (libraryItemId) => { - return state.tasks.filter(t => t.data && t.data.libraryItemId === libraryItemId) + return state.tasks.filter(t => t.data?.libraryItemId === libraryItemId) + }, + getRunningLibraryScanTask: (state) => (libraryId) => { + return state.tasks.find(t => t.data?.libraryId === libraryId && !t.isFinished) } } diff --git a/server/Server.js b/server/Server.js index f5bb85a0..d95bd799 100644 --- a/server/Server.js +++ b/server/Server.js @@ -86,10 +86,6 @@ class Server { LibraryScanner.setCancelLibraryScan(libraryId) } - getLibrariesScanning() { - return LibraryScanner.librariesScanning - } - /** * Initialize database, backups, logs, rss feeds, cron jobs & watcher * Cleanup stale/invalid data diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 86f94d3d..ea84e7df 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -179,8 +179,7 @@ class SocketAuthority { const initialPayload = { userId: client.user.id, - username: client.user.username, - librariesScanning: this.Server.getLibrariesScanning() + username: client.user.username } if (user.isAdminOrUp) { initialPayload.usersOnline = this.getUsersOnline() From 50215dab9a905bbdc2d8dc1ef905c604e936ea66 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 21 Oct 2023 13:00:41 -0500 Subject: [PATCH 54/84] Hide library modal tools tab for new libraries --- client/components/modals/libraries/EditModal.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/components/modals/libraries/EditModal.vue b/client/components/modals/libraries/EditModal.vue index 03b66931..5bcdabed 100644 --- a/client/components/modals/libraries/EditModal.vue +++ b/client/components/modals/libraries/EditModal.vue @@ -88,6 +88,8 @@ export default { component: 'modals-libraries-library-tools' } ].filter((tab) => { + // Do not show tools tab for new libraries + if (tab.id === 'tools' && !this.library) return false return tab.id !== 'scanner' || this.mediaType === 'book' }) }, From 49403771c95e8e1d7e0dce868b0db99197a46ace Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 21 Oct 2023 13:53:00 -0500 Subject: [PATCH 55/84] Update:Quick match all for library to use task instead of toast, remove scan socket events --- .../components/cards/ItemTaskRunningCard.vue | 5 +- .../components/modals/item/tabs/Details.vue | 8 +-- .../tables/library/LibrariesTable.vue | 5 +- client/layouts/default.vue | 53 ------------------- client/store/scanners.js | 25 ++------- client/store/tasks.js | 3 +- server/controllers/LibraryController.js | 7 +++ server/models/LibraryItem.js | 2 +- server/scanner/Scanner.js | 26 +++++++-- 9 files changed, 45 insertions(+), 89 deletions(-) diff --git a/client/components/cards/ItemTaskRunningCard.vue b/client/components/cards/ItemTaskRunningCard.vue index c9de1a87..d284c505 100644 --- a/client/components/cards/ItemTaskRunningCard.vue +++ b/client/components/cards/ItemTaskRunningCard.vue @@ -12,7 +12,7 @@ <p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p> <p v-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p> </div> - <ui-btn v-if="userIsAdminOrUp && !isFinished && action === 'library-scan' && !cancelingScan" color="primary" :padding-y="1" :padding-x="1" class="text-xs w-16 max-w-16 truncate mr-1" @click.stop="cancelScan">{{ this.$strings.ButtonCancel }}</ui-btn> + <ui-btn v-if="userIsAdminOrUp && !isFinished && isLibraryScan && !cancelingScan" color="primary" :padding-y="1" :padding-x="1" class="text-xs w-16 max-w-16 truncate mr-1" @click.stop="cancelScan">{{ this.$strings.ButtonCancel }}</ui-btn> </div> </template> @@ -81,6 +81,9 @@ export default { } return '' + }, + isLibraryScan() { + return this.action === 'library-scan' || this.action === 'library-match-all' } }, methods: { diff --git a/client/components/modals/item/tabs/Details.vue b/client/components/modals/item/tabs/Details.vue index 14fe68a7..62f08c92 100644 --- a/client/components/modals/item/tabs/Details.vue +++ b/client/components/modals/item/tabs/Details.vue @@ -11,8 +11,8 @@ <ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn> </ui-tooltip> - <ui-tooltip :disabled="!!libraryScan" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4"> - <ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn> + <ui-tooltip :disabled="isLibraryScanning" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4"> + <ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="isLibraryScanning" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn> </ui-tooltip> <div class="flex-grow" /> @@ -80,9 +80,9 @@ export default { libraryProvider() { return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google' }, - libraryScan() { + isLibraryScanning() { if (!this.libraryId) return null - return this.$store.getters['scanners/getLibraryScan'](this.libraryId) + return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.libraryId) } }, methods: { diff --git a/client/components/tables/library/LibrariesTable.vue b/client/components/tables/library/LibrariesTable.vue index 598b12b7..faf8d69d 100644 --- a/client/components/tables/library/LibrariesTable.vue +++ b/client/components/tables/library/LibrariesTable.vue @@ -42,13 +42,10 @@ export default { return this.$store.getters['libraries/getCurrentLibrary'] }, currentLibraryId() { - return this.currentLibrary ? this.currentLibrary.id : null + return this.currentLibrary?.id || null }, libraries() { return this.$store.getters['libraries/getSortedLibraries']() - }, - libraryScans() { - return this.$store.state.scanners.libraryScans } }, methods: { diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 1f2acbd3..4f5e0fea 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -212,54 +212,6 @@ export default { this.libraryItemAdded(ab) }) }, - scanComplete(data) { - console.log('Scan complete received', data) - - let message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" complete!` - let toastType = 'success' - if (data.error) { - message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" finished with error:\n${data.error}` - toastType = 'error' - } else if (data.results) { - var scanResultMsgs = [] - var results = data.results - if (results.added) scanResultMsgs.push(`${results.added} added`) - if (results.updated) scanResultMsgs.push(`${results.updated} updated`) - if (results.removed) scanResultMsgs.push(`${results.removed} removed`) - if (results.missing) scanResultMsgs.push(`${results.missing} missing`) - if (!scanResultMsgs.length) message += '\nEverything was up to date' - else message += '\n' + scanResultMsgs.join('\n') - } else { - message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" was canceled` - } - - var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id) - if (existingScan && !isNaN(existingScan.toastId)) { - this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: toastType, closeButton: false, onClose: () => null } }, true) - } else { - this.$toast[toastType](message, { timeout: 5000 }) - } - - this.$store.commit('scanners/remove', data) - }, - onScanToastCancel(id) { - this.$root.socket.emit('cancel_scan', id) - }, - scanStart(data) { - data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) }) - this.$store.commit('scanners/addUpdate', data) - }, - scanProgress(data) { - var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id) - if (existingScan && !isNaN(existingScan.toastId)) { - data.toastId = existingScan.toastId - this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true) - } else { - data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) }) - } - - this.$store.commit('scanners/addUpdate', data) - }, taskStarted(task) { console.log('Task started', task) this.$store.commit('tasks/addUpdateTask', task) @@ -442,11 +394,6 @@ export default { this.socket.on('playlist_updated', this.playlistUpdated) this.socket.on('playlist_removed', this.playlistRemoved) - // Scan Listeners - this.socket.on('scan_start', this.scanStart) - this.socket.on('scan_complete', this.scanComplete) - this.socket.on('scan_progress', this.scanProgress) - // Task Listeners this.socket.on('task_started', this.taskStarted) this.socket.on('task_finished', this.taskFinished) diff --git a/client/store/scanners.js b/client/store/scanners.js index de154c3d..ccdc1791 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -1,5 +1,4 @@ export const state = () => ({ - libraryScans: [], providers: [ { text: 'Google Books', @@ -72,26 +71,8 @@ export const state = () => ({ ] }) -export const getters = { - getLibraryScan: state => id => { - return state.libraryScans.find(ls => ls.id === id) - } -} +export const getters = {} -export const actions = { +export const actions = {} -} - -export const mutations = { - addUpdate(state, data) { - const index = state.libraryScans.findIndex(lib => lib.id === data.id) - if (index >= 0) { - state.libraryScans.splice(index, 1, data) - } else { - state.libraryScans.push(data) - } - }, - remove(state, data) { - state.libraryScans = state.libraryScans.filter(scan => scan.id !== data.id) - } -} \ No newline at end of file +export const mutations = {} \ No newline at end of file diff --git a/client/store/tasks.js b/client/store/tasks.js index 9277d412..96e7e5b8 100644 --- a/client/store/tasks.js +++ b/client/store/tasks.js @@ -9,7 +9,8 @@ export const getters = { return state.tasks.filter(t => t.data?.libraryItemId === libraryItemId) }, getRunningLibraryScanTask: (state) => (libraryId) => { - return state.tasks.find(t => t.data?.libraryId === libraryId && !t.isFinished) + const libraryScanActions = ['library-scan', 'library-match-all'] + return state.tasks.find(t => libraryScanActions.includes(t.action) && t.data?.libraryId === libraryId && !t.isFinished) } } diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 9c593ff2..10a77b2a 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -775,6 +775,13 @@ class LibraryController { }) } + /** + * GET: /api/libraries/:id/matchall + * Quick match all library items. Book libraries only. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async matchAll(req, res) { if (!req.user.isAdminOrUp) { Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index d17dbd15..b6f2f285 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -69,7 +69,7 @@ class LibraryItem extends Model { * * @param {number} offset * @param {number} limit - * @returns {Promise<Model<LibraryItem>[]>} LibraryItem + * @returns {Promise<LibraryItem[]>} LibraryItem */ static getLibraryItemsIncrement(offset, limit, where = null) { return this.findAll({ diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index ebce3607..616baf29 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -12,6 +12,7 @@ const Author = require('../objects/entities/Author') const Series = require('../objects/entities/Series') const LibraryScanner = require('./LibraryScanner') const CoverManager = require('../managers/CoverManager') +const TaskManager = require('../managers/TaskManager') class Scanner { constructor() { } @@ -280,6 +281,14 @@ class Scanner { return false } + /** + * Quick match library items + * + * @param {import('../objects/Library')} library + * @param {import('../objects/LibraryItem')[]} libraryItems + * @param {LibraryScan} libraryScan + * @returns {Promise<boolean>} false if scan canceled + */ async matchLibraryItemsChunk(library, libraryItems, libraryScan) { for (let i = 0; i < libraryItems.length; i++) { const libraryItem = libraryItems[i] @@ -313,6 +322,11 @@ class Scanner { return true } + /** + * Quick match all library items for library + * + * @param {import('../objects/Library')} library + */ async matchLibraryItems(library) { if (library.mediaType === 'podcast') { Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`) @@ -330,11 +344,14 @@ class Scanner { const libraryScan = new LibraryScan() libraryScan.setData(library, 'match') LibraryScanner.librariesScanning.push(libraryScan.getScanEmitData) - SocketAuthority.emitter('scan_start', libraryScan.getScanEmitData) - + const taskData = { + libraryId: library.id + } + const task = TaskManager.createAndAddTask('library-match-all', `Matching books in "${library.name}"`, null, true, taskData) Logger.info(`[Scanner] matchLibraryItems: Starting library match scan ${libraryScan.id} for ${libraryScan.libraryName}`) let hasMoreChunks = true + let isCanceled = false while (hasMoreChunks) { const libraryItems = await Database.libraryItemModel.getLibraryItemsIncrement(offset, limit, { libraryId: library.id }) if (!libraryItems.length) { @@ -347,6 +364,7 @@ class Scanner { const shouldContinue = await this.matchLibraryItemsChunk(library, oldLibraryItems, libraryScan) if (!shouldContinue) { + isCanceled = true break } } @@ -354,13 +372,15 @@ class Scanner { if (offset === 0) { Logger.error(`[Scanner] matchLibraryItems: Library has no items ${library.id}`) libraryScan.setComplete('Library has no items') + task.setFailed(libraryScan.error) } else { libraryScan.setComplete() + task.setFinished(isCanceled ? 'Canceled' : libraryScan.scanResultsString) } delete LibraryScanner.cancelLibraryScan[libraryScan.libraryId] LibraryScanner.librariesScanning = LibraryScanner.librariesScanning.filter(ls => ls.id !== library.id) - SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData) + TaskManager.taskFinished(task) } } module.exports = new Scanner() From 8ecec93e670d31e100c38188ef297d79ed5cf35f Mon Sep 17 00:00:00 2001 From: Hallo951 <40667862+Hallo951@users.noreply.github.com> Date: Sat, 21 Oct 2023 22:33:31 +0200 Subject: [PATCH 56/84] Update de.json --- client/strings/de.json | 56 +++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 7bcc2df9..67dc2930 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -218,7 +218,7 @@ "LabelCurrently": "Aktuell:", "LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck", "LabelDatetime": "Datum & Uhrzeit", - "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", + "LabelDeleteFromFileSystemCheckbox": "Löschen von der Festplatte + Datenbank (deaktivieren um nur aus der Datenbank zu löschen)", "LabelDescription": "Beschreibung", "LabelDeselectAll": "Alles abwählen", "LabelDevice": "Gerät", @@ -519,34 +519,34 @@ "MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)", "MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)", "MessageCheckingCron": "Überprüfe Cron...", - "MessageConfirmCloseFeed": "Sind Sie sicher, dass Sie diesen Feed schließen wollen?", - "MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?", - "MessageConfirmDeleteFile": "Es wird die Datei vom System löschen. Sind Sie sicher?", - "MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?", - "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", - "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", - "MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?", - "MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?", - "MessageConfirmMarkAllEpisodesFinished": "Sind Sie sicher, dass Sie alle Episoden als abgeschlossen markieren möchten?", - "MessageConfirmMarkAllEpisodesNotFinished": "Sind Sie sicher, dass Sie alle Episoden als nicht abgeschlossen markieren möchten?", - "MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?", - "MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?", - "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", - "MessageConfirmRemoveAllChapters": "Sind Sie sicher, dass Sie alle Kapitel entfernen möchten?", - "MessageConfirmRemoveAuthor": "Sind Sie sicher, dass Sie den Autor \"{0}\" enfernen möchten?", - "MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?", - "MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?", - "MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?", - "MessageConfirmRemoveNarrator": "Sind Sie sicher, dass Sie den Erzähler \"{0}\" löschen möchten?", - "MessageConfirmRemovePlaylist": "Sind Sie sicher, dass Sie die Wiedergabeliste \"{0}\" entfernen möchten?", - "MessageConfirmRenameGenre": "Sind Sie sicher, dass Sie die Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?", + "MessageConfirmCloseFeed": "Feed wird geschlossen! Sind Sie sicher?", + "MessageConfirmDeleteBackup": "Sicherung für {0} wird gelöscht! Sind Sie sicher?", + "MessageConfirmDeleteFile": "Datei wird vom System gelöscht! Sind Sie sicher?", + "MessageConfirmDeleteLibrary": "Bibliothek \"{0}\" wird dauerhaft gelöscht! Sind Sie sicher?", + "MessageConfirmDeleteLibraryItem": "Bibliothekselement wird aus der Datenbank + Festplatte gelöscht? Sind Sie sicher?", + "MessageConfirmDeleteLibraryItems": "{0} Bibliothekselemente werden aus der Datenbank + Festplatte gelöscht? Sind Sie sicher?", + "MessageConfirmDeleteSession": "Sitzung wird gelöscht! Sind Sie sicher?", + "MessageConfirmForceReScan": "Scanvorgang erzwingen! Sind Sie sicher?", + "MessageConfirmMarkAllEpisodesFinished": "Alle Episoden werden als abgeschlossen markiert! Sind Sie sicher?", + "MessageConfirmMarkAllEpisodesNotFinished": "Alle Episoden werden als nicht abgeschlossen markiert! Sind Sie sicher?", + "MessageConfirmMarkSeriesFinished": "Alle Medien dieser Reihe werden als abgeschlossen markiert! Sind Sie sicher?", + "MessageConfirmMarkSeriesNotFinished": "Alle Medien dieser Reihe werden als nicht abgeschlossen markiert! Sind Sie sicher?", + "MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achten Sie darauf, dass Sie eine Sicherungskopie der Audiodateien besitzen. <br><br>Möchten Sie fortfahren?", + "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Sind Sie sicher?", + "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Sind Sie sicher?", + "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Sind Sie sicher?", + "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Sind Sie sicher?", + "MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! Sind Sie sicher?", + "MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird gelöscht! Sind Sie sicher?", + "MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Sind Sie sicher?", + "MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Sind Sie sicher?", "MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.", "MessageConfirmRenameGenreWarning": "Warnung! Ein ähnliche Kategorie mit einem anderen Wortlaut existiert bereits: \"{0}\".", - "MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?", + "MessageConfirmRenameTag": "Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Sind Sie sicher?", "MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.", "MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".", - "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", - "MessageConfirmSendEbookToDevice": "Sind Sie sicher, dass sie {0} ebook \"{1}\" auf das Gerät \"{2}\" senden wollen?", + "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Sind Sie sicher?", + "MessageConfirmSendEbookToDevice": "{0} E-Book \"{1}\" werden auf das Gerät \"{2}\" gesendet! Sind Sie sicher?", "MessageDownloadingEpisode": "Episode herunterladen", "MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge", "MessageEmbedFinished": "Einbettung abgeschlossen!", @@ -610,9 +610,9 @@ "MessageRemoveChapter": "Kapitel löschen", "MessageRemoveEpisodes": "Entferne {0} Episode(n)", "MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen", - "MessageRemoveUserWarning": "Sind Sie sicher, dass Sie den Benutzer \"{0}\" dauerhaft löschen wollen?", + "MessageRemoveUserWarning": "Benutzer \"{0}\" wird dauerhaft gelöscht! Sind Sie sicher?", "MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf", - "MessageResetChaptersConfirm": "Sind Sie sicher, dass Sie die Kapitel zurücksetzen und die vorgenommenen Änderungen rückgängig machen wollen?", + "MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Sind Sie sicher?", "MessageRestoreBackupConfirm": "Sind Sie sicher, dass Sie die Sicherung wiederherstellen wollen, welche am", "MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in Ihren Bibliotheksordnern verändert. Wenn Sie die Servereinstellungen aktiviert haben, um Cover und Metadaten in Ihren Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.", "MessageSearchResultsFor": "Suchergebnisse für", @@ -713,4 +713,4 @@ "ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen", "ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden", "ToastUserDeleteSuccess": "Benutzer gelöscht" -} \ No newline at end of file +} From b42edfe7a726ed78f6cd2e4422f09d92183dcd05 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 22 Oct 2023 07:10:52 -0500 Subject: [PATCH 57/84] Book duration shown on match page compares minutes #1803 --- client/components/cards/BookMatchCard.vue | 16 ++++++++-------- client/pages/config/index.vue | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/components/cards/BookMatchCard.vue b/client/components/cards/BookMatchCard.vue index 77619e55..f2c15280 100644 --- a/client/components/cards/BookMatchCard.vue +++ b/client/components/cards/BookMatchCard.vue @@ -70,14 +70,14 @@ export default { return (this.book.duration || 0) * 60 }, bookDurationComparison() { - if (!this.bookDuration || !this.currentBookDuration) return '' - let differenceInSeconds = this.currentBookDuration - this.bookDuration - // Only show seconds on difference if difference is less than an hour - if (differenceInSeconds < 0) { - differenceInSeconds = Math.abs(differenceInSeconds) - return `(${this.$elapsedPrettyExtended(differenceInSeconds, false, differenceInSeconds < 3600)} shorter)` - } else if (differenceInSeconds > 0) { - return `(${this.$elapsedPrettyExtended(differenceInSeconds, false, differenceInSeconds < 3600)} longer)` + if (!this.book.duration || !this.currentBookDuration) return '' + const currentBookDurationMinutes = Math.floor(this.currentBookDuration / 60) + let differenceInMinutes = currentBookDurationMinutes - this.book.duration + if (differenceInMinutes < 0) { + differenceInMinutes = Math.abs(differenceInMinutes) + return `(${this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)} shorter)` + } else if (differenceInMinutes > 0) { + return `(${this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)} longer)` } return '(exact match)' } diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 936f6a30..1721a379 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -244,7 +244,7 @@ export default { value: 'json' }, { - text: '.abs', + text: '.abs (deprecated)', value: 'abs' } ] From ce88c6ccc37362cda4a8c9b79091169b894eaa26 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 22 Oct 2023 12:58:05 -0500 Subject: [PATCH 58/84] Scanner metadata order of precedence description label, link to guide, add translations --- .../modals/libraries/LibraryScannerSettings.vue | 15 ++++++++++++--- client/strings/da.json | 4 ++++ client/strings/de.json | 6 +++++- client/strings/en-us.json | 4 ++++ client/strings/es.json | 4 ++++ client/strings/fr.json | 4 ++++ client/strings/gu.json | 4 ++++ client/strings/hi.json | 4 ++++ client/strings/hr.json | 4 ++++ client/strings/it.json | 4 ++++ client/strings/lt.json | 4 ++++ client/strings/nl.json | 4 ++++ client/strings/no.json | 4 ++++ client/strings/pl.json | 4 ++++ client/strings/ru.json | 4 ++++ client/strings/zh-cn.json | 4 ++++ 16 files changed, 73 insertions(+), 4 deletions(-) diff --git a/client/components/modals/libraries/LibraryScannerSettings.vue b/client/components/modals/libraries/LibraryScannerSettings.vue index 95ae801a..215f79b5 100644 --- a/client/components/modals/libraries/LibraryScannerSettings.vue +++ b/client/components/modals/libraries/LibraryScannerSettings.vue @@ -1,8 +1,17 @@ <template> <div class="w-full h-full px-1 md:px-4 py-1 mb-4"> - <div class="flex items-center justify-between mb-4"> - <h2 class="text-lg text-gray-200">Metadata order of precedence</h2> - <ui-btn small @click="resetToDefault">Reset to default</ui-btn> + <div class="flex items-center justify-between mb-2"> + <h2 class="text-base md:text-lg text-gray-200">{{ $strings.HeaderMetadataOrderOfPrecedence }}</h2> + <ui-btn small @click="resetToDefault">{{ $strings.ButtonResetToDefault }}</ui-btn> + </div> + + <div class="flex items-center justify-between md:justify-start mb-4"> + <p class="text-sm text-gray-300 pr-2">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p> + <ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex"> + <a href="https://www.audiobookshelf.org/guides/book-scanner" target="_blank" class="inline-flex"> + <span class="material-icons text-xl w-5">help_outline</span> + </a> + </ui-tooltip> </div> <draggable v-model="metadataSourceMapped" v-bind="dragOptions" class="list-group" draggable=".item" handle=".drag-handle" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate"> diff --git a/client/strings/da.json b/client/strings/da.json index 905beb26..aa1b66ed 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra Fortsæt Serie", "ButtonReScan": "Gen-scan", "ButtonReset": "Nulstil", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Gendan", "ButtonSave": "Gem", "ButtonSaveAndClose": "Gem & Luk", @@ -122,6 +123,7 @@ "HeaderManageTags": "Administrer Tags", "HeaderMapDetails": "Kort Detaljer", "HeaderMatch": "Match", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata til indlejring", "HeaderNewAccount": "Ny Konto", "HeaderNewLibrary": "Nyt Bibliotek", @@ -200,6 +202,7 @@ "LabelChapters": "Kapitler", "LabelChaptersFound": "fundne kapitler", "LabelChapterTitle": "Kapitel Titel", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Luk afspiller", "LabelCodec": "Codec", "LabelCollapseSeries": "Fold Serie Sammen", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Søg efter nye episoder efter denne dato", "LabelMediaPlayer": "Medieafspiller", "LabelMediaType": "Medietype", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadataudbyder", "LabelMetaTag": "Meta-tag", "LabelMetaTags": "Meta-tags", diff --git a/client/strings/de.json b/client/strings/de.json index 67dc2930..fe1df71c 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste", "ButtonReScan": "Neu scannen", "ButtonReset": "Zurücksetzen", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Wiederherstellen", "ButtonSave": "Speichern", "ButtonSaveAndClose": "Speichern & Schließen", @@ -122,6 +123,7 @@ "HeaderManageTags": "Tags verwalten", "HeaderMapDetails": "Stapelverarbeitung", "HeaderMatch": "Metadaten", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Einzubettende Metadaten", "HeaderNewAccount": "Neues Konto", "HeaderNewLibrary": "Neue Bibliothek", @@ -200,6 +202,7 @@ "LabelChapters": "Kapitel", "LabelChaptersFound": "gefundene Kapitel", "LabelChapterTitle": "Kapitelüberschrift", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Player schließen", "LabelCodec": "Codec", "LabelCollapseSeries": "Serien zusammenfassen", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum", "LabelMediaPlayer": "Mediaplayer", "LabelMediaType": "Medientyp", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadatenanbieter", "LabelMetaTag": "Meta Schlagwort", "LabelMetaTags": "Meta Tags", @@ -713,4 +717,4 @@ "ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen", "ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden", "ToastUserDeleteSuccess": "Benutzer gelöscht" -} +} \ No newline at end of file diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 6befd10d..43f44821 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Remove Series from Continue Series", "ButtonReScan": "Re-Scan", "ButtonReset": "Reset", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Restore", "ButtonSave": "Save", "ButtonSaveAndClose": "Save & Close", @@ -122,6 +123,7 @@ "HeaderManageTags": "Manage Tags", "HeaderMapDetails": "Map details", "HeaderMatch": "Match", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata to embed", "HeaderNewAccount": "New Account", "HeaderNewLibrary": "New Library", @@ -200,6 +202,7 @@ "LabelChapters": "Chapters", "LabelChaptersFound": "chapters found", "LabelChapterTitle": "Chapter Title", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Close player", "LabelCodec": "Codec", "LabelCollapseSeries": "Collapse Series", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/es.json b/client/strings/es.json index 57d09a72..3a6012b6 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Remover Serie de Continuar Series", "ButtonReScan": "Re-Escanear", "ButtonReset": "Reiniciar", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Restaurar", "ButtonSave": "Guardar", "ButtonSaveAndClose": "Guardar y Cerrar", @@ -122,6 +123,7 @@ "HeaderManageTags": "Administrar Etiquetas", "HeaderMapDetails": "Asignar Detalles", "HeaderMatch": "Encontrar", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadatos para Insertar", "HeaderNewAccount": "Nueva Cuenta", "HeaderNewLibrary": "Nueva Biblioteca", @@ -200,6 +202,7 @@ "LabelChapters": "Capítulos", "LabelChaptersFound": "Capítulo Encontrado", "LabelChapterTitle": "Titulo del Capítulo", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Cerrar Reproductor", "LabelCodec": "Codec", "LabelCollapseSeries": "Colapsar Serie", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Buscar Nuevos Episodios a partir de esta Fecha", "LabelMediaPlayer": "Reproductor de Medios", "LabelMediaType": "Tipo de Multimedia", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Proveedor de Metadata", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/fr.json b/client/strings/fr.json index 25b3261e..a78f7d66 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série", "ButtonReScan": "Nouvelle analyse", "ButtonReset": "Réinitialiser", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Rétablir", "ButtonSave": "Sauvegarder", "ButtonSaveAndClose": "Sauvegarder et Fermer", @@ -122,6 +123,7 @@ "HeaderManageTags": "Gérer les étiquettes", "HeaderMapDetails": "Édition en masse", "HeaderMatch": "Chercher", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Métadonnée à intégrer", "HeaderNewAccount": "Nouveau compte", "HeaderNewLibrary": "Nouvelle bibliothèque", @@ -200,6 +202,7 @@ "LabelChapters": "Chapitres", "LabelChaptersFound": "Chapitres trouvés", "LabelChapterTitle": "Titres du chapitre", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Fermer le lecteur", "LabelCodec": "Codec", "LabelCollapseSeries": "Réduire les séries", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date", "LabelMediaPlayer": "Lecteur multimédia", "LabelMediaType": "Type de média", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Fournisseur de métadonnées", "LabelMetaTag": "Etiquette de métadonnée", "LabelMetaTags": "Etiquettes de métadonnée", diff --git a/client/strings/gu.json b/client/strings/gu.json index 7716773d..8b6a963f 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો", "ButtonReScan": "ફરીથી સ્કેન કરો", "ButtonReset": "રીસેટ કરો", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "પુનઃસ્થાપિત કરો", "ButtonSave": "સાચવો", "ButtonSaveAndClose": "સાચવો અને બંધ કરો", @@ -122,6 +123,7 @@ "HeaderManageTags": "Manage Tags", "HeaderMapDetails": "Map details", "HeaderMatch": "Match", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata to embed", "HeaderNewAccount": "New Account", "HeaderNewLibrary": "New Library", @@ -200,6 +202,7 @@ "LabelChapters": "Chapters", "LabelChaptersFound": "chapters found", "LabelChapterTitle": "Chapter Title", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Close player", "LabelCodec": "Codec", "LabelCollapseSeries": "Collapse Series", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/hi.json b/client/strings/hi.json index 3cc25ae6..7c8651e3 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "इस सीरीज को कंटिन्यू सीरीज से हटा दें", "ButtonReScan": "पुन: स्कैन करें", "ButtonReset": "रीसेट करें", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "पुनर्स्थापित करें", "ButtonSave": "सहेजें", "ButtonSaveAndClose": "सहेजें और बंद करें", @@ -122,6 +123,7 @@ "HeaderManageTags": "Manage Tags", "HeaderMapDetails": "Map details", "HeaderMatch": "Match", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata to embed", "HeaderNewAccount": "New Account", "HeaderNewLibrary": "New Library", @@ -200,6 +202,7 @@ "LabelChapters": "Chapters", "LabelChaptersFound": "chapters found", "LabelChapterTitle": "Chapter Title", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Close player", "LabelCodec": "Codec", "LabelCollapseSeries": "Collapse Series", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/hr.json b/client/strings/hr.json index 1a97f0f4..8e3946a5 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Ukloni seriju iz Nastavi seriju", "ButtonReScan": "Skeniraj ponovno", "ButtonReset": "Poništi", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Povrati", "ButtonSave": "Spremi", "ButtonSaveAndClose": "Spremi i zatvori", @@ -122,6 +123,7 @@ "HeaderManageTags": "Manage Tags", "HeaderMapDetails": "Map details", "HeaderMatch": "Match", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metapodatci za ugradnju", "HeaderNewAccount": "Novi korisnički račun", "HeaderNewLibrary": "Nova biblioteka", @@ -200,6 +202,7 @@ "LabelChapters": "Chapters", "LabelChaptersFound": "poglavlja pronađena", "LabelChapterTitle": "Ime poglavlja", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Close player", "LabelCodec": "Codec", "LabelCollapseSeries": "Collapse Series", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Poslužitelj metapodataka ", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/it.json b/client/strings/it.json index 003e167c..e384ea59 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla", "ButtonReScan": "Ri-scansiona", "ButtonReset": "Reset", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Ripristina", "ButtonSave": "Salva", "ButtonSaveAndClose": "Salva & Chiudi", @@ -122,6 +123,7 @@ "HeaderManageTags": "Gestisci Tags", "HeaderMapDetails": "Mappa Dettagli", "HeaderMatch": "Trova Corrispondenza", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata da incorporare", "HeaderNewAccount": "Nuovo Account", "HeaderNewLibrary": "Nuova Libreria", @@ -200,6 +202,7 @@ "LabelChapters": "Capitoli", "LabelChaptersFound": "Capitoli Trovati", "LabelChapterTitle": "Titoli dei Capitoli", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Chiudi player", "LabelCodec": "Codec", "LabelCollapseSeries": "Comprimi Serie", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Tipo Media", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/lt.json b/client/strings/lt.json index 3266e978..c5c937ba 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Pašalinti seriją iš Tęsti Seriją", "ButtonReScan": "Iš naujo nuskaityti", "ButtonReset": "Atstatyti", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Atkurti", "ButtonSave": "Išsaugoti", "ButtonSaveAndClose": "Išsaugoti ir uždaryti", @@ -122,6 +123,7 @@ "HeaderManageTags": "Tvarkyti žymas", "HeaderMapDetails": "Susieti detales", "HeaderMatch": "Atitaikyti", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metaduomenys įterpimui", "HeaderNewAccount": "Nauja paskyra", "HeaderNewLibrary": "Nauja biblioteka", @@ -200,6 +202,7 @@ "LabelChapters": "Skyriai", "LabelChaptersFound": "rasti skyriai", "LabelChapterTitle": "Skyriaus pavadinimas", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Uždaryti grotuvą", "LabelCodec": "Kodekas", "LabelCollapseSeries": "Suskleisti seriją", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Ieškoti naujų epizodų po šios datos", "LabelMediaPlayer": "Grotuvas", "LabelMediaType": "Medijos tipas", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metaduomenų tiekėjas", "LabelMetaTag": "Meta žymė", "LabelMetaTags": "Meta žymos", diff --git a/client/strings/nl.json b/client/strings/nl.json index da0b8046..a2046d57 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Verwijder serie uit Serie vervolgen", "ButtonReScan": "Nieuwe scan", "ButtonReset": "Reset", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Herstel", "ButtonSave": "Opslaan", "ButtonSaveAndClose": "Opslaan & sluiten", @@ -122,6 +123,7 @@ "HeaderManageTags": "Tags beheren", "HeaderMapDetails": "Map details", "HeaderMatch": "Match", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "In te sluiten metadata", "HeaderNewAccount": "Nieuwe account", "HeaderNewLibrary": "Nieuwe bibliotheek", @@ -200,6 +202,7 @@ "LabelChapters": "Hoofdstukken", "LabelChaptersFound": "Hoofdstukken gevonden", "LabelChapterTitle": "Hoofdstuktitel", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Sluit speler", "LabelCodec": "Codec", "LabelCollapseSeries": "Series inklappen", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum", "LabelMediaPlayer": "Mediaspeler", "LabelMediaType": "Mediatype", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadatabron", "LabelMetaTag": "Meta-tag", "LabelMetaTags": "Meta-tags", diff --git a/client/strings/no.json b/client/strings/no.json index 90e8758f..26a282af 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Fjern serie fra Fortsett serie", "ButtonReScan": "Skann på nytt", "ButtonReset": "Nullstill", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Gjenopprett", "ButtonSave": "Lagre", "ButtonSaveAndClose": "Lagre og lukk", @@ -122,6 +123,7 @@ "HeaderManageTags": "Behandle tags", "HeaderMapDetails": "Kartleggingsdetaljer", "HeaderMatch": "Tilpasse", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata å bake inn", "HeaderNewAccount": "Ny konto", "HeaderNewLibrary": "Ny bibliotek", @@ -200,6 +202,7 @@ "LabelChapters": "Kapitler", "LabelChaptersFound": "kapitler funnet", "LabelChapterTitle": "Kapittel tittel", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Lukk spiller", "LabelCodec": "Kodek", "LabelCollapseSeries": "Minimer serier", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Se etter nye episoder etter denne datoen", "LabelMediaPlayer": "Mediespiller", "LabelMediaType": "Medie type", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadata Leverandør", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/pl.json b/client/strings/pl.json index 82167c08..b38406ba 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Usuń serię z listy odtwarzania", "ButtonReScan": "Ponowne skanowanie", "ButtonReset": "Resetowanie", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Przywróć", "ButtonSave": "Zapisz", "ButtonSaveAndClose": "Zapisz i zamknij", @@ -122,6 +123,7 @@ "HeaderManageTags": "Manage Tags", "HeaderMapDetails": "Map details", "HeaderMatch": "Dopasuj", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Osadź metadane", "HeaderNewAccount": "Nowe konto", "HeaderNewLibrary": "Nowa biblioteka", @@ -200,6 +202,7 @@ "LabelChapters": "Chapters", "LabelChaptersFound": "Znalezione rozdziały", "LabelChapterTitle": "Tytuł rozdziału", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Zamknij odtwarzacz", "LabelCodec": "Codec", "LabelCollapseSeries": "Podsumuj serię", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie", "LabelMediaPlayer": "Odtwarzacz", "LabelMediaType": "Typ mediów", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Dostawca metadanych", "LabelMetaTag": "Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/ru.json b/client/strings/ru.json index d4d258d3..94a8bc63 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию", "ButtonReScan": "Пересканировать", "ButtonReset": "Сбросить", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Восстановить", "ButtonSave": "Сохранить", "ButtonSaveAndClose": "Сохранить и закрыть", @@ -122,6 +123,7 @@ "HeaderManageTags": "Редактировать теги", "HeaderMapDetails": "Найти подробности", "HeaderMatch": "Поиск", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Метаинформация для встраивания", "HeaderNewAccount": "Новая учетная запись", "HeaderNewLibrary": "Новая библиотека", @@ -200,6 +202,7 @@ "LabelChapters": "Главы", "LabelChaptersFound": "глав найдено", "LabelChapterTitle": "Название главы", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Закрыть проигрыватель", "LabelCodec": "Кодек", "LabelCollapseSeries": "Свернуть серии", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты", "LabelMediaPlayer": "Медиа проигрыватель", "LabelMediaType": "Тип медиа", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Провайдер", "LabelMetaTag": "Мета тег", "LabelMetaTags": "Мета теги", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index b76e7949..0ea26727 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除", "ButtonReScan": "重新扫描", "ButtonReset": "重置", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "恢复", "ButtonSave": "保存", "ButtonSaveAndClose": "保存并关闭", @@ -122,6 +123,7 @@ "HeaderManageTags": "管理标签", "HeaderMapDetails": "编辑详情", "HeaderMatch": "匹配", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "嵌入元数据", "HeaderNewAccount": "新建帐户", "HeaderNewLibrary": "新建媒体库", @@ -200,6 +202,7 @@ "LabelChapters": "章节", "LabelChaptersFound": "找到的章节", "LabelChapterTitle": "章节标题", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "关闭播放器", "LabelCodec": "编解码", "LabelCollapseSeries": "折叠系列", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", "LabelMediaPlayer": "媒体播放器", "LabelMediaType": "媒体类型", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "元数据提供者", "LabelMetaTag": "元数据标签", "LabelMetaTags": "元标签", From 60a80a2996895373c797f5b119204f6492274470 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 22 Oct 2023 15:53:05 -0500 Subject: [PATCH 59/84] Update:Remove support for metadata.abs, added script to create metadata.json files if they dont exist --- client/package.json | 1 + client/pages/config/index.vue | 20 +- client/strings/da.json | 2 +- client/strings/de.json | 2 +- client/strings/en-us.json | 2 +- client/strings/es.json | 2 +- client/strings/fr.json | 2 +- client/strings/gu.json | 2 +- client/strings/hi.json | 2 +- client/strings/hr.json | 2 +- client/strings/it.json | 2 +- client/strings/lt.json | 2 +- client/strings/nl.json | 2 +- client/strings/no.json | 2 +- client/strings/pl.json | 2 +- client/strings/ru.json | 2 +- client/strings/zh-cn.json | 2 +- package.json | 3 +- server/Database.js | 24 +- server/models/Book.js | 26 + server/models/Podcast.js | 19 + server/objects/LibraryItem.js | 117 ++-- server/objects/mediaTypes/Book.js | 2 +- server/objects/mediaTypes/Podcast.js | 14 +- server/objects/settings/ServerSettings.js | 14 +- server/scanner/AbsMetadataFileScanner.js | 83 +-- server/scanner/BookScanner.js | 175 ++---- server/scanner/PodcastScanner.js | 161 ++--- .../utils/generators/abmetadataGenerator.js | 553 +----------------- .../utils/migrations/absMetadataMigration.js | 93 +++ 30 files changed, 390 insertions(+), 945 deletions(-) create mode 100644 server/utils/migrations/absMetadataMigration.js diff --git a/client/package.json b/client/package.json index cc785926..21cae124 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,7 @@ { "name": "audiobookshelf-client", "version": "2.4.4", + "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", "scripts": { diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 1721a379..12ce7b1e 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -47,10 +47,6 @@ <p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p> </div> - <div class="w-44 mb-2"> - <ui-dropdown v-model="newServerSettings.metadataFileFormat" small :items="metadataFileFormats" label="Metadata File Format" @input="updateMetadataFileFormat" :disabled="updatingServerSettings" /> - </div> - <div class="pt-4"> <h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2> </div> @@ -237,17 +233,7 @@ export default { hasPrefixesChanged: false, newServerSettings: {}, showConfirmPurgeCache: false, - savingPrefixes: false, - metadataFileFormats: [ - { - text: '.json', - value: 'json' - }, - { - text: '.abs (deprecated)', - value: 'abs' - } - ] + savingPrefixes: false } }, watch: { @@ -329,10 +315,6 @@ export default { updateServerLanguage(val) { this.updateSettingsKey('language', val) }, - updateMetadataFileFormat(val) { - if (this.serverSettings.metadataFileFormat === val) return - this.updateSettingsKey('metadataFileFormat', val) - }, updateSettingsKey(key, val) { if (key === 'scannerDisableWatcher') { this.newServerSettings.scannerDisableWatcher = val diff --git a/client/strings/da.json b/client/strings/da.json index aa1b66ed..adf138a1 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Gem omslag med element", "LabelSettingsStoreCoversWithItemHelp": "Som standard gemmes omslag i /metadata/items, aktivering af denne indstilling vil gemme omslag i mappen for dit bibliotekselement. Kun én fil med navnet \"cover\" vil blive bevaret", "LabelSettingsStoreMetadataWithItem": "Gem metadata med element", - "LabelSettingsStoreMetadataWithItemHelp": "Som standard gemmes metadatafiler i /metadata/items, aktivering af denne indstilling vil gemme metadatafiler i dine bibliotekselementmapper. Bruger .abs-filudvidelsen", + "LabelSettingsStoreMetadataWithItemHelp": "Som standard gemmes metadatafiler i /metadata/items, aktivering af denne indstilling vil gemme metadatafiler i dine bibliotekselementmapper", "LabelSettingsTimeFormat": "Tidsformat", "LabelShowAll": "Vis alle", "LabelSize": "Størrelse", diff --git a/client/strings/de.json b/client/strings/de.json index fe1df71c..a072a549 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Titelbilder im Medienordner speichern", "LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.", "LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern", - "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. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.", + "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", "LabelShowAll": "Alles anzeigen", "LabelSize": "Größe", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 43f44821..24d07726 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreMetadataWithItem": "Store metadata with item", - "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension", + "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Show All", "LabelSize": "Size", diff --git a/client/strings/es.json b/client/strings/es.json index 3a6012b6..4b37139d 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Guardar portadas con elementos", "LabelSettingsStoreCoversWithItemHelp": "Por defecto, las portadas se almacenan en /metadata/items. Si habilita esta opción, las portadas se almacenarán en la carpeta de elementos de su biblioteca. Se guardará un solo archivo llamado \"cover\".", "LabelSettingsStoreMetadataWithItem": "Guardar metadatos con elementos", - "LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca. Usa la extensión .abs", + "LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca", "LabelSettingsTimeFormat": "Formato de Tiempo", "LabelShowAll": "Mostrar Todos", "LabelSize": "Tamaño", diff --git a/client/strings/fr.json b/client/strings/fr.json index a78f7d66..28bdf743 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles", "LabelSettingsStoreCoversWithItemHelp": "Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiers de l’article. Seul un fichier nommé « cover » sera conservé.", "LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles", - "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les métadonnées dans le dossier de l’article avec une extension « .abs ».", + "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items", "LabelSettingsTimeFormat": "Format d’heure", "LabelShowAll": "Afficher Tout", "LabelSize": "Taille", diff --git a/client/strings/gu.json b/client/strings/gu.json index 8b6a963f..8593a95d 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreMetadataWithItem": "Store metadata with item", - "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension", + "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Show All", "LabelSize": "Size", diff --git a/client/strings/hi.json b/client/strings/hi.json index 7c8651e3..82d25986 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreMetadataWithItem": "Store metadata with item", - "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension", + "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Show All", "LabelSize": "Size", diff --git a/client/strings/hr.json b/client/strings/hr.json index 8e3946a5..e9a323ee 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Spremi cover uz stakvu", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreMetadataWithItem": "Spremi metapodatke uz stavku", - "LabelSettingsStoreMetadataWithItemHelp": "Po defaultu metapodatci su spremljeni u /metadata/items, uključujućite li ovu postavku, metapodatci će biti spremljeni u folderima od biblioteke. Koristi .abs ekstenziju.", + "LabelSettingsStoreMetadataWithItemHelp": "Po defaultu metapodatci su spremljeni u /metadata/items, uključujućite li ovu postavku, metapodatci će biti spremljeni u folderima od biblioteke", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Prikaži sve", "LabelSize": "Veličina", diff --git a/client/strings/it.json b/client/strings/it.json index e384ea59..f73b3ffc 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Archivia le copertine con il file", "LabelSettingsStoreCoversWithItemHelp": "Di default, le immagini di copertina sono salvate dentro /metadata/items, abilitando questa opzione le copertine saranno archiviate nella cartella della libreria corrispondente. Verrà conservato solo un file denominato \"cover\"", "LabelSettingsStoreMetadataWithItem": "Archivia i metadata con il file", - "LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria. I file avranno estensione .abs", + "LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria", "LabelSettingsTimeFormat": "Formato Ora", "LabelShowAll": "Mostra Tutto", "LabelSize": "Dimensione", diff --git a/client/strings/lt.json b/client/strings/lt.json index c5c937ba..dee54e12 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Saugoti viršelius su elementu", "LabelSettingsStoreCoversWithItemHelp": "Pagal nutylėjimą viršeliai saugomi /metadata/items aplanke, įjungus šią parinktį viršeliai bus saugomi jūsų bibliotekos elemento aplanke. Bus išsaugotas tik vienas „cover“ pavadinimo failas.", "LabelSettingsStoreMetadataWithItem": "Saugoti metaduomenis su elementu", - "LabelSettingsStoreMetadataWithItemHelp": "Pagal nutylėjimą metaduomenų failai saugomi /metadata/items aplanke, įjungus šią parinktį metaduomenų failai bus saugomi jūsų bibliotekos elemento aplanke. Naudojamas .abs plėtinys.", + "LabelSettingsStoreMetadataWithItemHelp": "Pagal nutylėjimą metaduomenų failai saugomi /metadata/items aplanke, įjungus šią parinktį metaduomenų failai bus saugomi jūsų bibliotekos elemento aplanke", "LabelSettingsTimeFormat": "Laiko formatas", "LabelShowAll": "Rodyti viską", "LabelSize": "Dydis", diff --git a/client/strings/nl.json b/client/strings/nl.json index a2046d57..62696dce 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Bewaar covers bij onderdeel", "LabelSettingsStoreCoversWithItemHelp": "Standaard worden covers bewaard in /metadata/items, door deze instelling in te schakelen zullen covers in de map van je bibliotheekonderdeel bewaard worden. Slechts een bestand genaamd \"cover\" zal worden bewaard", "LabelSettingsStoreMetadataWithItem": "Bewaar metadata bij onderdeel", - "LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden. Gebruikt .abs-extensie", + "LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden", "LabelSettingsTimeFormat": "Tijdformat", "LabelShowAll": "Toon alle", "LabelSize": "Grootte", diff --git a/client/strings/no.json b/client/strings/no.json index 26a282af..dc7685ee 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Lagre bokomslag med gjenstand", "LabelSettingsStoreCoversWithItemHelp": "Som standard vil bokomslag bli lagret under /metadata/items, aktiveres dette valget vil bokomslag bli lagret i samme mappe som gjenstanden. Kun en fil med navn \"cover\" vil bli beholdt", "LabelSettingsStoreMetadataWithItem": "Lagre metadata med gjenstand", - "LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden. Bruker .abs filetternavn", + "LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden", "LabelSettingsTimeFormat": "Tid format", "LabelShowAll": "Vis alt", "LabelSize": "Størrelse", diff --git a/client/strings/pl.json b/client/strings/pl.json index b38406ba..c4fb50f8 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Przechowuj okładkę w folderze książki", "LabelSettingsStoreCoversWithItemHelp": "Domyślnie okładki są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana.", "LabelSettingsStoreMetadataWithItem": "Przechowuj metadane w folderze książki", - "LabelSettingsStoreMetadataWithItemHelp": "Domyślnie metadane są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana. Rozszerzenie pliku metadanych: .abs", + "LabelSettingsStoreMetadataWithItemHelp": "Domyślnie metadane są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Pokaż wszystko", "LabelSize": "Rozmiar", diff --git a/client/strings/ru.json b/client/strings/ru.json index 94a8bc63..69868bca 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Хранить обложки с элементом", "LabelSettingsStoreCoversWithItemHelp": "По умолчанию обложки сохраняются в папке /metadata/items, при включении этой настройки обложка будет храниться в папке элемента. Будет сохраняться только один файл с именем \"cover\"", "LabelSettingsStoreMetadataWithItem": "Хранить метаинформацию с элементом", - "LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента. Используется расширение файла .abs", + "LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента", "LabelSettingsTimeFormat": "Формат времени", "LabelShowAll": "Показать все", "LabelSize": "Размер", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 0ea26727..219e861a 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "存储项目封面", "LabelSettingsStoreCoversWithItemHelp": "默认情况下封面存储在/metadata/items文件夹中, 启用此设置将存储封面在你媒体项目文件夹中. 只保留一个名为 \"cover\" 的文件", "LabelSettingsStoreMetadataWithItem": "存储项目元数据", - "LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中. 使 .abs 文件护展名", + "LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中", "LabelSettingsTimeFormat": "时间格式", "LabelShowAll": "全部显示", "LabelSize": "文件大小", diff --git a/package.json b/package.json index e76147d8..f8ea7dee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "audiobookshelf", "version": "2.4.4", + "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", "scripts": { @@ -45,4 +46,4 @@ "devDependencies": { "nodemon": "^2.0.20" } -} +} \ No newline at end of file diff --git a/server/Database.js b/server/Database.js index 521e016d..5721ac27 100644 --- a/server/Database.js +++ b/server/Database.js @@ -276,11 +276,17 @@ class Database { global.ServerSettings = this.serverSettings.toJSON() // Version specific migrations - if (this.serverSettings.version === '2.3.0' && this.compareVersions(packageJson.version, '2.3.0') == 1) { - await dbMigration.migrationPatch(this) + if (packageJson.version !== this.serverSettings.version) { + if (this.serverSettings.version === '2.3.0' && this.compareVersions(packageJson.version, '2.3.0') == 1) { + await dbMigration.migrationPatch(this) + } + if (['2.3.0', '2.3.1', '2.3.2', '2.3.3'].includes(this.serverSettings.version) && this.compareVersions(packageJson.version, '2.3.3') >= 0) { + await dbMigration.migrationPatch2(this) + } } - if (['2.3.0', '2.3.1', '2.3.2', '2.3.3'].includes(this.serverSettings.version) && this.compareVersions(packageJson.version, '2.3.3') >= 0) { - await dbMigration.migrationPatch2(this) + // Build migrations + if (this.serverSettings.buildNumber <= 0) { + await require('./utils/migrations/absMetadataMigration').migrate(this) } await this.cleanDatabase() @@ -288,9 +294,19 @@ class Database { // Set if root user has been created this.hasRootUser = await this.models.user.getHasRootUser() + // Update server settings with version/build + let updateServerSettings = false if (packageJson.version !== this.serverSettings.version) { Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`) this.serverSettings.version = packageJson.version + this.serverSettings.buildNumber = packageJson.buildNumber + updateServerSettings = true + } else if (packageJson.buildNumber !== this.serverSettings.buildNumber) { + Logger.info(`[Database] Server v${packageJson.version} build upgraded from ${this.serverSettings.buildNumber} to ${packageJson.buildNumber}`) + this.serverSettings.buildNumber = packageJson.buildNumber + updateServerSettings = true + } + if (updateServerSettings) { await this.updateServerSettings() } } diff --git a/server/models/Book.js b/server/models/Book.js index 31bcfa3c..9537d7b3 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -211,6 +211,32 @@ class Book extends Model { } } + getAbsMetadataJson() { + return { + tags: this.tags || [], + chapters: this.chapters?.map(c => ({ ...c })) || [], + title: this.title, + subtitle: this.subtitle, + authors: this.authors.map(a => a.name), + narrators: this.narrators, + series: this.series.map(se => { + const sequence = se.bookSeries?.sequence || '' + if (!sequence) return se.name + return `${se.name} #${sequence}` + }), + genres: this.genres || [], + publishedYear: this.publishedYear, + publishedDate: this.publishedDate, + publisher: this.publisher, + description: this.description, + isbn: this.isbn, + asin: this.asin, + language: this.language, + explicit: !!this.explicit, + abridged: !!this.abridged + } + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 60311bfd..82ae8fe2 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -112,6 +112,25 @@ class Podcast extends Model { } } + getAbsMetadataJson() { + return { + tags: this.tags || [], + title: this.title, + author: this.author, + description: this.description, + releaseDate: this.releaseDate, + genres: this.genres || [], + feedURL: this.feedURL, + imageURL: this.imageURL, + itunesPageURL: this.itunesPageURL, + itunesId: this.itunesId, + itunesArtistId: this.itunesArtistId, + language: this.language, + explicit: !!this.explicit, + podcastType: this.podcastType + } + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index bb91e2d6..3b92bdcc 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -2,7 +2,6 @@ const uuidv4 = require("uuid").v4 const fs = require('../libs/fsExtra') const Path = require('path') const Logger = require('../Logger') -const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') const LibraryFile = require('./files/LibraryFile') const Book = require('./mediaTypes/Book') const Podcast = require('./mediaTypes/Podcast') @@ -263,7 +262,7 @@ class LibraryItem { } /** - * Save metadata.json/metadata.abs file + * Save metadata.json file * TODO: Move to new LibraryItem model * @returns {Promise<LibraryFile>} null if not saved */ @@ -282,91 +281,41 @@ class LibraryItem { await fs.ensureDir(metadataPath) } - const metadataFileFormat = global.ServerSettings.metadataFileFormat - const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`) - if (metadataFileFormat === 'json') { - // Remove metadata.abs if it exists - if (await fs.pathExists(Path.join(metadataPath, `metadata.abs`))) { - Logger.debug(`[LibraryItem] Removing metadata.abs for item "${this.media.metadata.title}"`) - await fs.remove(Path.join(metadataPath, `metadata.abs`)) - this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`))) + const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) + + return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => { + // Add metadata.json to libraryFiles array if it is new + let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) + if (storeMetadataWithItem) { + if (!metadataLibraryFile) { + metadataLibraryFile = new LibraryFile() + await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) + this.libraryFiles.push(metadataLibraryFile) + } else { + const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) + if (fileTimestamps) { + metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs + metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs + metadataLibraryFile.metadata.size = fileTimestamps.size + metadataLibraryFile.ino = fileTimestamps.ino + } + } + const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) + if (libraryItemDirTimestamps) { + this.mtimeMs = libraryItemDirTimestamps.mtimeMs + this.ctimeMs = libraryItemDirTimestamps.ctimeMs + } } - return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => { - // Add metadata.json to libraryFiles array if it is new - let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - metadataLibraryFile = new LibraryFile() - await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - this.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) - if (libraryItemDirTimestamps) { - this.mtimeMs = libraryItemDirTimestamps.mtimeMs - this.ctimeMs = libraryItemDirTimestamps.ctimeMs - } - } + Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) - Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) - - return metadataLibraryFile - }).catch((error) => { - Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error) - return null - }).finally(() => { - this.isSavingMetadata = false - }) - } else { - // Remove metadata.json if it exists - if (await fs.pathExists(Path.join(metadataPath, `metadata.json`))) { - Logger.debug(`[LibraryItem] Removing metadata.json for item "${this.media.metadata.title}"`) - await fs.remove(Path.join(metadataPath, `metadata.json`)) - this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`))) - } - - return abmetadataGenerator.generate(this, metadataFilePath).then(async (success) => { - if (!success) { - Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataFilePath}"`) - return null - } - // Add metadata.abs to libraryFiles array if it is new - let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - metadataLibraryFile = new LibraryFile() - await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`) - this.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) - if (libraryItemDirTimestamps) { - this.mtimeMs = libraryItemDirTimestamps.mtimeMs - this.ctimeMs = libraryItemDirTimestamps.ctimeMs - } - } - - Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) - return metadataLibraryFile - }).finally(() => { - this.isSavingMetadata = false - }) - } + return metadataLibraryFile + }).catch((error) => { + Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error) + return null + }).finally(() => { + this.isSavingMetadata = false + }) } removeLibraryFile(ino) { diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index afbf1622..d53a53a7 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -94,7 +94,7 @@ class Book { return { tags: [...this.tags], chapters: this.chapters.map(c => ({ ...c })), - metadata: this.metadata.toJSONForMetadataFile() + ...this.metadata.toJSONForMetadataFile() } } diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 969e2548..a0e5de04 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -97,7 +97,19 @@ class Podcast { toJSONForMetadataFile() { return { tags: [...this.tags], - metadata: this.metadata.toJSON() + title: this.metadata.title, + author: this.metadata.author, + description: this.metadata.description, + releaseDate: this.metadata.releaseDate, + genres: [...this.metadata.genres], + feedURL: this.metadata.feedUrl, + imageURL: this.metadata.imageUrl, + itunesPageURL: this.metadata.itunesPageUrl, + itunesId: this.metadata.itunesId, + itunesArtistId: this.metadata.itunesArtistId, + explicit: this.metadata.explicit, + language: this.metadata.language, + podcastType: this.metadata.type } } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 5c0d9dad..f31aaf6b 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -1,3 +1,4 @@ +const packageJson = require('../../../package.json') const { BookshelfView } = require('../../utils/constants') const Logger = require('../../Logger') @@ -50,7 +51,8 @@ class ServerSettings { this.logLevel = Logger.logLevel - this.version = null + this.version = packageJson.version + this.buildNumber = packageJson.buildNumber if (settings) { this.construct(settings) @@ -90,6 +92,7 @@ class ServerSettings { this.language = settings.language || 'en-us' this.logLevel = settings.logLevel || Logger.logLevel this.version = settings.version || null + this.buildNumber = settings.buildNumber || 0 // Added v2.4.5 // Migrations if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0 @@ -106,9 +109,9 @@ class ServerSettings { this.metadataFileFormat = 'abs' } - // Validation - if (!['abs', 'json'].includes(this.metadataFileFormat)) { - Logger.error(`[ServerSettings] construct: Invalid metadataFileFormat ${this.metadataFileFormat}`) + // As of v2.4.5 only json is supported + if (this.metadataFileFormat !== 'json') { + Logger.warn(`[ServerSettings] Invalid metadataFileFormat ${this.metadataFileFormat} (as of v2.4.5 only json is supported)`) this.metadataFileFormat = 'json' } @@ -146,7 +149,8 @@ class ServerSettings { timeFormat: this.timeFormat, language: this.language, logLevel: this.logLevel, - version: this.version + version: this.version, + buildNumber: this.buildNumber } } diff --git a/server/scanner/AbsMetadataFileScanner.js b/server/scanner/AbsMetadataFileScanner.js index 037726f6..1f9d2823 100644 --- a/server/scanner/AbsMetadataFileScanner.js +++ b/server/scanner/AbsMetadataFileScanner.js @@ -8,7 +8,7 @@ class AbsMetadataFileScanner { constructor() { } /** - * Check for metadata.json or metadata.abs file and set book metadata + * Check for metadata.json file and set book metadata * * @param {import('./LibraryScan')} libraryScan * @param {import('./LibraryItemScanData')} libraryItemData @@ -16,54 +16,36 @@ class AbsMetadataFileScanner { * @param {string} [existingLibraryItemId] */ async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) { - const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile || libraryItemData.metadataAbsLibraryFile + const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile let metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null let metadataFilePath = metadataLibraryFile?.metadata.path - let metadataFileFormat = libraryItemData.metadataJsonLibraryFile ? 'json' : 'abs' // When metadata file is not stored with library item then check in the /metadata/items folder for it if (!metadataText && existingLibraryItemId) { let metadataPath = Path.join(global.MetadataPath, 'items', existingLibraryItemId) - let altFormat = global.ServerSettings.metadataFileFormat === 'json' ? 'abs' : 'json' - // First check the metadata format set in server settings, fallback to the alternate - metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - metadataFileFormat = global.ServerSettings.metadataFileFormat + metadataFilePath = Path.join(metadataPath, 'metadata.json') if (await fsExtra.pathExists(metadataFilePath)) { metadataText = await readTextFile(metadataFilePath) - } else if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.${altFormat}`))) { - metadataFilePath = Path.join(metadataPath, `metadata.${altFormat}`) - metadataFileFormat = altFormat - metadataText = await readTextFile(metadataFilePath) } } if (metadataText) { - libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}" - preferring`) - let abMetadata = null - if (metadataFileFormat === 'json') { - abMetadata = abmetadataGenerator.parseJson(metadataText) - } else { - abMetadata = abmetadataGenerator.parse(metadataText, 'book') - } + libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) + const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} + for (const key in abMetadata) { + // TODO: When to override with null or empty arrays? + if (abMetadata[key] === undefined || abMetadata[key] === null) continue + if (key === 'tags' && !abMetadata.tags?.length) continue + if (key === 'chapters' && !abMetadata.chapters?.length) continue - if (abMetadata) { - if (abMetadata.tags?.length) { - bookMetadata.tags = abMetadata.tags - } - if (abMetadata.chapters?.length) { - bookMetadata.chapters = abMetadata.chapters - } - for (const key in abMetadata.metadata) { - if (abMetadata.metadata[key] === undefined || abMetadata.metadata[key] === null) continue - bookMetadata[key] = abMetadata.metadata[key] - } + bookMetadata[key] = abMetadata[key] } } } /** - * Check for metadata.json or metadata.abs file and set podcast metadata + * Check for metadata.json file and set podcast metadata * * @param {import('./LibraryScan')} libraryScan * @param {import('./LibraryItemScanData')} libraryItemData @@ -71,53 +53,28 @@ class AbsMetadataFileScanner { * @param {string} [existingLibraryItemId] */ async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) { - const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile || libraryItemData.metadataAbsLibraryFile + const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile let metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null let metadataFilePath = metadataLibraryFile?.metadata.path - let metadataFileFormat = libraryItemData.metadataJsonLibraryFile ? 'json' : 'abs' // When metadata file is not stored with library item then check in the /metadata/items folder for it if (!metadataText && existingLibraryItemId) { let metadataPath = Path.join(global.MetadataPath, 'items', existingLibraryItemId) - let altFormat = global.ServerSettings.metadataFileFormat === 'json' ? 'abs' : 'json' - // First check the metadata format set in server settings, fallback to the alternate - metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - metadataFileFormat = global.ServerSettings.metadataFileFormat + metadataFilePath = Path.join(metadataPath, 'metadata.json') if (await fsExtra.pathExists(metadataFilePath)) { metadataText = await readTextFile(metadataFilePath) - } else if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.${altFormat}`))) { - metadataFilePath = Path.join(metadataPath, `metadata.${altFormat}`) - metadataFileFormat = altFormat - metadataText = await readTextFile(metadataFilePath) } } if (metadataText) { - libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}" - preferring`) - let abMetadata = null - if (metadataFileFormat === 'json') { - abMetadata = abmetadataGenerator.parseJson(metadataText) - } else { - abMetadata = abmetadataGenerator.parse(metadataText, 'podcast') - } + libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) + const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} + for (const key in abMetadata) { + if (abMetadata[key] === undefined || abMetadata[key] === null) continue + if (key === 'tags' && !abMetadata.tags?.length) continue - if (abMetadata) { - if (abMetadata.tags?.length) { - podcastMetadata.tags = abMetadata.tags - } - for (const key in abMetadata.metadata) { - if (abMetadata.metadata[key] === undefined) continue - - // TODO: New podcast model changed some keys, need to update the abmetadataGenerator - let newModelKey = key - if (key === 'feedUrl') newModelKey = 'feedURL' - else if (key === 'imageUrl') newModelKey = 'imageURL' - else if (key === 'itunesPageUrl') newModelKey = 'itunesPageURL' - else if (key === 'type') newModelKey = 'podcastType' - - podcastMetadata[newModelKey] = abMetadata.metadata[key] - } + podcastMetadata[key] = abMetadata[key] } } } diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index f752417c..282155f2 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -678,10 +678,10 @@ class BookScanner { } /** - * Metadata from metadata.json or metadata.abs + * Metadata from metadata.json */ async absMetadata() { - // If metadata.json or metadata.abs use this for metadata + // If metadata.json use this for metadata await AbsMetadataFileScanner.scanBookMetadataFile(this.libraryScan, this.libraryItemData, this.bookMetadata, this.existingLibraryItemId) } } @@ -703,121 +703,66 @@ class BookScanner { await fsExtra.ensureDir(metadataPath) } - const metadataFileFormat = global.ServerSettings.metadataFileFormat - const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`) - if (metadataFileFormat === 'json') { - // Remove metadata.abs if it exists - if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.abs`))) { - libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.abs for item "${libraryItem.media.title}"`) - await fsExtra.remove(Path.join(metadataPath, `metadata.abs`)) - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`))) - } + const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - // TODO: Update to not use `metadata` so it fits the updated model - const jsonObject = { - tags: libraryItem.media.tags || [], - chapters: libraryItem.media.chapters?.map(c => ({ ...c })) || [], - metadata: { - title: libraryItem.media.title, - subtitle: libraryItem.media.subtitle, - authors: libraryItem.media.authors.map(a => a.name), - narrators: libraryItem.media.narrators, - series: libraryItem.media.series.map(se => { - const sequence = se.bookSeries?.sequence || '' - if (!sequence) return se.name - return `${se.name} #${sequence}` - }), - genres: libraryItem.media.genres || [], - publishedYear: libraryItem.media.publishedYear, - publishedDate: libraryItem.media.publishedDate, - publisher: libraryItem.media.publisher, - description: libraryItem.media.description, - isbn: libraryItem.media.isbn, - asin: libraryItem.media.asin, - language: libraryItem.media.language, - explicit: !!libraryItem.media.explicit, - abridged: !!libraryItem.media.abridged - } - } - return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { - // Add metadata.json to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - - return metadataLibraryFile - }).catch((error) => { - libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) - return null - }) - } else { - // Remove metadata.json if it exists - if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.json`))) { - libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.json for item "${libraryItem.media.title}"`) - await fsExtra.remove(Path.join(metadataPath, `metadata.json`)) - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`))) - } - - return abmetadataGenerator.generateFromNewModel(libraryItem, metadataFilePath).then(async (success) => { - if (!success) { - libraryScan.addLog(LogLevel.ERROR, `Failed saving abmetadata to "${metadataFilePath}"`) - return null - } - // Add metadata.abs to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - return metadataLibraryFile - }) + const jsonObject = { + tags: libraryItem.media.tags || [], + chapters: libraryItem.media.chapters?.map(c => ({ ...c })) || [], + title: libraryItem.media.title, + subtitle: libraryItem.media.subtitle, + authors: libraryItem.media.authors.map(a => a.name), + narrators: libraryItem.media.narrators, + series: libraryItem.media.series.map(se => { + const sequence = se.bookSeries?.sequence || '' + if (!sequence) return se.name + return `${se.name} #${sequence}` + }), + genres: libraryItem.media.genres || [], + publishedYear: libraryItem.media.publishedYear, + publishedDate: libraryItem.media.publishedDate, + publisher: libraryItem.media.publisher, + description: libraryItem.media.description, + isbn: libraryItem.media.isbn, + asin: libraryItem.media.asin, + language: libraryItem.media.language, + explicit: !!libraryItem.media.explicit, + abridged: !!libraryItem.media.abridged } + return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { + // Add metadata.json to libraryFiles array if it is new + let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) + if (storeMetadataWithItem) { + if (!metadataLibraryFile) { + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) + metadataLibraryFile = newLibraryFile.toJSON() + libraryItem.libraryFiles.push(metadataLibraryFile) + } else { + const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) + if (fileTimestamps) { + metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs + metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs + metadataLibraryFile.metadata.size = fileTimestamps.size + metadataLibraryFile.ino = fileTimestamps.ino + } + } + const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) + if (libraryItemDirTimestamps) { + libraryItem.mtime = libraryItemDirTimestamps.mtimeMs + libraryItem.ctime = libraryItemDirTimestamps.ctimeMs + let size = 0 + libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + libraryItem.size = size + } + } + + libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) + + return metadataLibraryFile + }).catch((error) => { + libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) + return null + }) } /** diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index 53d4ad1f..b56c4db6 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -342,7 +342,7 @@ class PodcastScanner { AudioFileScanner.setPodcastMetadataFromAudioMetaTags(podcastEpisodes[0].audioFile, podcastMetadata, libraryScan) } - // Use metadata.json or metadata.abs file + // Use metadata.json file await AbsMetadataFileScanner.scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId) podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title) @@ -367,115 +367,60 @@ class PodcastScanner { await fsExtra.ensureDir(metadataPath) } - const metadataFileFormat = global.ServerSettings.metadataFileFormat - const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`) - if (metadataFileFormat === 'json') { - // Remove metadata.abs if it exists - if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.abs`))) { - libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.abs for item "${libraryItem.media.title}"`) - await fsExtra.remove(Path.join(metadataPath, `metadata.abs`)) - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`))) - } + const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - // TODO: Update to not use `metadata` so it fits the updated model - const jsonObject = { - tags: libraryItem.media.tags || [], - metadata: { - title: libraryItem.media.title, - author: libraryItem.media.author, - description: libraryItem.media.description, - releaseDate: libraryItem.media.releaseDate, - genres: libraryItem.media.genres || [], - feedUrl: libraryItem.media.feedURL, - imageUrl: libraryItem.media.imageURL, - itunesPageUrl: libraryItem.media.itunesPageURL, - itunesId: libraryItem.media.itunesId, - itunesArtistId: libraryItem.media.itunesArtistId, - asin: libraryItem.media.asin, - language: libraryItem.media.language, - explicit: !!libraryItem.media.explicit, - type: libraryItem.media.podcastType - } - } - return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { - // Add metadata.json to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - - return metadataLibraryFile - }).catch((error) => { - libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) - return null - }) - } else { - // Remove metadata.json if it exists - if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.json`))) { - libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.json for item "${libraryItem.media.title}"`) - await fsExtra.remove(Path.join(metadataPath, `metadata.json`)) - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`))) - } - - return abmetadataGenerator.generateFromNewModel(libraryItem, metadataFilePath).then(async (success) => { - if (!success) { - libraryScan.addLog(LogLevel.ERROR, `Failed saving abmetadata to "${metadataFilePath}"`) - return null - } - // Add metadata.abs to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - return metadataLibraryFile - }) + const jsonObject = { + tags: libraryItem.media.tags || [], + title: libraryItem.media.title, + author: libraryItem.media.author, + description: libraryItem.media.description, + releaseDate: libraryItem.media.releaseDate, + genres: libraryItem.media.genres || [], + feedURL: libraryItem.media.feedURL, + imageURL: libraryItem.media.imageURL, + itunesPageURL: libraryItem.media.itunesPageURL, + itunesId: libraryItem.media.itunesId, + itunesArtistId: libraryItem.media.itunesArtistId, + asin: libraryItem.media.asin, + language: libraryItem.media.language, + explicit: !!libraryItem.media.explicit, + podcastType: libraryItem.media.podcastType } + return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { + // Add metadata.json to libraryFiles array if it is new + let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) + if (storeMetadataWithItem) { + if (!metadataLibraryFile) { + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) + metadataLibraryFile = newLibraryFile.toJSON() + libraryItem.libraryFiles.push(metadataLibraryFile) + } else { + const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) + if (fileTimestamps) { + metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs + metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs + metadataLibraryFile.metadata.size = fileTimestamps.size + metadataLibraryFile.ino = fileTimestamps.ino + } + } + const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) + if (libraryItemDirTimestamps) { + libraryItem.mtime = libraryItemDirTimestamps.mtimeMs + libraryItem.ctime = libraryItemDirTimestamps.ctimeMs + let size = 0 + libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + libraryItem.size = size + } + } + + libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) + + return metadataLibraryFile + }).catch((error) => { + libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) + return null + }) } } module.exports = new PodcastScanner() \ No newline at end of file diff --git a/server/utils/generators/abmetadataGenerator.js b/server/utils/generators/abmetadataGenerator.js index ff82ac33..e0b78d2e 100644 --- a/server/utils/generators/abmetadataGenerator.js +++ b/server/utils/generators/abmetadataGenerator.js @@ -1,461 +1,26 @@ -const fs = require('../../libs/fsExtra') -const package = require('../../../package.json') const Logger = require('../../Logger') -const { getId } = require('../index') -const areEquivalent = require('../areEquivalent') - - -const CurrentAbMetadataVersion = 2 -// abmetadata v1 key map -// const bookKeyMap = { -// title: 'title', -// subtitle: 'subtitle', -// author: 'authorFL', -// narrator: 'narratorFL', -// publishedYear: 'publishedYear', -// publisher: 'publisher', -// description: 'description', -// isbn: 'isbn', -// asin: 'asin', -// language: 'language', -// genres: 'genresCommaSeparated' -// } - -const commaSeparatedToArray = (v) => { - if (!v) return [] - return [...new Set(v.split(',').map(_v => _v.trim()).filter(_v => _v))] -} - -const podcastMetadataMapper = { - title: { - to: (m) => m.title || '', - from: (v) => v || '' - }, - author: { - to: (m) => m.author || '', - from: (v) => v || null - }, - language: { - to: (m) => m.language || '', - from: (v) => v || null - }, - genres: { - to: (m) => m.genres?.join(', ') || '', - from: (v) => commaSeparatedToArray(v) - }, - feedUrl: { - to: (m) => m.feedUrl || '', - from: (v) => v || null - }, - itunesId: { - to: (m) => m.itunesId || '', - from: (v) => v || null - }, - explicit: { - to: (m) => m.explicit ? 'Y' : 'N', - from: (v) => v && v.toLowerCase() == 'y' - } -} - -const bookMetadataMapper = { - title: { - to: (m) => m.title || '', - from: (v) => v || '' - }, - subtitle: { - to: (m) => m.subtitle || '', - from: (v) => v || null - }, - authors: { - to: (m) => { - if (m.authorName !== undefined) return m.authorName - if (!m.authors?.length) return '' - return m.authors.map(au => au.name).join(', ') - }, - from: (v) => commaSeparatedToArray(v) - }, - narrators: { - to: (m) => m.narrators?.join(', ') || '', - from: (v) => commaSeparatedToArray(v) - }, - publishedYear: { - to: (m) => m.publishedYear || '', - from: (v) => v || null - }, - publisher: { - to: (m) => m.publisher || '', - from: (v) => v || null - }, - isbn: { - to: (m) => m.isbn || '', - from: (v) => v || null - }, - asin: { - to: (m) => m.asin || '', - from: (v) => v || null - }, - language: { - to: (m) => m.language || '', - from: (v) => v || null - }, - genres: { - to: (m) => m.genres?.join(', ') || '', - from: (v) => commaSeparatedToArray(v) - }, - series: { - to: (m) => { - if (m.seriesName !== undefined) return m.seriesName - if (!m.series?.length) return '' - return m.series.map((se) => { - const sequence = se.bookSeries?.sequence || '' - if (!sequence) return se.name - return `${se.name} #${sequence}` - }).join(', ') - }, - from: (v) => { - return commaSeparatedToArray(v).map(series => { // Return array of { name, sequence } - let sequence = null - let name = series - // Series sequence match any characters after " #" other than whitespace and another # - // e.g. "Name #1a" is valid. "Name #1#a" or "Name #1 a" is not valid. - const matchResults = series.match(/ #([^#\s]+)$/) // Pull out sequence # - if (matchResults && matchResults.length && matchResults.length > 1) { - sequence = matchResults[1] // Group 1 - name = series.replace(matchResults[0], '') - } - return { - name, - sequence - } - }) - } - }, - explicit: { - to: (m) => m.explicit ? 'Y' : 'N', - from: (v) => v && v.toLowerCase() == 'y' - }, - abridged: { - to: (m) => m.abridged ? 'Y' : 'N', - from: (v) => v && v.toLowerCase() == 'y' - } -} - -const metadataMappers = { - book: bookMetadataMapper, - podcast: podcastMetadataMapper -} - -function generate(libraryItem, outputPath) { - let fileString = `;ABMETADATA${CurrentAbMetadataVersion}\n` - fileString += `#audiobookshelf v${package.version}\n\n` - - const mediaType = libraryItem.mediaType - - fileString += `media=${mediaType}\n` - fileString += `tags=${JSON.stringify(libraryItem.media.tags)}\n` - - const metadataMapper = metadataMappers[mediaType] - var mediaMetadata = libraryItem.media.metadata - for (const key in metadataMapper) { - fileString += `${key}=${metadataMapper[key].to(mediaMetadata)}\n` - } - - // Description block - if (mediaMetadata.description) { - fileString += '\n[DESCRIPTION]\n' - fileString += mediaMetadata.description + '\n' - } - - // Book chapters - if (libraryItem.mediaType == 'book' && libraryItem.media.chapters.length) { - fileString += '\n' - libraryItem.media.chapters.forEach((chapter) => { - fileString += `[CHAPTER]\n` - fileString += `start=${chapter.start}\n` - fileString += `end=${chapter.end}\n` - fileString += `title=${chapter.title}\n` - }) - } - return fs.writeFile(outputPath, fileString).then(() => true).catch((error) => { - Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error) - return false - }) -} -module.exports.generate = generate - -function generateFromNewModel(libraryItem, outputPath) { - let fileString = `;ABMETADATA${CurrentAbMetadataVersion}\n` - fileString += `#audiobookshelf v${package.version}\n\n` - - const mediaType = libraryItem.mediaType - - fileString += `media=${mediaType}\n` - fileString += `tags=${JSON.stringify(libraryItem.media.tags || '')}\n` - - const metadataMapper = metadataMappers[mediaType] - for (const key in metadataMapper) { - fileString += `${key}=${metadataMapper[key].to(libraryItem.media)}\n` - } - - // Description block - if (libraryItem.media.description) { - fileString += '\n[DESCRIPTION]\n' - fileString += libraryItem.media.description + '\n' - } - - // Book chapters - if (mediaType == 'book' && libraryItem.media.chapters?.length) { - fileString += '\n' - libraryItem.media.chapters.forEach((chapter) => { - fileString += `[CHAPTER]\n` - fileString += `start=${chapter.start}\n` - fileString += `end=${chapter.end}\n` - fileString += `title=${chapter.title}\n` - }) - } - return fs.writeFile(outputPath, fileString).then(() => true).catch((error) => { - Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error) - return false - }) -} -module.exports.generateFromNewModel = generateFromNewModel - -function parseSections(lines) { - if (!lines || !lines.length || !lines[0].startsWith('[')) { // First line must be section start - return [] - } - - var sections = [] - var currentSection = [] - lines.forEach(line => { - if (!line || !line.trim()) return - - if (line.startsWith('[') && currentSection.length) { // current section ended - sections.push(currentSection) - currentSection = [] - } - - currentSection.push(line) - }) - if (currentSection.length) sections.push(currentSection) - return sections -} - -// lines inside chapter section -function parseChapterLines(lines) { - var chapter = { - start: null, - end: null, - title: null - } - - lines.forEach((line) => { - var keyValue = line.split('=') - if (keyValue.length > 1) { - var key = keyValue[0].trim() - var value = keyValue[1].trim() - - if (key === 'start' || key === 'end') { - if (!isNaN(value)) { - chapter[key] = Number(value) - } else { - Logger.warn(`[abmetadataGenerator] Invalid chapter value for ${key}: ${value}`) - } - } else if (key === 'title') { - chapter[key] = value - } - } - }) - - if (chapter.start === null || chapter.end === null || chapter.end < chapter.start) { - Logger.warn(`[abmetadataGenerator] Invalid chapter`) - return null - } - return chapter -} - -function parseTags(value) { - if (!value) return null - try { - const parsedTags = [] - JSON.parse(value).forEach((loadedTag) => { - if (loadedTag.trim()) parsedTags.push(loadedTag) // Only push tags that are non-empty - }) - return parsedTags - } catch (err) { - Logger.error(`[abmetadataGenerator] Error parsing TAGS "${value}":`, err.message) - return null - } -} - -function parseAbMetadataText(text, mediaType) { - if (!text) return null - let lines = text.split(/\r?\n/) - - // Check first line and get abmetadata version number - const firstLine = lines.shift().toLowerCase() - if (!firstLine.startsWith(';abmetadata')) { - Logger.error(`Invalid abmetadata file first line is not ;abmetadata "${firstLine}"`) - return null - } - const abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim()) - if (isNaN(abmetadataVersion) || abmetadataVersion != CurrentAbMetadataVersion) { - Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - must use version ${CurrentAbMetadataVersion}`) - return null - } - - // Remove comments and empty lines - const ignoreFirstChars = [' ', '#', ';'] // Ignore any line starting with the following - lines = lines.filter(line => !!line.trim() && !ignoreFirstChars.includes(line[0])) - - // Get lines that map to book details (all lines before the first chapter or description section) - const firstSectionLine = lines.findIndex(l => l.startsWith('[')) - const detailLines = firstSectionLine > 0 ? lines.slice(0, firstSectionLine) : lines - const remainingLines = firstSectionLine > 0 ? lines.slice(firstSectionLine) : [] - - if (!detailLines.length) { - Logger.error(`Invalid abmetadata file no detail lines`) - return null - } - - // Check the media type saved for this abmetadata file show warning if not matching expected - if (detailLines[0].toLowerCase().startsWith('media=')) { - const mediaLine = detailLines.shift() // Remove media line - const abMediaType = mediaLine.toLowerCase().split('=')[1].trim() - if (abMediaType != mediaType) { - Logger.warn(`Invalid media type in abmetadata file ${abMediaType} expecting ${mediaType}`) - } - } else { - Logger.warn(`No media type found in abmetadata file - expecting ${mediaType}`) - } - - const metadataMapper = metadataMappers[mediaType] - // Put valid book detail values into map - const mediaDetails = { - metadata: {}, - chapters: [], - tags: null // When tags are null it will not be used - } - - for (let i = 0; i < detailLines.length; i++) { - const line = detailLines[i] - const keyValue = line.split('=') - if (keyValue.length < 2) { - Logger.warn('abmetadata invalid line has no =', line) - } else if (keyValue[0].trim() === 'tags') { // Parse tags - const value = keyValue.slice(1).join('=').trim() // Everything after "tags=" - mediaDetails.tags = parseTags(value) - } else if (!metadataMapper[keyValue[0].trim()]) { // Ensure valid media metadata key - Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid ${mediaType} metadata key`) - } else { - const key = keyValue.shift().trim() - const value = keyValue.join('=').trim() - mediaDetails.metadata[key] = metadataMapper[key].from(value) - } - } - - // Parse sections for description and chapters - const sections = parseSections(remainingLines) - sections.forEach((section) => { - const sectionHeader = section.shift() - if (sectionHeader.toLowerCase().startsWith('[description]')) { - mediaDetails.metadata.description = section.join('\n') - } else if (sectionHeader.toLowerCase().startsWith('[chapter]')) { - const chapter = parseChapterLines(section) - if (chapter) { - mediaDetails.chapters.push(chapter) - } - } - }) - - mediaDetails.chapters.sort((a, b) => a.start - b.start) - - if (mediaDetails.chapters.length) { - mediaDetails.chapters = cleanChaptersArray(mediaDetails.chapters, mediaDetails.metadata.title) || [] - } - - return mediaDetails -} -module.exports.parse = parseAbMetadataText - -function checkUpdatedBookAuthors(abmetadataAuthors, authors) { - const finalAuthors = [] - let hasUpdates = false - - abmetadataAuthors.forEach((authorName) => { - const findAuthor = authors.find(au => au.name.toLowerCase() == authorName.toLowerCase()) - if (!findAuthor) { - hasUpdates = true - finalAuthors.push({ - id: getId('new'), // New author gets created in Scanner.js after library scan - name: authorName - }) - } else { - finalAuthors.push(findAuthor) - } - }) - - var authorsRemoved = authors.filter(au => !abmetadataAuthors.some(auname => auname.toLowerCase() == au.name.toLowerCase())) - if (authorsRemoved.length) { - hasUpdates = true - } - - return { - authors: finalAuthors, - hasUpdates - } -} - -function checkUpdatedBookSeries(abmetadataSeries, series) { - var finalSeries = [] - var hasUpdates = false - - abmetadataSeries.forEach((seriesObj) => { - var findSeries = series.find(se => se.name.toLowerCase() == seriesObj.name.toLowerCase()) - if (!findSeries) { - hasUpdates = true - finalSeries.push({ - id: getId('new'), // New series gets created in Scanner.js after library scan - name: seriesObj.name, - sequence: seriesObj.sequence - }) - } else if (findSeries.sequence != seriesObj.sequence) { // Sequence was updated - hasUpdates = true - finalSeries.push({ - id: findSeries.id, - name: findSeries.name, - sequence: seriesObj.sequence - }) - } else { - finalSeries.push(findSeries) - } - }) - - var seriesRemoved = series.filter(se => !abmetadataSeries.some(_se => _se.name.toLowerCase() == se.name.toLowerCase())) - if (seriesRemoved.length) { - hasUpdates = true - } - - return { - series: finalSeries, - hasUpdates - } -} - -function checkArraysChanged(abmetadataArray, mediaArray) { - if (!Array.isArray(abmetadataArray)) return false - if (!Array.isArray(mediaArray)) return true - return abmetadataArray.join(',') != mediaArray.join(',') -} function parseJsonMetadataText(text) { try { const abmetadataData = JSON.parse(text) - if (!abmetadataData.metadata) abmetadataData.metadata = {} - if (abmetadataData.metadata.series?.length) { - abmetadataData.metadata.series = [...new Set(abmetadataData.metadata.series.map(t => t?.trim()).filter(t => t))] - abmetadataData.metadata.series = abmetadataData.metadata.series.map(series => { + // Old metadata.json used nested "metadata" + if (abmetadataData.metadata) { + for (const key in abmetadataData.metadata) { + if (abmetadataData.metadata[key] === undefined) continue + let newModelKey = key + if (key === 'feedUrl') newModelKey = 'feedURL' + else if (key === 'imageUrl') newModelKey = 'imageURL' + else if (key === 'itunesPageUrl') newModelKey = 'itunesPageURL' + else if (key === 'type') newModelKey = 'podcastType' + abmetadataData[newModelKey] = abmetadataData.metadata[key] + } + } + delete abmetadataData.metadata + + if (abmetadataData.series?.length) { + abmetadataData.series = [...new Set(abmetadataData.series.map(t => t?.trim()).filter(t => t))] + abmetadataData.series = abmetadataData.series.map(series => { let sequence = null let name = series // Series sequence match any characters after " #" other than whitespace and another # @@ -476,17 +41,17 @@ function parseJsonMetadataText(text) { abmetadataData.tags = [...new Set(abmetadataData.tags.map(t => t?.trim()).filter(t => t))] } if (abmetadataData.chapters?.length) { - abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.metadata.title) + abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title) } // clean remove dupes - if (abmetadataData.metadata.authors?.length) { - abmetadataData.metadata.authors = [...new Set(abmetadataData.metadata.authors.map(t => t?.trim()).filter(t => t))] + if (abmetadataData.authors?.length) { + abmetadataData.authors = [...new Set(abmetadataData.authors.map(t => t?.trim()).filter(t => t))] } - if (abmetadataData.metadata.narrators?.length) { - abmetadataData.metadata.narrators = [...new Set(abmetadataData.metadata.narrators.map(t => t?.trim()).filter(t => t))] + if (abmetadataData.narrators?.length) { + abmetadataData.narrators = [...new Set(abmetadataData.narrators.map(t => t?.trim()).filter(t => t))] } - if (abmetadataData.metadata.genres?.length) { - abmetadataData.metadata.genres = [...new Set(abmetadataData.metadata.genres.map(t => t?.trim()).filter(t => t))] + if (abmetadataData.genres?.length) { + abmetadataData.genres = [...new Set(abmetadataData.genres.map(t => t?.trim()).filter(t => t))] } return abmetadataData } catch (error) { @@ -522,73 +87,3 @@ function cleanChaptersArray(chaptersArray, mediaTitle) { } return chapters } - -// Input text from abmetadata file and return object of media changes -// only returns object of changes. empty object means no changes -function parseAndCheckForUpdates(text, media, mediaType, isJSON) { - if (!text || !media || !media.metadata || !mediaType) { - Logger.error(`Invalid inputs to parseAndCheckForUpdates`) - return null - } - - const mediaMetadata = media.metadata - const metadataUpdatePayload = {} // Only updated key/values - - let abmetadataData = null - - if (isJSON) { - abmetadataData = parseJsonMetadataText(text) - } else { - abmetadataData = parseAbMetadataText(text, mediaType) - } - - if (!abmetadataData || !abmetadataData.metadata) { - Logger.error(`[abmetadataGenerator] Invalid metadata file`) - return null - } - - const abMetadata = abmetadataData.metadata // Metadata from abmetadata file - for (const key in abMetadata) { - if (mediaMetadata[key] !== undefined) { - if (key === 'authors') { - const authorUpdatePayload = checkUpdatedBookAuthors(abMetadata[key], mediaMetadata[key]) - if (authorUpdatePayload.hasUpdates) metadataUpdatePayload.authors = authorUpdatePayload.authors - } else if (key === 'series') { - const seriesUpdatePayload = checkUpdatedBookSeries(abMetadata[key], mediaMetadata[key]) - if (seriesUpdatePayload.hasUpdates) metadataUpdatePayload.series = seriesUpdatePayload.series - } else if (key === 'genres' || key === 'narrators') { // Compare array differences - if (checkArraysChanged(abMetadata[key], mediaMetadata[key])) { - metadataUpdatePayload[key] = abMetadata[key] - } - } else if (abMetadata[key] !== mediaMetadata[key]) { - metadataUpdatePayload[key] = abMetadata[key] - } - } else { - Logger.warn('[abmetadataGenerator] Invalid key', key) - } - } - - const updatePayload = {} // Only updated key/values - // Check update tags - if (abmetadataData.tags) { - if (checkArraysChanged(abmetadataData.tags, media.tags)) { - updatePayload.tags = abmetadataData.tags - } - } - - if (abmetadataData.chapters && mediaType === 'book') { - const abmetadataChaptersCleaned = cleanChaptersArray(abmetadataData.chapters) - if (abmetadataChaptersCleaned) { - if (!areEquivalent(abmetadataChaptersCleaned, media.chapters)) { - updatePayload.chapters = abmetadataChaptersCleaned - } - } - } - - if (Object.keys(metadataUpdatePayload).length) { - updatePayload.metadata = metadataUpdatePayload - } - - return updatePayload -} -module.exports.parseAndCheckForUpdates = parseAndCheckForUpdates diff --git a/server/utils/migrations/absMetadataMigration.js b/server/utils/migrations/absMetadataMigration.js new file mode 100644 index 00000000..0d9f909a --- /dev/null +++ b/server/utils/migrations/absMetadataMigration.js @@ -0,0 +1,93 @@ +const Path = require('path') +const Logger = require('../../Logger') +const fsExtra = require('../../libs/fsExtra') +const fileUtils = require('../fileUtils') +const LibraryFile = require('../../objects/files/LibraryFile') + +/** + * + * @param {import('../../models/LibraryItem')} libraryItem + * @returns {Promise<boolean>} false if failed + */ +async function writeMetadataFileForItem(libraryItem) { + const storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem && !libraryItem.isFile + const metadataPath = storeMetadataWithItem ? libraryItem.path : Path.join(global.MetadataPath, 'items', libraryItem.id) + const metadataFilepath = fileUtils.filePathToPOSIX(Path.join(metadataPath, 'metadata.json')) + if ((await fsExtra.pathExists(metadataFilepath))) { + // Metadata file already exists do nothing + return null + } + Logger.info(`[absMetadataMigration] metadata file not found at "${metadataFilepath}" - creating`) + + if (!storeMetadataWithItem) { + // Ensure /metadata/items/<lid> dir + await fsExtra.ensureDir(metadataPath) + } + + const metadataJson = libraryItem.media.getAbsMetadataJson() + + // Save to file + const success = await fsExtra.writeFile(metadataFilepath, JSON.stringify(metadataJson, null, 2)).then(() => true).catch((error) => { + Logger.error(`[absMetadataMigration] failed to save metadata file at "${metadataFilepath}"`, error.message || error) + return false + }) + + if (!success) return false + if (!storeMetadataWithItem) return true // No need to do anything else + + // Safety check to make sure library file with the same path isnt already there + libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== metadataFilepath) + + // Put new library file in library item + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(metadataFilepath, 'metadata.json') + libraryItem.libraryFiles.push(newLibraryFile.toJSON()) + + // Update library item timestamps and total size + const libraryItemDirTimestamps = await fileUtils.getFileTimestampsWithIno(libraryItem.path) + if (libraryItemDirTimestamps) { + libraryItem.mtime = libraryItemDirTimestamps.mtimeMs + libraryItem.ctime = libraryItemDirTimestamps.ctimeMs + let size = 0 + libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + libraryItem.size = size + } + + libraryItem.changed('libraryFiles', true) + return libraryItem.save().then(() => true).catch((error) => { + Logger.error(`[absMetadataMigration] failed to save libraryItem "${libraryItem.id}"`, error.message || error) + return false + }) +} + +/** + * + * @param {import('../../Database')} Database + * @param {number} [offset=0] + * @param {number} [totalCreated=0] + */ +async function runMigration(Database, offset = 0, totalCreated = 0) { + const libraryItems = await Database.libraryItemModel.getLibraryItemsIncrement(offset, 500, { isMissing: false }) + if (!libraryItems.length) return totalCreated + + let numCreated = 0 + for (const libraryItem of libraryItems) { + const success = await writeMetadataFileForItem(libraryItem) + if (success) numCreated++ + } + + if (libraryItems.length < 500) { + return totalCreated + numCreated + } + return runMigration(Database, offset + libraryItems.length, totalCreated + numCreated) +} + +/** + * + * @param {import('../../Database')} Database + */ +module.exports.migrate = async (Database) => { + Logger.info(`[absMetadataMigration] Starting metadata.json migration`) + const totalCreated = await runMigration(Database) + Logger.info(`[absMetadataMigration] Finished metadata.json migration (${totalCreated} files created)`) +} \ No newline at end of file From 5a70c0d7bee676102d2b17c296fcf328f7248249 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 22 Oct 2023 16:40:12 -0500 Subject: [PATCH 60/84] Fix:Authors page books hide radio button on hover --- client/components/cards/LazyBookCard.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index d3f956ec..1b87df0f 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -68,7 +68,8 @@ <span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span> </div> - <div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick"> + <!-- Radio button --> + <div v-if="!isAuthorBookshelfView" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick"> <span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span> </div> From c4c12836a4c512b82c560a3fdfe8fa427edf3a2b Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 22 Oct 2023 17:04:45 -0500 Subject: [PATCH 61/84] Fix:Version in bottom left of siderail overlapping buttons #2195 --- client/components/app/ConfigSideNav.vue | 6 +- client/components/app/SideRail.vue | 161 ++++++++++++---------- client/components/widgets/CloseButton.vue | 33 ----- client/layouts/default.vue | 2 - 4 files changed, 89 insertions(+), 113 deletions(-) delete mode 100644 client/components/widgets/CloseButton.vue diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index 50e440d7..267aabaa 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -14,10 +14,10 @@ </div> <div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }"> - <div class="flex justify-between"> - <p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p> + <div class="flex items-center justify-between"> + <button type="button" class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</button> - <p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p> + <p class="text-xs text-gray-300 italic">{{ Source }}</p> </div> <a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a> </div> diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index 995f4c23..deb96a6c 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -3,117 +3,119 @@ <!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar --> <div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" /> - <nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> - </svg> + <div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden"> + <nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> + </svg> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p> - <div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="material-icons text-2xl">format_list_bulleted</span> + <nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="material-icons text-2xl">format_list_bulleted</span> - <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p> + <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p> - <div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> - </svg> + <nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> + </svg> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p> - <div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" /> - </svg> + <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" /> + </svg> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p> - <div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="material-icons-outlined text-2xl">collections_bookmark</span> + <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="material-icons-outlined text-2xl">collections_bookmark</span> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p> - <div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="material-icons text-2.5xl">queue_music</span> + <nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="material-icons text-2.5xl">queue_music</span> - <p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p> + <p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p> - <div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <svg class="w-6 h-6" viewBox="0 0 24 24"> - <path - fill="currentColor" - d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z" - /> - </svg> + <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg class="w-6 h-6" viewBox="0 0 24 24"> + <path + fill="currentColor" + d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z" + /> + </svg> - <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p> + <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p> - <div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="material-icons text-2xl">record_voice_over</span> + <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="material-icons text-2xl">record_voice_over</span> - <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p> + <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p> - <div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="abs-icons icon-podcast text-xl"></span> + <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="abs-icons icon-podcast text-xl"></span> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p> - <div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="material-icons-outlined text-xl">album</span> + <nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="material-icons-outlined text-xl">album</span> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p> - <div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="material-icons text-2xl">file_download</span> + <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="material-icons text-2xl">file_download</span> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p> - <div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'"> - <span class="material-icons text-2xl">warning</span> + <nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'"> + <span class="material-icons text-2xl">warning</span> - <p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p> - <div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - <div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center"> - <p class="text-xs font-mono pb-0.5">{{ numIssues }}</p> - </div> - </nuxt-link> + <div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + <div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center"> + <p class="text-xs font-mono pb-0.5">{{ numIssues }}</p> + </div> + </nuxt-link> + </div> - <div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }"> + <div class="w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0" :style="{ bottom: streamLibraryItem ? '224px' : '65px' }"> <p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p> <a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a> <p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p> @@ -235,3 +237,12 @@ export default { mounted() {} } </script> + +<style> +#siderail-buttons-container { + max-height: calc(100vh - 64px - 48px); +} +#siderail-buttons-container.player-open { + max-height: calc(100vh - 64px - 48px - 160px); +} +</style> \ No newline at end of file diff --git a/client/components/widgets/CloseButton.vue b/client/components/widgets/CloseButton.vue deleted file mode 100644 index a9a61e1b..00000000 --- a/client/components/widgets/CloseButton.vue +++ /dev/null @@ -1,33 +0,0 @@ -<template> - <button class="bg-error text-white px-2 py-1 shadow-md" @click="$emit('click', $event)">Cancel</button> -</template> - -<script> -export default { - data() { - return {} - }, - computed: {}, - methods: {}, - mounted() {} -} -</script> - -<style> -.Vue-Toastification__close-button.cancel-scan-btn { - background-color: rgb(255, 82, 82); - color: white; - font-size: 0.9rem; - opacity: 1; - padding: 0px 10px; - border-radius: 6px; - font-weight: normal; - font-family: 'Open Sans'; - margin-left: 10px; - opacity: 0.3; -} -.Vue-Toastification__close-button.cancel-scan-btn:hover { - background-color: rgb(235, 65, 65); - opacity: 1; -} -</style> \ No newline at end of file diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 4f5e0fea..df8f754a 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -25,8 +25,6 @@ </template> <script> -import CloseButton from '@/components/widgets/CloseButton' - export default { middleware: 'authenticated', data() { From 976ae502bbbf42a24c6c7ce1a3f7a46b7d9ec73d Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Mon, 23 Oct 2023 21:48:34 +0000 Subject: [PATCH 62/84] Fix incorrect subpath checks --- server/Watcher.js | 6 +++--- server/utils/fileUtils.js | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/server/Watcher.js b/server/Watcher.js index 3ce6a5f5..f348ce8e 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -6,7 +6,7 @@ const LibraryScanner = require('./scanner/LibraryScanner') const Task = require('./objects/Task') const TaskManager = require('./managers/TaskManager') -const { filePathToPOSIX } = require('./utils/fileUtils') +const { filePathToPOSIX, isSameOrSubPath } = require('./utils/fileUtils') /** * @typedef PendingFileUpdate @@ -183,7 +183,7 @@ class FolderWatcher extends EventEmitter { } // Get file folder - const folder = libwatcher.folders.find(fold => path.startsWith(filePathToPOSIX(fold.fullPath))) + const folder = libwatcher.folders.find(fold => isSameOrSubPath(fold.fullPath, path)) if (!folder) { Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`) return @@ -233,7 +233,7 @@ class FolderWatcher extends EventEmitter { checkShouldIgnorePath(path) { return !!this.ignoreDirs.find(dirpath => { - return filePathToPOSIX(path).startsWith(dirpath) + return isSameOrSubPath(dirpath, path) }) } diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 4df26400..7ee16d56 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -19,6 +19,18 @@ const filePathToPOSIX = (path) => { } module.exports.filePathToPOSIX = filePathToPOSIX +function isSameOrSubPath(parentPath, childPath) { + parentPath = filePathToPOSIX(parentPath) + childPath = filePathToPOSIX(childPath) + if (parentPath === childPath) return true + const relativePath = Path.relative(parentPath, childPath) + return ( + relativePath === '' // Same path (e.g. parentPath = '/a/b/', childPath = '/a/b') + || !relativePath.startsWith('..') && !Path.isAbsolute(relativePath) // Sub path + ) +} +module.exports.isSameOrSubPath = isSameOrSubPath + async function getFileStat(path) { try { var stat = await fs.stat(path) From 9a477a92705f5e3f31df5e6959b0956f20e98136 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 23 Oct 2023 17:28:59 -0500 Subject: [PATCH 63/84] Add jsdocs --- server/utils/fileUtils.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 7ee16d56..19735fb7 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -19,6 +19,13 @@ const filePathToPOSIX = (path) => { } module.exports.filePathToPOSIX = filePathToPOSIX +/** + * Check path is a child of or equal to another path + * + * @param {string} parentPath + * @param {string} childPath + * @returns {boolean} + */ function isSameOrSubPath(parentPath, childPath) { parentPath = filePathToPOSIX(parentPath) childPath = filePathToPOSIX(childPath) From 32616aa4416b4eac6493191812d8ef0d35919b99 Mon Sep 17 00:00:00 2001 From: MxMarx <ruby.e.marx@gmail.com> Date: Mon, 23 Oct 2023 20:37:51 -0700 Subject: [PATCH 64/84] show a modal with cover images when clicked --- client/components/app/StreamContainer.vue | 2 +- client/components/covers/BookCover.vue | 19 ++++++++++++++++++- client/pages/item/_id/index.vue | 8 ++++---- client/store/globals.js | 4 ++-- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/client/components/app/StreamContainer.vue b/client/components/app/StreamContainer.vue index 1aecbf4e..3439910f 100644 --- a/client/components/app/StreamContainer.vue +++ b/client/components/app/StreamContainer.vue @@ -2,7 +2,7 @@ <div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2"> <div id="videoDock" /> <nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer"> - <covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" /> + <covers-book-cover :expand-on-click="true" :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" /> </nuxt-link> <div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'"> <div class="min-w-0"> diff --git a/client/components/covers/BookCover.vue b/client/components/covers/BookCover.vue index be39ae3c..810baa43 100644 --- a/client/components/covers/BookCover.vue +++ b/client/components/covers/BookCover.vue @@ -5,7 +5,14 @@ <div class="absolute cover-bg" ref="coverBg" /> </div> - <img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" /> + <img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" draggable="false" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" @click="clickCover" /> + + <modals-modal v-if="libraryItem && expandOnClick" v-model="showImageModal" name="cover" :width="'90%'" :height="'90%'" :contentMarginTop="0"> + <div class="w-full h-full" @click="showImageModal = false"> + <img loading="lazy" :src="rawCoverUrl" class="w-full h-full z-10 object-scale-down" /> + </div> + </modals-modal> + <div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> <p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p> <div class="absolute top-2 right-2"> @@ -43,10 +50,12 @@ export default { type: Number, default: 120 }, + expandOnClick: Boolean, bookCoverAspectRatio: Number }, data() { return { + showImageModal: false, loading: true, imageFailed: false, showCoverBg: false, @@ -102,6 +111,11 @@ export default { var store = this.$store || this.$nuxt.$store return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl) }, + rawCoverUrl() { + if (!this.libraryItem) return null + var store = this.$store || this.$nuxt.$store + return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, null, true) + }, cover() { return this.media.coverPath || this.placeholderUrl }, @@ -132,6 +146,9 @@ export default { } }, methods: { + clickCover() { + this.showImageModal = true + }, setCoverBg() { if (this.$refs.coverBg) { this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")` diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 0f4f17b2..eff240f7 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -3,21 +3,21 @@ <div class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8"> <div class="flex flex-col lg:flex-row max-w-6xl mx-auto"> <div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px"> - <div class="relative" style="height: fit-content"> - <covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> + <div class="relative group" style="height: fit-content"> + <covers-book-cover class="relative group-hover:brightness-75 transition" :expand-on-click="true" :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <!-- Item Progress Bar --> <div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div> <!-- Item Cover Overlay --> - <div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent> + <div class="absolute top-0 left-0 w-full h-full z-10 pointer-events-none"> <div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none"> <div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem"> <span class="material-icons text-4xl">play_circle_filled</span> </div> </div> - <span class="absolute bottom-2.5 right-2.5 z-10 material-icons text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200" @click="showEditCover">edit</span> + <span class="absolute bottom-2.5 right-2.5 z-10 material-icons text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" @click="showEditCover">edit</span> </div> </div> </div> diff --git a/client/store/globals.js b/client/store/globals.js index a202d685..44b35f88 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -80,7 +80,7 @@ export const state = () => ({ }) export const getters = { - getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = null) => { + getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = null, raw = false) => { if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg` if (!libraryItem) return placeholder const media = libraryItem.media @@ -94,7 +94,7 @@ export const getters = { const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers if (process.env.NODE_ENV !== 'production') { // Testing - return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}` + return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}${raw ? '&raw=1' : ''}` } return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}` From e054b9a54ca392930901695e2a74b2e306c7a5b0 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Tue, 24 Oct 2023 13:35:43 +0000 Subject: [PATCH 65/84] Add API to update a path on a watched library folder --- server/controllers/MiscController.js | 48 ++++++++++++++++++++++++++++ server/routers/ApiRouter.js | 1 + 2 files changed, 49 insertions(+) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index ffa4e2c2..fb6124df 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -527,6 +527,54 @@ class MiscController { }) } + /** + * POST: /api/watcher/update + * Update a watch path + * Req.body { libraryId, path, type, [oldPath] } + * type = add, unlink, rename + * oldPath = required only for rename + * @param {*} req + * @param {*} res + */ + updateWatchedPath(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user attempted to updateWatchedPath`) + return res.sendStatus(404) + } + + const libraryId = req.body.libraryId + const path = req.body.path + const type = req.body.type + if (!libraryId || !path || !type) { + Logger.error(`[MiscController] Invalid request body for updateWatchedPath. libraryId: "${libraryId}", path: "${path}", type: "${type}"`) + return res.sendStatus(400) + } + + switch (type) { + case 'add': + this.watcher.onNewFile(libraryId, path) + break; + case 'unlink': + this.watcher.onFileRemoved(libraryId, path) + break; + case 'rename': + const oldPath = req.body.oldPath + if (!oldPath) { + Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`) + return res.sendStatus(400) + } + this.watcher.onRename(libraryId, oldPath, path) + break; + default: + Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`) + return res.sendStatus(400) + } + + res.sendStatus(200) + + } + + validateCronExpression(req, res) { const expression = req.body.expression if (!expression) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index b40c3d80..c4ac0327 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -308,6 +308,7 @@ class ApiRouter { this.router.post('/genres/rename', MiscController.renameGenre.bind(this)) this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this)) this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this)) + this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this)) } async getDirectories(dir, relpath, excludedDirs, level = 0) { From ef1cdf6ad231b5fefff8b83915cfacffd84db945 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 24 Oct 2023 17:04:54 -0500 Subject: [PATCH 66/84] Fix:Only show authors with books for users #2250 --- server/controllers/LibraryController.js | 2 +- server/utils/queries/libraryFilters.js | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 10a77b2a..d2090270 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -621,7 +621,7 @@ class LibraryController { model: Database.bookModel, attributes: ['id', 'tags', 'explicit'], where: bookWhere, - required: false, + required: !req.user.isAdminOrUp, // Only show authors with 0 books for admin users or up through: { attributes: [] } diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 6ba6ec5e..785124a9 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -308,6 +308,8 @@ module.exports = { async getNewestAuthors(library, user, limit) { if (library.mediaType !== 'book') return { authors: [], count: 0 } + const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(user) + const { rows: authors, count } = await Database.authorModel.findAndCountAll({ where: { libraryId: library.id, @@ -315,9 +317,15 @@ module.exports = { [Sequelize.Op.gte]: new Date(new Date() - (60 * 24 * 60 * 60 * 1000)) // 60 days ago } }, + replacements, include: { - model: Database.bookAuthorModel, - required: true // Must belong to a book + model: Database.bookModel, + attributes: ['id', 'tags', 'explicit'], + where: bookWhere, + required: true, // Must belong to a book + through: { + attributes: [] + } }, limit, distinct: true, @@ -328,7 +336,7 @@ module.exports = { return { authors: authors.map((au) => { - const numBooks = au.bookAuthors?.length || 0 + const numBooks = au.books.length || 0 return au.getOldAuthor().toJSONExpanded(numBooks) }), count From 8dc44901699763052db295321e0adbb4eeec0798 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 25 Oct 2023 16:53:53 -0500 Subject: [PATCH 67/84] Fix:Watcher waits for files to finish transferring before scanning #1362 #2248 --- server/Watcher.js | 90 +++++++++++++++++++++++++++++++++------ server/utils/fileUtils.js | 37 +++++++++------- 2 files changed, 97 insertions(+), 30 deletions(-) diff --git a/server/Watcher.js b/server/Watcher.js index f348ce8e..99318a7e 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -6,7 +6,7 @@ const LibraryScanner = require('./scanner/LibraryScanner') const Task = require('./objects/Task') const TaskManager = require('./managers/TaskManager') -const { filePathToPOSIX, isSameOrSubPath } = require('./utils/fileUtils') +const { filePathToPOSIX, isSameOrSubPath, getFileMTimeMs } = require('./utils/fileUtils') /** * @typedef PendingFileUpdate @@ -29,6 +29,8 @@ class FolderWatcher extends EventEmitter { /** @type {Task} */ this.pendingTask = null + this.filesBeingAdded = new Set() + /** @type {string[]} */ this.ignoreDirs = [] /** @type {string[]} */ @@ -64,14 +66,13 @@ class FolderWatcher extends EventEmitter { }) watcher .on('add', (path) => { - this.onNewFile(library.id, path) + this.onFileAdded(library.id, filePathToPOSIX(path)) }).on('change', (path) => { // This is triggered from metadata changes, not what we want - // this.onFileUpdated(path) }).on('unlink', path => { - this.onFileRemoved(library.id, path) + this.onFileRemoved(library.id, filePathToPOSIX(path)) }).on('rename', (path, pathNext) => { - this.onRename(library.id, path, pathNext) + this.onFileRename(library.id, filePathToPOSIX(path), filePathToPOSIX(pathNext)) }).on('error', (error) => { Logger.error(`[Watcher] ${error}`) }).on('ready', () => { @@ -137,14 +138,31 @@ class FolderWatcher extends EventEmitter { return this.libraryWatchers.map(lib => lib.watcher.close()) } - onNewFile(libraryId, path) { + /** + * Watcher detected file added + * + * @param {string} libraryId + * @param {string} path + */ + onFileAdded(libraryId, path) { if (this.checkShouldIgnorePath(path)) { return } Logger.debug('[Watcher] File Added', path) this.addFileUpdate(libraryId, path, 'added') + + if (!this.filesBeingAdded.has(path)) { + this.filesBeingAdded.add(path) + this.waitForFileToAdd(path) + } } + /** + * Watcher detected file removed + * + * @param {string} libraryId + * @param {string} path + */ onFileRemoved(libraryId, path) { if (this.checkShouldIgnorePath(path)) { return @@ -153,11 +171,13 @@ class FolderWatcher extends EventEmitter { this.addFileUpdate(libraryId, path, 'deleted') } - onFileUpdated(path) { - Logger.debug('[Watcher] Updated File', path) - } - - onRename(libraryId, pathFrom, pathTo) { + /** + * Watcher detected file renamed + * + * @param {string} libraryId + * @param {string} path + */ + onFileRename(libraryId, pathFrom, pathTo) { if (this.checkShouldIgnorePath(pathTo)) { return } @@ -166,13 +186,41 @@ class FolderWatcher extends EventEmitter { } /** - * File update detected from watcher + * Get mtimeMs from an added file every second until it is no longer changing + * Times out after 180s + * + * @param {string} path + * @param {number} [lastMTimeMs=0] + * @param {number} [loop=0] + */ + async waitForFileToAdd(path, lastMTimeMs = 0, loop = 0) { + // Safety to catch infinite loop (180s) + if (loop >= 180) { + Logger.warn(`[Watcher] Waiting to add file at "${path}" timeout (loop ${loop}) - proceeding`) + return this.filesBeingAdded.delete(path) + } + + const mtimeMs = await getFileMTimeMs(path) + if (mtimeMs === lastMTimeMs) { + if (lastMTimeMs) Logger.debug(`[Watcher] File finished adding at "${path}"`) + return this.filesBeingAdded.delete(path) + } + if (lastMTimeMs % 5 === 0) { + Logger.debug(`[Watcher] Waiting to add file at "${path}". mtimeMs=${mtimeMs} lastMTimeMs=${lastMTimeMs} (loop ${loop})`) + } + // Wait 1 second + await new Promise((resolve) => setTimeout(resolve, 1000)) + this.waitForFileToAdd(path, mtimeMs, ++loop) + } + + /** + * Queue file update + * * @param {string} libraryId * @param {string} path * @param {string} type */ addFileUpdate(libraryId, path, type) { - path = filePathToPOSIX(path) if (this.pendingFilePaths.includes(path)) return // Get file library @@ -222,12 +270,26 @@ class FolderWatcher extends EventEmitter { type }) - // Notify server of update after "pendingDelay" + this.handlePendingFileUpdatesTimeout() + } + + /** + * Wait X seconds before notifying scanner that files changed + * reset timer if files are still copying + */ + handlePendingFileUpdatesTimeout() { clearTimeout(this.pendingTimeout) this.pendingTimeout = setTimeout(() => { + // Check that files are not still being added + if (this.pendingFileUpdates.some(pfu => this.filesBeingAdded.has(pfu.path))) { + Logger.debug(`[Watcher] Still waiting for pending files "${[...this.filesBeingAdded].join(', ')}"`) + return this.handlePendingFileUpdatesTimeout() + } + LibraryScanner.scanFilesChanged(this.pendingFileUpdates, this.pendingTask) this.pendingTask = null this.pendingFileUpdates = [] + this.filesBeingAdded.clear() }, this.pendingDelay) } diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 19735fb7..26578f57 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -38,22 +38,14 @@ function isSameOrSubPath(parentPath, childPath) { } module.exports.isSameOrSubPath = isSameOrSubPath -async function getFileStat(path) { +function getFileStat(path) { try { - var stat = await fs.stat(path) - return { - size: stat.size, - atime: stat.atime, - mtime: stat.mtime, - ctime: stat.ctime, - birthtime: stat.birthtime - } + return fs.stat(path) } catch (err) { Logger.error('[fileUtils] Failed to stat', err) - return false + return null } } -module.exports.getFileStat = getFileStat async function getFileTimestampsWithIno(path) { try { @@ -72,12 +64,25 @@ async function getFileTimestampsWithIno(path) { } module.exports.getFileTimestampsWithIno = getFileTimestampsWithIno -async function getFileSize(path) { - var stat = await getFileStat(path) - if (!stat) return 0 - return stat.size || 0 +/** + * Get file size + * + * @param {string} path + * @returns {Promise<number>} + */ +module.exports.getFileSize = async (path) => { + return (await getFileStat(path))?.size || 0 +} + +/** + * Get file mtimeMs + * + * @param {string} path + * @returns {Promise<number>} epoch timestamp + */ +module.exports.getFileMTimeMs = async (path) => { + return (await getFileStat(path))?.mtimeMs || 0 } -module.exports.getFileSize = getFileSize /** * From 24228b442419109521f1884cbf713a1b30f1737e Mon Sep 17 00:00:00 2001 From: MxMarx <ruby.e.marx@gmail.com> Date: Thu, 26 Oct 2023 02:01:40 -0700 Subject: [PATCH 68/84] Option to change the font family in epub viewer --- client/components/readers/EpubReader.vue | 2 ++ client/components/readers/Reader.vue | 41 +++++++++++++++++------- client/strings/da.json | 1 + client/strings/de.json | 1 + client/strings/en-us.json | 1 + client/strings/es.json | 1 + client/strings/fr.json | 1 + client/strings/gu.json | 1 + client/strings/hi.json | 1 + client/strings/lt.json | 1 + client/strings/nl.json | 1 + client/strings/no.json | 1 + client/strings/pl.json | 1 + client/strings/ru.json | 1 + client/strings/zh-cn.json | 1 + 15 files changed, 45 insertions(+), 11 deletions(-) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index fba30ec9..7cc3c33a 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -42,6 +42,7 @@ export default { rendition: null, ereaderSettings: { theme: 'dark', + font: 'serif', fontScale: 100, lineSpacing: 115, spread: 'auto' @@ -130,6 +131,7 @@ export default { const fontScale = settings.fontScale || 100 this.rendition.themes.fontSize(`${fontScale}%`) + this.rendition.themes.font(settings.font) this.rendition.spread(settings.spread || 'auto') }, prev() { diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue index 120bb400..569ff84f 100644 --- a/client/components/readers/Reader.vue +++ b/client/components/readers/Reader.vue @@ -63,7 +63,13 @@ <div class="w-40"> <p class="text-lg">{{ $strings.LabelTheme }}:</p> </div> - <ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" /> + <ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems.theme" @input="settingsUpdated" /> + </div> + <div class="flex items-center mb-4"> + <div class="w-40"> + <p class="text-lg">{{ $strings.LabelFontFamily }}:</p> + </div> + <ui-toggle-btns v-model="ereaderSettings.font" :items="themeItems.font" @input="settingsUpdated" /> </div> <div class="flex items-center mb-4"> <div class="w-40"> @@ -103,6 +109,7 @@ export default { showSettings: false, ereaderSettings: { theme: 'dark', + font: 'serif', fontScale: 100, lineSpacing: 115, spread: 'auto' @@ -142,16 +149,28 @@ export default { ] }, themeItems() { - return [ - { - text: this.$strings.LabelThemeDark, - value: 'dark' - }, - { - text: this.$strings.LabelThemeLight, - value: 'light' - } - ] + return { + theme: [ + { + text: this.$strings.LabelThemeDark, + value: 'dark' + }, + { + text: this.$strings.LabelThemeLight, + value: 'light' + } + ], + font: [ + { + text: 'Sans', + value: 'sans-serif', + }, + { + text: 'Serif', + value: 'serif', + } + ] + } }, componentName() { if (this.ebookType === 'epub') return 'readers-epub-reader' diff --git a/client/strings/da.json b/client/strings/da.json index adf138a1..3197cc3c 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -260,6 +260,7 @@ "LabelFinished": "Færdig", "LabelFolder": "Mappe", "LabelFolders": "Mapper", + "LabelFontFamily": "Fontfamilie", "LabelFontScale": "Skriftstørrelse", "LabelFormat": "Format", "LabelGenre": "Genre", diff --git a/client/strings/de.json b/client/strings/de.json index a072a549..942cad8b 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -260,6 +260,7 @@ "LabelFinished": "beendet", "LabelFolder": "Ordner", "LabelFolders": "Verzeichnisse", + "LabelFontFamily": "Schriftfamilie", "LabelFontScale": "Schriftgröße", "LabelFormat": "Format", "LabelGenre": "Kategorie", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 24d07726..9e69aa4e 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -260,6 +260,7 @@ "LabelFinished": "Finished", "LabelFolder": "Folder", "LabelFolders": "Folders", + "LabelFontFamily": "Font family", "LabelFontScale": "Font scale", "LabelFormat": "Format", "LabelGenre": "Genre", diff --git a/client/strings/es.json b/client/strings/es.json index 4b37139d..b04815ab 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -260,6 +260,7 @@ "LabelFinished": "Terminado", "LabelFolder": "Carpeta", "LabelFolders": "Carpetas", + "LabelFontFamily": "Familia tipográfica", "LabelFontScale": "Tamaño de Fuente", "LabelFormat": "Formato", "LabelGenre": "Genero", diff --git a/client/strings/fr.json b/client/strings/fr.json index 28bdf743..11fa1468 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -260,6 +260,7 @@ "LabelFinished": "Fini(e)", "LabelFolder": "Dossier", "LabelFolders": "Dossiers", + "LabelFontFamily": "Famille de polices", "LabelFontScale": "Taille de la police de caractère", "LabelFormat": "Format", "LabelGenre": "Genre", diff --git a/client/strings/gu.json b/client/strings/gu.json index 8593a95d..b3de487a 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -260,6 +260,7 @@ "LabelFinished": "Finished", "LabelFolder": "Folder", "LabelFolders": "Folders", + "LabelFontFamily": "ફોન્ટ કુટુંબ", "LabelFontScale": "Font scale", "LabelFormat": "Format", "LabelGenre": "Genre", diff --git a/client/strings/hi.json b/client/strings/hi.json index 82d25986..d05c1e85 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -260,6 +260,7 @@ "LabelFinished": "Finished", "LabelFolder": "Folder", "LabelFolders": "Folders", + "LabelFontFamily": "फुहारा परिवार", "LabelFontScale": "Font scale", "LabelFormat": "Format", "LabelGenre": "Genre", diff --git a/client/strings/lt.json b/client/strings/lt.json index dee54e12..0623a7ab 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -260,6 +260,7 @@ "LabelFinished": "Baigta", "LabelFolder": "Aplankas", "LabelFolders": "Aplankai", + "LabelFontFamily": "Famiglia di font", "LabelFontScale": "Šrifto mastelis", "LabelFormat": "Formatas", "LabelGenre": "Žanras", diff --git a/client/strings/nl.json b/client/strings/nl.json index 62696dce..659e3ec5 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -260,6 +260,7 @@ "LabelFinished": "Voltooid", "LabelFolder": "Map", "LabelFolders": "Mappen", + "LabelFontFamily": "Lettertypefamilie", "LabelFontScale": "Lettertype schaal", "LabelFormat": "Formaat", "LabelGenre": "Genre", diff --git a/client/strings/no.json b/client/strings/no.json index dc7685ee..5bf537f2 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -260,6 +260,7 @@ "LabelFinished": "Fullført", "LabelFolder": "Mappe", "LabelFolders": "Mapper", + "LabelFontFamily": "Fontfamilie", "LabelFontScale": "Font størrelse", "LabelFormat": "Format", "LabelGenre": "Sjanger", diff --git a/client/strings/pl.json b/client/strings/pl.json index c4fb50f8..16a0970b 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -260,6 +260,7 @@ "LabelFinished": "Zakończone", "LabelFolder": "Folder", "LabelFolders": "Foldery", + "LabelFontFamily": "Rodzina czcionek", "LabelFontScale": "Font scale", "LabelFormat": "Format", "LabelGenre": "Gatunek", diff --git a/client/strings/ru.json b/client/strings/ru.json index 69868bca..478ac33a 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -260,6 +260,7 @@ "LabelFinished": "Закончен", "LabelFolder": "Папка", "LabelFolders": "Папки", + "LabelFontFamily": "Семейство шрифтов", "LabelFontScale": "Масштаб шрифта", "LabelFormat": "Формат", "LabelGenre": "Жанр", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 219e861a..ded2c9e2 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -260,6 +260,7 @@ "LabelFinished": "已听完", "LabelFolder": "文件夹", "LabelFolders": "文件夹", + "LabelFontFamily": "字体系列", "LabelFontScale": "字体比例", "LabelFormat": "编码格式", "LabelGenre": "流派", From 0c23da7b028dbea3978949ad3863360c2883e8c7 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 26 Oct 2023 16:31:47 -0500 Subject: [PATCH 69/84] Add missing translations --- client/strings/hr.json | 1 + client/strings/it.json | 1 + 2 files changed, 2 insertions(+) diff --git a/client/strings/hr.json b/client/strings/hr.json index e9a323ee..32213095 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -260,6 +260,7 @@ "LabelFinished": "Finished", "LabelFolder": "Folder", "LabelFolders": "Folderi", + "LabelFontFamily": "Font family", "LabelFontScale": "Font scale", "LabelFormat": "Format", "LabelGenre": "Genre", diff --git a/client/strings/it.json b/client/strings/it.json index f73b3ffc..8de1f4ec 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -260,6 +260,7 @@ "LabelFinished": "Finita", "LabelFolder": "Cartella", "LabelFolders": "Cartelle", + "LabelFontFamily": "Font family", "LabelFontScale": "Dimensione Font", "LabelFormat": "Formato", "LabelGenre": "Genere", From f9c4dd24574600c317cf0b992de0defde4eca73b Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 26 Oct 2023 16:41:54 -0500 Subject: [PATCH 70/84] Update watcher function calls, add js docs --- server/controllers/MiscController.js | 24 ++++++++++++------------ server/routers/ApiRouter.js | 1 + 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index fb6124df..f4f1703d 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -528,14 +528,16 @@ class MiscController { } /** - * POST: /api/watcher/update - * Update a watch path - * Req.body { libraryId, path, type, [oldPath] } - * type = add, unlink, rename - * oldPath = required only for rename - * @param {*} req - * @param {*} res - */ + * POST: /api/watcher/update + * Update a watch path + * Req.body { libraryId, path, type, [oldPath] } + * type = add, unlink, rename + * oldPath = required only for rename + * @this import('../routers/ApiRouter') + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ updateWatchedPath(req, res) { if (!req.user.isAdminOrUp) { Logger.error(`[MiscController] Non-admin user attempted to updateWatchedPath`) @@ -552,7 +554,7 @@ class MiscController { switch (type) { case 'add': - this.watcher.onNewFile(libraryId, path) + this.watcher.onFileAdded(libraryId, path) break; case 'unlink': this.watcher.onFileRemoved(libraryId, path) @@ -563,7 +565,7 @@ class MiscController { Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`) return res.sendStatus(400) } - this.watcher.onRename(libraryId, oldPath, path) + this.watcher.onFileRename(libraryId, oldPath, path) break; default: Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`) @@ -571,10 +573,8 @@ class MiscController { } res.sendStatus(200) - } - validateCronExpression(req, res) { const expression = req.body.expression if (!expression) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index c4ac0327..41b24716 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -39,6 +39,7 @@ class ApiRouter { this.playbackSessionManager = Server.playbackSessionManager this.abMergeManager = Server.abMergeManager this.backupManager = Server.backupManager + /** @type {import('../Watcher')} */ this.watcher = Server.watcher this.podcastManager = Server.podcastManager this.audioMetadataManager = Server.audioMetadataManager From 5778200c8fafd569dc36626f0f67ced247ab6cc5 Mon Sep 17 00:00:00 2001 From: MxMarx <ruby.e.marx@gmail.com> Date: Fri, 27 Oct 2023 00:14:46 -0700 Subject: [PATCH 71/84] Make epubs searchable --- client/components/readers/EpubReader.vue | 88 ++++++++++++++++++++++-- client/components/readers/Reader.vue | 45 +++++++++--- client/components/ui/TextInput.vue | 1 + 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 7cc3c33a..11e7bf9e 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -40,6 +40,7 @@ export default { book: null, /** @type {ePub.Rendition} */ rendition: null, + chapters: [], ereaderSettings: { theme: 'dark', font: 'serif', @@ -68,10 +69,6 @@ export default { hasNext() { return !this.rendition?.location?.atEnd }, - /** @returns {Array<ePub.NavItem>} */ - chapters() { - return this.book?.navigation?.toc || [] - }, userMediaProgress() { if (!this.libraryItemId) return return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) @@ -146,6 +143,40 @@ export default { if (!this.rendition?.manager) return return this.rendition?.display(href) }, + /** @returns {object} Returns the chapter that the `position` in the book is in */ + findChapterFromPosition(chapters, position) { + let foundChapter + for (let i = 0; i < chapters.length; i++) { + if (position >= chapters[i].start && (!chapters[i + 1] || position < chapters[i + 1].start)) { + foundChapter = chapters[i] + if (chapters[i].subitems && chapters[i].subitems.length > 0) { + return this.findChapterFromPosition(chapters[i].subitems, position, foundChapter) + } + break + } + } + return foundChapter + }, + /** @returns {Array} Returns an array of chapters that only includes chapters with query results */ + async searchBook(query) { + const chapters = structuredClone(await this.chapters) + const searchResults = await Promise.all(this.book.spine.spineItems.map((item) => item.load(this.book.load.bind(this.book)).then(item.find.bind(item, query)).finally(item.unload.bind(item)))) + const mergedResults = [].concat(...searchResults) + + mergedResults.forEach((chapter) => { + chapter.start = this.book.locations.percentageFromCfi(chapter.cfi) + const foundChapter = this.findChapterFromPosition(chapters, chapter.start) + if (foundChapter) foundChapter.searchResults.push(chapter) + }) + + let filteredResults = chapters.filter(function f(o) { + if (o.searchResults.length) return true + if (o.subitems.length) { + return (o.subitems = o.subitems.filter(f)).length + } + }) + return filteredResults + }, keyUp(e) { const rtl = this.book.package.metadata.direction === 'rtl' if ((e.keyCode || e.which) == 37) { @@ -319,6 +350,55 @@ export default { this.checkSaveLocations(reader.book.locations.save()) }) } + this.getChapters() + }) + }, + getChapters() { + // Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759 + const toc = this.book?.navigation?.toc || [] + + const tocTree = [] + + const resolveURL = (url, relativeTo) => { + // see https://github.com/futurepress/epub.js/issues/1084 + // HACK-ish: abuse the URL API a little to resolve the path + // the base needs to be a valid URL, or it will throw a TypeError, + // so we just set a random base URI and remove it later + const base = 'https://example.invalid/' + return new URL(url, base + relativeTo).href.replace(base, '') + } + + const basePath = this.book.packaging.navPath || this.book.packaging.ncxPath + + const createTree = async (toc, parent) => { + const promises = toc.map(async (tocItem, i) => { + const href = resolveURL(tocItem.href, basePath) + const id = href.split('#')[1] + const item = this.book.spine.get(href) + await item.load(this.book.load.bind(this.book)) + const el = id ? item.document.getElementById(id) : item.document.body + + const cfi = item.cfiFromElement(el) + + parent[i] = { + title: tocItem.label.trim(), + subitems: [], + href, + cfi, + start: this.book.locations.percentageFromCfi(cfi), + end: null, // set by flattenChapters() + id: null, // set by flattenChapters() + searchResults: [] + } + + if (tocItem.subitems) { + await createTree(tocItem.subitems, parent[i].subitems) + } + }) + await Promise.all(promises) + } + return createTree(toc, tocTree).then(() => { + this.chapters = tocTree }) }, resize() { diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue index 569ff84f..2a7b90cf 100644 --- a/client/components/readers/Reader.vue +++ b/client/components/readers/Reader.vue @@ -26,9 +26,9 @@ <component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" @touchstart="touchstart" @touchend="touchend" @hook:mounted="readerMounted" /> <!-- TOC side nav --> - <div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div> + <div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div> <div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC"> - <div class="p-4 h-full"> + <div class="flex flex-col p-4 h-full"> <div class="flex items-center mb-2"> <button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100"> <span class="material-icons text-2xl">arrow_back</span> @@ -36,13 +36,28 @@ <p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p> </div> - <div class="tocContent"> + <form @submit.prevent="searchBook" @click.stop.prevent> + <ui-text-input clearable ref="input" @submit="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" /> + </form> + + <div class="overflow-y-auto"> + <div v-if="isSearching && !this.searchResults.length" class="w-full h-40 justify-center"> + <p class="text-center text-xl py-4">{{ $strings.MessageNoResults }}</p> + </div> + <ul> - <li v-for="chapter in chapters" :key="chapter.id" class="py-1"> - <a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a> + <li v-for="chapter in isSearching ? this.searchResults : chapters" :key="chapter.id" class="py-1"> + <a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.title }}</a> + <div v-for="searchResults in chapter.searchResults" :key="searchResults.cfi" class="text-sm py-1 pl-4"> + <a :href="searchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(searchResults.cfi)">{{ searchResults.excerpt }}</a> + </div> + <ul v-if="chapter.subitems.length"> <li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4"> - <a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a> + <a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.title }}</a> + <div v-for="subChapterSearchResults in subchapter.searchResults" :key="subChapterSearchResults.cfi" class="text-sm py-1 pl-4"> + <a :href="subChapterSearchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subChapterSearchResults.cfi)">{{ subChapterSearchResults.excerpt }}</a> + </div> </li> </ul> </li> @@ -105,6 +120,9 @@ export default { touchstartTime: 0, touchIdentifier: null, chapters: [], + isSearching: false, + searchResults: [], + searchQuery: '', tocOpen: false, showSettings: false, ereaderSettings: { @@ -281,6 +299,15 @@ export default { this.close() } }, + async searchBook() { + if (this.searchQuery.length > 1) { + this.searchResults = await this.$refs.readerComponent.searchBook(this.searchQuery) + this.isSearching = true + } else { + this.isSearching = false + this.searchResults = [] + } + }, next() { if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next() }, @@ -359,6 +386,8 @@ export default { }, close() { this.unregisterListeners() + this.isSearching = false + this.searchQuery = '' this.show = false } }, @@ -372,10 +401,6 @@ export default { </script> <style> -.tocContent { - height: calc(100% - 36px); - overflow-y: auto; -} #reader { height: 100%; } diff --git a/client/components/ui/TextInput.vue b/client/components/ui/TextInput.vue index c347eea3..56825491 100644 --- a/client/components/ui/TextInput.vue +++ b/client/components/ui/TextInput.vue @@ -68,6 +68,7 @@ export default { methods: { clear() { this.inputValue = '' + this.$emit('submit') }, focused() { this.isFocused = true From 4229cb7fb6fce179c796b80417be8295fcd8f987 Mon Sep 17 00:00:00 2001 From: MxMarx <ruby.e.marx@gmail.com> Date: Fri, 27 Oct 2023 00:35:28 -0700 Subject: [PATCH 72/84] Added a method to unwrap the chapter list --- client/components/readers/EpubReader.vue | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 11e7bf9e..aa11d162 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -401,6 +401,26 @@ export default { this.chapters = tocTree }) }, + flattenChapters(chapters) { + // Convert the nested epub chapters into something that looks like audiobook chapters for player-ui + const unwrap = (chapters) => { + return chapters.reduce((acc, chapter) => { + return chapter.subitems ? [...acc, chapter, ...unwrap(chapter.subitems)] : [...acc, chapter] + }, []) + } + let flattenedChapters = unwrap(chapters) + + flattenedChapters = flattenedChapters.sort((a, b) => a.start - b.start) + for (let i = 0; i < flattenedChapters.length; i++) { + flattenedChapters[i].id = i + if (i < flattenedChapters.length - 1) { + flattenedChapters[i].end = flattenedChapters[i + 1].start + } else { + flattenedChapters[i].end = 1 + } + } + return flattenedChapters + }, resize() { this.windowWidth = window.innerWidth this.windowHeight = window.innerHeight From 6278bb86651d2148750d92cf87f4de56a187a3b6 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 27 Oct 2023 16:51:44 -0500 Subject: [PATCH 73/84] Move raw cover preview to a separate global component, fix item page cover overlay show on hover --- client/components/app/StreamContainer.vue | 6 ++-- client/components/covers/BookCover.vue | 16 ++------- .../modals/RawCoverPreviewModal.vue | 33 +++++++++++++++++++ client/layouts/default.vue | 1 + client/pages/item/_id/index.vue | 4 +-- client/store/globals.js | 9 +++++ 6 files changed, 51 insertions(+), 18 deletions(-) create mode 100644 client/components/modals/RawCoverPreviewModal.vue diff --git a/client/components/app/StreamContainer.vue b/client/components/app/StreamContainer.vue index 3439910f..e9b6969d 100644 --- a/client/components/app/StreamContainer.vue +++ b/client/components/app/StreamContainer.vue @@ -1,9 +1,9 @@ <template> <div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2"> <div id="videoDock" /> - <nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer"> - <covers-book-cover :expand-on-click="true" :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" /> - </nuxt-link> + <div class="absolute left-2 top-2 md:left-4 cursor-pointer"> + <covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" /> + </div> <div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'"> <div class="min-w-0"> <nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate"> diff --git a/client/components/covers/BookCover.vue b/client/components/covers/BookCover.vue index 810baa43..a2a4cc2f 100644 --- a/client/components/covers/BookCover.vue +++ b/client/components/covers/BookCover.vue @@ -7,12 +7,6 @@ <img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" draggable="false" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" @click="clickCover" /> - <modals-modal v-if="libraryItem && expandOnClick" v-model="showImageModal" name="cover" :width="'90%'" :height="'90%'" :contentMarginTop="0"> - <div class="w-full h-full" @click="showImageModal = false"> - <img loading="lazy" :src="rawCoverUrl" class="w-full h-full z-10 object-scale-down" /> - </div> - </modals-modal> - <div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> <p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p> <div class="absolute top-2 right-2"> @@ -55,7 +49,6 @@ export default { }, data() { return { - showImageModal: false, loading: true, imageFailed: false, showCoverBg: false, @@ -111,11 +104,6 @@ export default { var store = this.$store || this.$nuxt.$store return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl) }, - rawCoverUrl() { - if (!this.libraryItem) return null - var store = this.$store || this.$nuxt.$store - return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, null, true) - }, cover() { return this.media.coverPath || this.placeholderUrl }, @@ -147,7 +135,9 @@ export default { }, methods: { clickCover() { - this.showImageModal = true + if (this.expandOnClick && this.libraryItem) { + this.$store.commit('globals/setRawCoverPreviewModal', this.libraryItem.id) + } }, setCoverBg() { if (this.$refs.coverBg) { diff --git a/client/components/modals/RawCoverPreviewModal.vue b/client/components/modals/RawCoverPreviewModal.vue new file mode 100644 index 00000000..b8147aa7 --- /dev/null +++ b/client/components/modals/RawCoverPreviewModal.vue @@ -0,0 +1,33 @@ +<template> + <modals-modal v-model="show" name="cover" :width="'90%'" :height="'90%'" :contentMarginTop="0"> + <div class="w-full h-full" @click="show = false"> + <img loading="lazy" :src="rawCoverUrl" class="w-full h-full z-10 object-scale-down" /> + </div> + </modals-modal> +</template> + +<script> +export default { + data() { + return {} + }, + computed: { + show: { + get() { + return this.$store.state.globals.showRawCoverPreviewModal + }, + set(val) { + this.$store.commit('globals/setShowRawCoverPreviewModal', val) + } + }, + selectedLibraryItemId() { + return this.$store.state.globals.selectedLibraryItemId + }, + rawCoverUrl() { + return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.selectedLibraryItemId, null, true) + } + }, + methods: {}, + mounted() {} +} +</script> \ No newline at end of file diff --git a/client/layouts/default.vue b/client/layouts/default.vue index df8f754a..c3cc3484 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -19,6 +19,7 @@ <modals-authors-edit-modal /> <modals-batch-quick-match-model /> <modals-rssfeed-open-close-modal /> + <modals-raw-cover-preview-modal /> <prompt-confirm /> <readers-reader /> </div> diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index eff240f7..657d564d 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -4,13 +4,13 @@ <div class="flex flex-col lg:flex-row max-w-6xl mx-auto"> <div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px"> <div class="relative group" style="height: fit-content"> - <covers-book-cover class="relative group-hover:brightness-75 transition" :expand-on-click="true" :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> + <covers-book-cover class="relative group-hover:brightness-75 transition cursor-pointer" expand-on-click :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <!-- Item Progress Bar --> <div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div> <!-- Item Cover Overlay --> - <div class="absolute top-0 left-0 w-full h-full z-10 pointer-events-none"> + <div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none"> <div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none"> <div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem"> <span class="material-icons text-4xl">play_circle_filled</span> diff --git a/client/store/globals.js b/client/store/globals.js index 44b35f88..961dd52e 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -11,6 +11,7 @@ export const state = () => ({ showViewPodcastEpisodeModal: false, showRSSFeedOpenCloseModal: false, showConfirmPrompt: false, + showRawCoverPreviewModal: false, confirmPromptOptions: null, showEditAuthorModal: false, rssFeedEntity: null, @@ -20,6 +21,7 @@ export const state = () => ({ selectedCollection: null, selectedAuthor: null, selectedMediaItems: [], + selectedLibraryItemId: null, isCasting: false, // Actively casting isChromecastInitialized: false, // Script loadeds showBatchQuickMatchModal: false, @@ -156,6 +158,13 @@ export const mutations = { state.confirmPromptOptions = options state.showConfirmPrompt = true }, + setShowRawCoverPreviewModal(state, val) { + state.showRawCoverPreviewModal = val + }, + setRawCoverPreviewModal(state, libraryItemId) { + state.selectedLibraryItemId = libraryItemId + state.showRawCoverPreviewModal = true + }, setEditCollection(state, collection) { state.selectedCollection = collection state.showEditCollectionModal = true From 61f2fb28e09190f7c9b67c4e77355af9576a0ef4 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 Oct 2023 13:27:53 -0500 Subject: [PATCH 74/84] Add:Help icon buttons for libraries, rss feeds and users config pages, table add new buttons updated --- client/components/app/SettingsContent.vue | 13 +++---------- client/components/modals/AccountModal.vue | 1 + client/components/tables/UsersTable.vue | 11 +---------- client/pages/config/email.vue | 10 ++++++++-- client/pages/config/libraries.vue | 15 +++++++++++++-- client/pages/config/rss-feeds.vue | 10 +++++++++- client/pages/config/users/index.vue | 16 ++++++++++++++-- client/strings/da.json | 3 +++ client/strings/de.json | 3 +++ client/strings/en-us.json | 3 +++ client/strings/es.json | 3 +++ client/strings/fr.json | 3 +++ client/strings/gu.json | 3 +++ client/strings/hi.json | 3 +++ client/strings/hr.json | 3 +++ client/strings/it.json | 3 +++ client/strings/lt.json | 3 +++ client/strings/nl.json | 3 +++ client/strings/no.json | 3 +++ client/strings/pl.json | 3 +++ client/strings/ru.json | 3 +++ client/strings/zh-cn.json | 3 +++ 22 files changed, 94 insertions(+), 27 deletions(-) diff --git a/client/components/app/SettingsContent.vue b/client/components/app/SettingsContent.vue index 233839e7..c78873e3 100644 --- a/client/components/app/SettingsContent.vue +++ b/client/components/app/SettingsContent.vue @@ -3,9 +3,7 @@ <div class="flex items-center mb-2"> <h1 class="text-xl">{{ headerText }}</h1> - <div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked"> - <button type="button" class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button> - </div> + <slot name="header-items"></slot> </div> <p v-if="description" id="settings-description" class="mb-6 text-gray-200" v-html="description" /> @@ -19,14 +17,9 @@ export default { props: { headerText: String, description: String, - note: String, - showAddButton: Boolean + note: String }, - methods: { - clicked() { - this.$emit('clicked') - } - } + methods: {} } </script> diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index ddad3cd3..bdb8711d 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -329,6 +329,7 @@ export default { init() { this.fetchAllTags() this.isNew = !this.account + if (this.account) { this.newUser = { username: this.account.username, diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue index 863012b5..5494911f 100644 --- a/client/components/tables/UsersTable.vue +++ b/client/components/tables/UsersTable.vue @@ -52,8 +52,6 @@ </tr> </table> </div> - - <modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" /> </div> </template> @@ -62,8 +60,6 @@ export default { data() { return { users: [], - selectedAccount: null, - showAccountModal: false, isDeletingUser: false } }, @@ -114,13 +110,8 @@ export default { }) } }, - clickAddUser() { - this.selectedAccount = null - this.showAccountModal = true - }, editUser(user) { - this.selectedAccount = user - this.showAccountModal = true + this.$emit('edit', user) }, loadUsers() { this.$axios diff --git a/client/pages/config/email.vue b/client/pages/config/email.vue index 5ae659fa..e161a583 100644 --- a/client/pages/config/email.vue +++ b/client/pages/config/email.vue @@ -51,8 +51,14 @@ </div> </app-settings-content> - <app-settings-content :header-text="$strings.HeaderEreaderDevices" showAddButton :description="''" @clicked="addNewDeviceClick"> - <table v-if="existingEReaderDevices.length" class="tracksTable my-4"> + <app-settings-content :header-text="$strings.HeaderEreaderDevices" :description="''"> + <template #header-items> + <div class="flex-grow" /> + + <ui-btn color="primary" small @click="addNewDeviceClick">{{ $strings.ButtonAddDevice }}</ui-btn> + </template> + + <table v-if="existingEReaderDevices.length" class="tracksTable mt-4"> <tr> <th class="text-left">{{ $strings.LabelName }}</th> <th class="text-left">{{ $strings.LabelEmail }}</th> diff --git a/client/pages/config/libraries.vue b/client/pages/config/libraries.vue index 73158ab1..1293ccab 100644 --- a/client/pages/config/libraries.vue +++ b/client/pages/config/libraries.vue @@ -1,7 +1,18 @@ <template> <div> - <app-settings-content :header-text="$strings.HeaderLibraries" show-add-button @clicked="setShowLibraryModal"> - <tables-library-libraries-table @showLibraryModal="setShowLibraryModal" /> + <app-settings-content :header-text="$strings.HeaderLibraries"> + <template #header-items> + <ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2"> + <a href="https://www.audiobookshelf.org/guides/library_creation" target="_blank" class="inline-flex"> + <span class="material-icons text-xl w-5 text-gray-200">help_outline</span> + </a> + </ui-tooltip> + + <div class="flex-grow" /> + + <ui-btn color="primary" small @click="setShowLibraryModal()">{{ $strings.ButtonAddLibrary }}</ui-btn> + </template> + <tables-library-libraries-table @showLibraryModal="setShowLibraryModal" class="pt-2" /> </app-settings-content> <modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" /> </div> diff --git a/client/pages/config/rss-feeds.vue b/client/pages/config/rss-feeds.vue index 28dba670..813e69ec 100644 --- a/client/pages/config/rss-feeds.vue +++ b/client/pages/config/rss-feeds.vue @@ -1,7 +1,15 @@ <template> <div> <app-settings-content :header-text="$strings.HeaderRSSFeeds"> - <div v-if="feeds.length" class="block max-w-full"> + <template #header-items> + <ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2"> + <a href="https://www.audiobookshelf.org/guides/rss_feeds" target="_blank" class="inline-flex"> + <span class="material-icons text-xl w-5 text-gray-200">help_outline</span> + </a> + </ui-tooltip> + </template> + + <div v-if="feeds.length" class="block max-w-full pt-2"> <table class="rssFeedsTable text-xs"> <tr class="bg-primary bg-opacity-40 h-12"> <th class="w-16 min-w-16"></th> diff --git a/client/pages/config/users/index.vue b/client/pages/config/users/index.vue index 482da3ce..a03e2655 100644 --- a/client/pages/config/users/index.vue +++ b/client/pages/config/users/index.vue @@ -1,7 +1,19 @@ <template> <div> - <app-settings-content :header-text="$strings.HeaderUsers" show-add-button @clicked="setShowUserModal"> - <tables-users-table /> + <app-settings-content :header-text="$strings.HeaderUsers"> + <template #header-items> + <ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2"> + <a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex"> + <span class="material-icons text-xl w-5 text-gray-200">help_outline</span> + </a> + </ui-tooltip> + + <div class="flex-grow" /> + + <ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn> + </template> + + <tables-users-table class="pt-2" @edit="setShowUserModal" /> </app-settings-content> <modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" /> </div> diff --git a/client/strings/da.json b/client/strings/da.json index 3197cc3c..cf9f836b 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Tilføj", "ButtonAddChapters": "Tilføj kapitler", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Tilføj podcasts", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Tilføj din første bibliotek", "ButtonApply": "Anvend", "ButtonApplyChapters": "Anvend kapitler", diff --git a/client/strings/de.json b/client/strings/de.json index 942cad8b..e9242a3e 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Hinzufügen", "ButtonAddChapters": "Kapitel hinzufügen", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Podcasts hinzufügen", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek", "ButtonApply": "Übernehmen", "ButtonApplyChapters": "Kapitel anwenden", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 9e69aa4e..bfaac5ea 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Add", "ButtonAddChapters": "Add Chapters", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Add Podcasts", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Add your first library", "ButtonApply": "Apply", "ButtonApplyChapters": "Apply Chapters", diff --git a/client/strings/es.json b/client/strings/es.json index b04815ab..ca659fc8 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Agregar", "ButtonAddChapters": "Agregar Capitulo", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Agregar Podcasts", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Agrega tu Primera Biblioteca", "ButtonApply": "Aplicar", "ButtonApplyChapters": "Aplicar Capítulos", diff --git a/client/strings/fr.json b/client/strings/fr.json index 11fa1468..be624142 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Ajouter", "ButtonAddChapters": "Ajouter le chapitre", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Ajouter des podcasts", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Ajouter votre première bibliothèque", "ButtonApply": "Appliquer", "ButtonApplyChapters": "Appliquer les chapitres", diff --git a/client/strings/gu.json b/client/strings/gu.json index b3de487a..eb24cd6a 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -1,7 +1,10 @@ { "ButtonAdd": "ઉમેરો", "ButtonAddChapters": "પ્રકરણો ઉમેરો", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "પોડકાસ્ટ ઉમેરો", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "તમારી પ્રથમ પુસ્તકાલય ઉમેરો", "ButtonApply": "લાગુ કરો", "ButtonApplyChapters": "પ્રકરણો લાગુ કરો", diff --git a/client/strings/hi.json b/client/strings/hi.json index d05c1e85..4ffa2bd3 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -1,7 +1,10 @@ { "ButtonAdd": "जोड़ें", "ButtonAddChapters": "अध्याय जोड़ें", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "पॉडकास्ट जोड़ें", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "अपनी पहली पुस्तकालय जोड़ें", "ButtonApply": "लागू करें", "ButtonApplyChapters": "अध्यायों में परिवर्तन लागू करें", diff --git a/client/strings/hr.json b/client/strings/hr.json index 32213095..71090fe1 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Dodaj", "ButtonAddChapters": "Dodaj poglavlja", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Dodaj podcaste", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Dodaj svoju prvu biblioteku", "ButtonApply": "Primijeni", "ButtonApplyChapters": "Primijeni poglavlja", diff --git a/client/strings/it.json b/client/strings/it.json index 8de1f4ec..88f3c5b7 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Aggiungi", "ButtonAddChapters": "Aggiungi Capitoli", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Aggiungi Podcast", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Aggiungi la tua prima libreria", "ButtonApply": "Applica", "ButtonApplyChapters": "Applica", diff --git a/client/strings/lt.json b/client/strings/lt.json index 0623a7ab..6e85d689 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Pridėti", "ButtonAddChapters": "Pridėti skyrius", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Pridėti tinklalaides", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Pridėkite savo pirmąją biblioteką", "ButtonApply": "Taikyti", "ButtonApplyChapters": "Taikyti skyrius", diff --git a/client/strings/nl.json b/client/strings/nl.json index 659e3ec5..9391f332 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Toevoegen", "ButtonAddChapters": "Hoofdstukken toevoegen", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Podcasts toevoegen", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Voeg je eerste bibliotheek toe", "ButtonApply": "Pas toe", "ButtonApplyChapters": "Hoofdstukken toepassen", diff --git a/client/strings/no.json b/client/strings/no.json index 5bf537f2..ac16d351 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Legg til", "ButtonAddChapters": "Legg til kapittel", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Legg til podcast", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek", "ButtonApply": "Bruk", "ButtonApplyChapters": "Bruk kapittel", diff --git a/client/strings/pl.json b/client/strings/pl.json index 16a0970b..b92cb894 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Dodaj", "ButtonAddChapters": "Dodaj rozdziały", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Dodaj podcasty", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Dodaj swoją pierwszą bibliotekę", "ButtonApply": "Zatwierdź", "ButtonApplyChapters": "Zatwierdź rozdziały", diff --git a/client/strings/ru.json b/client/strings/ru.json index 478ac33a..5574aa9e 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Добавить", "ButtonAddChapters": "Добавить главы", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Добавить подкасты", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Добавьте Вашу первую библиотеку", "ButtonApply": "Применить", "ButtonApplyChapters": "Применить главы", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index ded2c9e2..fa815fab 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -1,7 +1,10 @@ { "ButtonAdd": "增加", "ButtonAddChapters": "添加章节", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "添加播客", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "添加第一个媒体库", "ButtonApply": "应用", "ButtonApplyChapters": "应用到章节", From 88c794e7102c3a64ec1f22020d3976128d1bd45d Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 Oct 2023 13:45:06 -0500 Subject: [PATCH 75/84] Fix:Open RSS feed for series & collections respect prevent indexing option #2047 --- server/objects/Feed.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/server/objects/Feed.js b/server/objects/Feed.js index 20b1c908..da856de7 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -174,7 +174,7 @@ class Feed { this.xml = null } - setFromCollection(userId, slug, collectionExpanded, serverAddress) { + setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { const feedUrl = `${serverAddress}/feed/${slug}` const itemsWithTracks = collectionExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) @@ -198,6 +198,9 @@ class Feed { this.meta.feedUrl = feedUrl this.meta.link = `${serverAddress}/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 + this.meta.ownerEmail = ownerEmail this.episodes = [] @@ -244,7 +247,7 @@ class Feed { this.xml = null } - setFromSeries(userId, slug, seriesExpanded, serverAddress) { + setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { const feedUrl = `${serverAddress}/feed/${slug}` let itemsWithTracks = seriesExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) @@ -272,6 +275,9 @@ class Feed { this.meta.feedUrl = feedUrl this.meta.link = `${serverAddress}/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 + this.meta.ownerEmail = ownerEmail this.episodes = [] From 6dc5b58d8e4df37a3c5a7153b81bc2c8a27506cb Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 Oct 2023 14:32:11 -0500 Subject: [PATCH 76/84] Update TOC to not close when clicking on it --- client/components/readers/Reader.vue | 20 ++++++++++++-------- client/components/ui/TextInput.vue | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue index 2a7b90cf..5ee85182 100644 --- a/client/components/readers/Reader.vue +++ b/client/components/readers/Reader.vue @@ -27,7 +27,7 @@ <!-- TOC side nav --> <div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div> - <div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC"> + <div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent> <div class="flex flex-col p-4 h-full"> <div class="flex items-center mb-2"> <button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100"> @@ -37,7 +37,7 @@ <p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p> </div> <form @submit.prevent="searchBook" @click.stop.prevent> - <ui-text-input clearable ref="input" @submit="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" /> + <ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" /> </form> <div class="overflow-y-auto"> @@ -47,16 +47,16 @@ <ul> <li v-for="chapter in isSearching ? this.searchResults : chapters" :key="chapter.id" class="py-1"> - <a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.title }}</a> + <a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(chapter.href)">{{ chapter.title }}</a> <div v-for="searchResults in chapter.searchResults" :key="searchResults.cfi" class="text-sm py-1 pl-4"> - <a :href="searchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(searchResults.cfi)">{{ searchResults.excerpt }}</a> + <a :href="searchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="goToChapter(searchResults.cfi)">{{ searchResults.excerpt }}</a> </div> <ul v-if="chapter.subitems.length"> <li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4"> - <a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.title }}</a> + <a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(subchapter.href)">{{ subchapter.title }}</a> <div v-for="subChapterSearchResults in subchapter.searchResults" :key="subChapterSearchResults.cfi" class="text-sm py-1 pl-4"> - <a :href="subChapterSearchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subChapterSearchResults.cfi)">{{ subChapterSearchResults.excerpt }}</a> + <a :href="subChapterSearchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="goToChapter(subChapterSearchResults.cfi)">{{ subChapterSearchResults.excerpt }}</a> </div> </li> </ul> @@ -181,11 +181,11 @@ export default { font: [ { text: 'Sans', - value: 'sans-serif', + value: 'sans-serif' }, { text: 'Serif', - value: 'serif', + value: 'serif' } ] } @@ -272,6 +272,10 @@ export default { } }, methods: { + goToChapter(uri) { + this.toggleToC() + this.$refs.readerComponent.goToChapter(uri) + }, readerMounted() { if (this.isEpub) { this.loadEreaderSettings() diff --git a/client/components/ui/TextInput.vue b/client/components/ui/TextInput.vue index 56825491..5f871635 100644 --- a/client/components/ui/TextInput.vue +++ b/client/components/ui/TextInput.vue @@ -68,7 +68,7 @@ export default { methods: { clear() { this.inputValue = '' - this.$emit('submit') + this.$emit('clear') }, focused() { this.isFocused = true From 2c9f2e0d68b12378b50d44ab333530d63adfce20 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 Oct 2023 15:54:19 -0500 Subject: [PATCH 77/84] Fix podcast episode rss feed search showing all episodes are downloaded --- client/components/modals/podcast/EpisodeFeed.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/modals/podcast/EpisodeFeed.vue b/client/components/modals/podcast/EpisodeFeed.vue index 1378dbe5..4a1b4753 100644 --- a/client/components/modals/podcast/EpisodeFeed.vue +++ b/client/components/modals/podcast/EpisodeFeed.vue @@ -93,7 +93,7 @@ export default { return this.libraryItem.media.metadata.title || 'Unknown' }, allDownloaded() { - return !this.episodesCleaned.some((episode) => this.getIsEpisodeDownloaded(episode)) + return !this.episodesCleaned.some((episode) => !this.getIsEpisodeDownloaded(episode)) }, episodesSelected() { return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key]) From 225dcdeafdd94206b6a0aebe97d96e1d6260960b Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 Oct 2023 16:11:15 -0500 Subject: [PATCH 78/84] Fix:RSS feed parser for episode metadata tags that have attributes #1996 --- server/utils/podcastUtils.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 0e68a0a4..cf1567f9 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -66,7 +66,7 @@ function extractPodcastMetadata(channel) { arrayFields.forEach((key) => { const cleanKey = key.split(':').pop() let value = extractFirstArrayItem(channel, key) - if (value && value['_']) value = value['_'] + if (value?.['_']) value = value['_'] metadata[cleanKey] = value }) return metadata @@ -131,7 +131,9 @@ function extractEpisodeData(item) { const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle'] arrayFields.forEach((key) => { const cleanKey = key.split(':').pop() - episode[cleanKey] = extractFirstArrayItem(item, key) + let value = extractFirstArrayItem(item, key) + if (value?.['_']) value = value['_'] + episode[cleanKey] = value }) return episode } From 94fd3841aa64ddd054303d03655c62e04f92a05b Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 29 Oct 2023 09:20:50 -0500 Subject: [PATCH 79/84] Update:Notification widget shows green dot indicating unseen completed tasks --- .../components/widgets/NotificationWidget.vue | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/client/components/widgets/NotificationWidget.vue b/client/components/widgets/NotificationWidget.vue index 891c13c3..fd883151 100644 --- a/client/components/widgets/NotificationWidget.vue +++ b/client/components/widgets/NotificationWidget.vue @@ -9,6 +9,8 @@ <span class="material-icons text-1.5xl" aria-label="Activities" role="button">notifications</span> </ui-tooltip> </div> + <div v-if="showUnseenSuccessIndicator" class="w-2 h-2 rounded-full bg-success pointer-events-none absolute -top-1 -right-0.5" /> + <div v-if="showUnseenSuccessIndicator" class="w-2 h-2 rounded-full bg-success/50 pointer-events-none absolute animate-ping -top-1 -right-0.5" /> </button> <transition name="menu"> <div class="sm:w-80 w-full relative"> @@ -46,7 +48,8 @@ export default { isActive: true }, showMenu: false, - disabled: false + disabled: false, + tasksSeen: [] } }, computed: { @@ -60,12 +63,20 @@ export default { // return just the tasks that are running or failed (or show success) in the last 1 minute const tasks = this.tasks.filter((t) => !t.isFinished || ((t.isFailed || t.showSuccess) && t.finishedAt > new Date().getTime() - 1000 * 60)) || [] return tasks.sort((a, b) => b.startedAt - a.startedAt) + }, + showUnseenSuccessIndicator() { + return this.tasksToShow.some((t) => t.isFinished && !t.isFailed && !this.tasksSeen.includes(t.id)) } }, methods: { clickShowMenu() { if (this.disabled) return this.showMenu = !this.showMenu + if (this.showMenu) { + this.tasksToShow.forEach((t) => { + if (!this.tasksSeen.includes(t.id)) this.tasksSeen.push(t.id) + }) + } }, clickedOutside() { this.showMenu = false @@ -83,9 +94,20 @@ export default { default: return '' } + }, + taskFinished(task) { + // add task as seen if menu is open when it finished + if (this.showMenu && !this.tasksSeen.includes(task.id)) { + this.tasksSeen.push(task.id) + } } }, - mounted() {} + mounted() { + this.$root.socket?.on('task_finished', this.taskFinished) + }, + beforeDestroy() { + this.$root.socket?.off('task_finished', this.taskFinished) + } } </script> From 27497451d9847fc57b7e67b8676f35532e299b2d Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 29 Oct 2023 11:28:34 -0500 Subject: [PATCH 80/84] Add:Ereader device setting to set users that have access #1982 --- .../modals/emails/EReaderDeviceModal.vue | 84 +++++++++++++++++-- client/components/ui/Dropdown.vue | 2 +- client/components/ui/InputDropdown.vue | 12 +-- client/components/ui/MultiSelectDropdown.vue | 40 +++++---- client/strings/da.json | 5 ++ client/strings/de.json | 5 ++ client/strings/en-us.json | 5 ++ client/strings/es.json | 5 ++ client/strings/fr.json | 5 ++ client/strings/gu.json | 5 ++ client/strings/hi.json | 5 ++ client/strings/hr.json | 5 ++ client/strings/it.json | 5 ++ client/strings/lt.json | 5 ++ client/strings/nl.json | 5 ++ client/strings/no.json | 5 ++ client/strings/pl.json | 5 ++ client/strings/ru.json | 5 ++ client/strings/zh-cn.json | 5 ++ server/controllers/EmailController.js | 29 +++++-- server/objects/settings/EmailSettings.js | 67 +++++++++++++-- server/objects/user/User.js | 3 + server/routers/ApiRouter.js | 10 +-- 23 files changed, 267 insertions(+), 55 deletions(-) diff --git a/client/components/modals/emails/EReaderDeviceModal.vue b/client/components/modals/emails/EReaderDeviceModal.vue index 4b6e87cf..79d80f7c 100644 --- a/client/components/modals/emails/EReaderDeviceModal.vue +++ b/client/components/modals/emails/EReaderDeviceModal.vue @@ -8,7 +8,7 @@ <form @submit.prevent="submitForm"> <div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300"> <div class="w-full px-3 py-5 md:p-12"> - <div class="flex items-center -mx-1 mb-2"> + <div class="flex items-center -mx-1 mb-4"> <div class="w-full md:w-1/2 px-1"> <ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" /> </div> @@ -16,6 +16,14 @@ <ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" /> </div> </div> + <div class="flex items-center -mx-1 mb-4"> + <div class="w-full md:w-1/2 px-1"> + <ui-dropdown v-model="newDevice.availabilityOption" :label="$strings.LabelDeviceIsAvailableTo" :items="userAvailabilityOptions" @input="availabilityOptionChanged" /> + </div> + <div class="w-full md:w-1/2 px-1"> + <ui-multi-select-dropdown v-if="newDevice.availabilityOption === 'specificUsers'" v-model="newDevice.users" :label="$strings.HeaderUsers" :items="userOptions" /> + </div> + </div> <div class="flex items-center pt-4"> <div class="flex-grow" /> @@ -45,8 +53,11 @@ export default { processing: false, newDevice: { name: '', - email: '' - } + email: '', + availabilityOption: 'adminAndUp', + users: [] + }, + users: [] } }, watch: { @@ -68,10 +79,55 @@ export default { } }, title() { - return this.ereaderDevice ? 'Create Device' : 'Update Device' + return !this.ereaderDevice ? 'Create Device' : 'Update Device' + }, + userAvailabilityOptions() { + return [ + { + text: this.$strings.LabelAdminUsersOnly, + value: 'adminOrUp' + }, + { + text: this.$strings.LabelAllUsersExcludingGuests, + value: 'userOrUp' + }, + { + text: this.$strings.LabelAllUsersIncludingGuests, + value: 'guestOrUp' + }, + { + text: this.$strings.LabelSelectUsers, + value: 'specificUsers' + } + ] + }, + userOptions() { + return this.users.map((u) => ({ text: u.username, value: u.id })) } }, methods: { + availabilityOptionChanged(option) { + if (option === 'specificUsers' && !this.users.length) { + this.loadUsers() + } + }, + async loadUsers() { + this.processing = true + this.users = await this.$axios + .$get('/api/users') + .then((res) => { + return res.users.sort((a, b) => { + return a.createdAt - b.createdAt + }) + }) + .catch((error) => { + console.error('Failed', error) + return [] + }) + .finally(() => { + this.processing = false + }) + }, submitForm() { this.$refs.ereaderNameInput.blur() this.$refs.ereaderEmailInput.blur() @@ -81,19 +137,27 @@ export default { return } + if (this.newDevice.availabilityOption === 'specificUsers' && !this.newDevice.users.length) { + this.$toast.error('Must select at least one user') + return + } + if (this.newDevice.availabilityOption !== 'specificUsers') { + this.newDevice.users = [] + } + this.newDevice.name = this.newDevice.name.trim() this.newDevice.email = this.newDevice.email.trim() if (!this.ereaderDevice) { if (this.existingDevices.some((d) => d.name === this.newDevice.name)) { - this.$toast.error('EReader device with that name already exists') + this.$toast.error('Ereader device with that name already exists') return } this.submitCreate() } else { if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) { - this.$toast.error('EReader device with that name already exists') + this.$toast.error('Ereader device with that name already exists') return } @@ -160,9 +224,17 @@ export default { if (this.ereaderDevice) { this.newDevice.name = this.ereaderDevice.name this.newDevice.email = this.ereaderDevice.email + this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'adminOrUp' + this.newDevice.users = this.ereaderDevice.users || [] + + if (this.newDevice.availabilityOption === 'specificUsers' && !this.users.length) { + this.loadUsers() + } } else { this.newDevice.name = '' this.newDevice.email = '' + this.newDevice.availabilityOption = 'adminOrUp' + this.newDevice.users = [] } } }, diff --git a/client/components/ui/Dropdown.vue b/client/components/ui/Dropdown.vue index 69f04afe..58155499 100644 --- a/client/components/ui/Dropdown.vue +++ b/client/components/ui/Dropdown.vue @@ -13,7 +13,7 @@ </button> <transition name="menu"> - <ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox"> + <ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox"> <template v-for="item in itemsToShow"> <li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)"> <div class="flex items-center"> diff --git a/client/components/ui/InputDropdown.vue b/client/components/ui/InputDropdown.vue index 1d4018fb..852aa997 100644 --- a/client/components/ui/InputDropdown.vue +++ b/client/components/ui/InputDropdown.vue @@ -4,7 +4,7 @@ <div ref="wrapper" class="relative"> <form @submit.prevent="submitForm"> <div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'"> - <input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> + <input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @focus="inputFocus" @blur="inputBlur" /> </div> </form> @@ -48,8 +48,6 @@ export default { data() { return { isFocused: false, - // currentSearch: null, - typingTimeout: null, textInput: null } }, @@ -83,12 +81,6 @@ export default { } }, methods: { - keydownInput() { - clearTimeout(this.typingTimeout) - this.typingTimeout = setTimeout(() => { - // this.currentSearch = this.textInput - }, 100) - }, setFocus() { if (this.$refs.input && this.editable) this.$refs.input.focus() }, @@ -133,11 +125,9 @@ export default { if (val && !this.items.includes(val)) { this.$emit('newItem', val) } - // this.currentSearch = null }, clickedOption(e, item) { this.textInput = null - // this.currentSearch = null this.input = item if (this.$refs.input) this.$refs.input.blur() } diff --git a/client/components/ui/MultiSelectDropdown.vue b/client/components/ui/MultiSelectDropdown.vue index 3baac572..7a3c7f00 100644 --- a/client/components/ui/MultiSelectDropdown.vue +++ b/client/components/ui/MultiSelectDropdown.vue @@ -1,5 +1,5 @@ <template> - <div class="w-full" v-click-outside="closeMenu"> + <div class="w-full" v-click-outside="clickOutsideObj"> <p class="px-1 text-sm font-semibold">{{ label }}</p> <div ref="wrapper" class="relative"> <div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-pointer" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> @@ -11,23 +11,24 @@ </div> </div> - <ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> - <template v-for="item in items"> - <li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> - <div class="flex items-center"> - <span class="font-normal ml-3 block truncate">{{ item.text }}</span> + <transition name="menu"> + <ul ref="menu" v-show="showMenu" class="absolute z-60 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> + <template v-for="item in items"> + <li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> + <p class="font-normal ml-3 block truncate">{{ item.text }}</p> + + <div v-if="selected.includes(item.value)" class="text-yellow-400 absolute inset-y-0 right-0 my-auto w-5 h-5 mr-3 overflow-hidden"> + <span class="material-icons text-xl">checkmark</span> + </div> + </li> + </template> + <li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option"> + <div class="flex items-center justify-center"> + <span class="font-normal">{{ $strings.MessageNoItems }}</span> </div> - <span v-if="selected.includes(item.value)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> - <span class="material-icons text-xl">checkmark</span> - </span> </li> - </template> - <li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option"> - <div class="flex items-center justify-center"> - <span class="font-normal">{{ $strings.MessageNoItems }}</span> - </div> - </li> - </ul> + </ul> + </transition> </div> </div> </template> @@ -48,7 +49,12 @@ export default { data() { return { showMenu: false, - menu: null + menu: null, + clickOutsideObj: { + handler: this.closeMenu, + events: ['mousedown'], + isActive: true + } } }, computed: { diff --git a/client/strings/da.json b/client/strings/da.json index cf9f836b..768bb724 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Tilføj {0} Bøger til Samling", "LabelAddToPlaylist": "Tilføj til Afspilningsliste", "LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Alle", "LabelAllUsers": "Alle Brugere", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Allerede i dit bibliotek", "LabelAppend": "Tilføj", "LabelAuthor": "Forfatter", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Fravælg Alle", "LabelDevice": "Enheds", "LabelDeviceInfo": "Enhedsinformation", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Mappe", "LabelDiscFromFilename": "Disk fra Filnavn", "LabelDiscFromMetadata": "Disk fra Metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Sæson", "LabelSelectAllEpisodes": "Vælg alle episoder", "LabelSelectEpisodesShowing": "Vælg {0} episoder vist", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send e-bog til...", "LabelSequence": "Sekvens", "LabelSeries": "Serie", diff --git a/client/strings/de.json b/client/strings/de.json index e9242a3e..f7cf8b68 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu", "LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen", "LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Alle", "LabelAllUsers": "Alle Benutzer", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "In der Bibliothek vorhanden", "LabelAppend": "Anhängen", "LabelAuthor": "Autor", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Alles abwählen", "LabelDevice": "Gerät", "LabelDeviceInfo": "Geräteinformationen", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Verzeichnis", "LabelDiscFromFilename": "CD aus dem Dateinamen", "LabelDiscFromMetadata": "CD aus den Metadaten", @@ -394,6 +398,7 @@ "LabelSeason": "Staffel", "LabelSelectAllEpisodes": "Alle Episoden auswählen", "LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "E-Book senden an...", "LabelSequence": "Reihenfolge", "LabelSeries": "Serien", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index bfaac5ea..1366c762 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", "LabelAllUsers": "All Users", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", "LabelAppend": "Append", "LabelAuthor": "Author", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Deselect All", "LabelDevice": "Device", "LabelDeviceInfo": "Device Info", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Directory", "LabelDiscFromFilename": "Disc from Filename", "LabelDiscFromMetadata": "Disc from Metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Season", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Sequence", "LabelSeries": "Series", diff --git a/client/strings/es.json b/client/strings/es.json index ca659fc8..0ac0a960 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Se Añadieron {0} Libros a la Colección", "LabelAddToPlaylist": "Añadido a la Lista de Reproducción", "LabelAddToPlaylistBatch": "Se Añadieron {0} Artículos a la Lista de Reproducción", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Todos", "LabelAllUsers": "Todos los Usuarios", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Ya en la Biblioteca", "LabelAppend": "Adjuntar", "LabelAuthor": "Autor", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Deseleccionar Todos", "LabelDevice": "Dispositivo", "LabelDeviceInfo": "Información de Dispositivo", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Directorio", "LabelDiscFromFilename": "Disco a partir del Nombre del Archivo", "LabelDiscFromMetadata": "Disco a partir de Metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Temporada", "LabelSelectAllEpisodes": "Seleccionar todos los episodios", "LabelSelectEpisodesShowing": "Seleccionar los {0} episodios visibles", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Enviar Ebook a...", "LabelSequence": "Secuencia", "LabelSeries": "Series", diff --git a/client/strings/fr.json b/client/strings/fr.json index be624142..5ad80723 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection", "LabelAddToPlaylist": "Ajouter à la liste de lecture", "LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Tout", "LabelAllUsers": "Tous les utilisateurs", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque", "LabelAppend": "Ajouter", "LabelAuthor": "Auteur", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Tout déselectionner", "LabelDevice": "Appareil", "LabelDeviceInfo": "Détail de l’appareil", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Répertoire", "LabelDiscFromFilename": "Disque depuis le fichier", "LabelDiscFromMetadata": "Disque depuis les métadonnées", @@ -394,6 +398,7 @@ "LabelSeason": "Saison", "LabelSelectAllEpisodes": "Sélectionner tous les épisodes", "LabelSelectEpisodesShowing": "Sélectionner {0} episode(s) en cours", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Envoyer le livre numérique à...", "LabelSequence": "Séquence", "LabelSeries": "Séries", diff --git a/client/strings/gu.json b/client/strings/gu.json index eb24cd6a..d71c9f17 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", "LabelAllUsers": "All Users", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", "LabelAppend": "Append", "LabelAuthor": "Author", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Deselect All", "LabelDevice": "Device", "LabelDeviceInfo": "Device Info", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Directory", "LabelDiscFromFilename": "Disc from Filename", "LabelDiscFromMetadata": "Disc from Metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Season", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Sequence", "LabelSeries": "Series", diff --git a/client/strings/hi.json b/client/strings/hi.json index 4ffa2bd3..51b2e762 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", "LabelAllUsers": "All Users", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", "LabelAppend": "Append", "LabelAuthor": "Author", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Deselect All", "LabelDevice": "Device", "LabelDeviceInfo": "Device Info", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Directory", "LabelDiscFromFilename": "Disc from Filename", "LabelDiscFromMetadata": "Disc from Metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Season", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Sequence", "LabelSeries": "Series", diff --git a/client/strings/hr.json b/client/strings/hr.json index 71090fe1..e04343a0 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", "LabelAllUsers": "Svi korisnici", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", "LabelAppend": "Append", "LabelAuthor": "Autor", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Odznači sve", "LabelDevice": "Uređaj", "LabelDeviceInfo": "O uređaju", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Direktorij", "LabelDiscFromFilename": "CD iz imena datoteke", "LabelDiscFromMetadata": "CD iz metapodataka", @@ -394,6 +398,7 @@ "LabelSeason": "Sezona", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Sekvenca", "LabelSeries": "Serije", diff --git a/client/strings/it.json b/client/strings/it.json index 88f3c5b7..747d7420 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta", "LabelAddToPlaylist": "aggiungi alla Playlist", "LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Tutti", "LabelAllUsers": "Tutti gli Utenti", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Già esistente nella libreria", "LabelAppend": "Appese", "LabelAuthor": "Autore", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Deseleziona Tutto", "LabelDevice": "Dispositivo", "LabelDeviceInfo": "Info Dispositivo", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Elenco", "LabelDiscFromFilename": "Disco dal nome file", "LabelDiscFromMetadata": "Disco dal Metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Stagione", "LabelSelectAllEpisodes": "Seleziona tutti gli Episodi", "LabelSelectEpisodesShowing": "Episodi {0} selezionati ", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Invia ebook a...", "LabelSequence": "Sequenza", "LabelSeries": "Serie", diff --git a/client/strings/lt.json b/client/strings/lt.json index 6e85d689..ebc6b558 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Pridėti {0} knygas į kolekciją", "LabelAddToPlaylist": "Pridėti į grojaraštį", "LabelAddToPlaylistBatch": "Pridėti {0} elementus į grojaraštį", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Visi", "LabelAllUsers": "Visi naudotojai", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Jau yra jūsų bibliotekoje", "LabelAppend": "Pridėti", "LabelAuthor": "Autorius", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Išvalyti pasirinktus", "LabelDevice": "Įrenginys", "LabelDeviceInfo": "Įrenginio informacija", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Katalogas", "LabelDiscFromFilename": "Diskas pagal failo pavadinimą", "LabelDiscFromMetadata": "Diskas pagal metaduomenis", @@ -394,6 +398,7 @@ "LabelSeason": "Sezonas", "LabelSelectAllEpisodes": "Pažymėti visus epizodus", "LabelSelectEpisodesShowing": "Pažymėti {0} rodomus epizodus", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Siųsti e-knygą į...", "LabelSequence": "Seka", "LabelSeries": "Serija", diff --git a/client/strings/nl.json b/client/strings/nl.json index 9391f332..06aed904 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "{0} boeken toevoegen aan collectie", "LabelAddToPlaylist": "Toevoegen aan afspeellijst", "LabelAddToPlaylistBatch": "{0} onderdelen toevoegen aan afspeellijst", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Alle", "LabelAllUsers": "Alle gebruikers", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Reeds in je bibliotheek", "LabelAppend": "Achteraan toevoegen", "LabelAuthor": "Auteur", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Deselecteer alle", "LabelDevice": "Apparaat", "LabelDeviceInfo": "Apparaat info", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Map", "LabelDiscFromFilename": "Schijf uit bestandsnaam", "LabelDiscFromMetadata": "Schijf uit metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Seizoen", "LabelSelectAllEpisodes": "Selecteer alle afleveringen", "LabelSelectEpisodesShowing": "Selecteer {0} afleveringen laten zien", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Stuur ebook naar...", "LabelSequence": "Sequentie", "LabelSeries": "Serie", diff --git a/client/strings/no.json b/client/strings/no.json index ac16d351..7fcd1c96 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Legg {0} bøker til samling", "LabelAddToPlaylist": "Legg til i spilleliste", "LabelAddToPlaylistBatch": "Legg {0} enheter til i spilleliste", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Alle", "LabelAllUsers": "Alle brukere", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Allerede i biblioteket", "LabelAppend": "Legge til", "LabelAuthor": "Forfatter", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Fjern valg", "LabelDevice": "Enhet", "LabelDeviceInfo": "Enhetsinformasjon", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Mappe", "LabelDiscFromFilename": "Disk fra filnavn", "LabelDiscFromMetadata": "Disk fra metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Sesong", "LabelSelectAllEpisodes": "Velg alle episoder", "LabelSelectEpisodesShowing": "Velg {0} episoder vist", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebok til...", "LabelSequence": "Sekvens", "LabelSeries": "Serier", diff --git a/client/strings/pl.json b/client/strings/pl.json index b92cb894..dd3c1d4a 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", "LabelAllUsers": "Wszyscy użytkownicy", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", "LabelAppend": "Append", "LabelAuthor": "Autor", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Odznacz wszystko", "LabelDevice": "Urządzenie", "LabelDeviceInfo": "Informacja o urządzeniu", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Katalog", "LabelDiscFromFilename": "Oznaczenie dysku z nazwy pliku", "LabelDiscFromMetadata": "Oznaczenie dysku z metadanych", @@ -394,6 +398,7 @@ "LabelSeason": "Sezon", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Kolejność", "LabelSeries": "Serie", diff --git a/client/strings/ru.json b/client/strings/ru.json index 5574aa9e..832ffe8b 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию", "LabelAddToPlaylist": "Добавить в плейлист", "LabelAddToPlaylistBatch": "Добавить {0} элементов в плейлист", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Все", "LabelAllUsers": "Все пользователи", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке", "LabelAppend": "Добавить", "LabelAuthor": "Автор", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Снять выделение", "LabelDevice": "Устройство", "LabelDeviceInfo": "Информация об устройстве", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Каталог", "LabelDiscFromFilename": "Диск из Имени файла", "LabelDiscFromMetadata": "Диск из Метаданных", @@ -394,6 +398,7 @@ "LabelSeason": "Сезон", "LabelSelectAllEpisodes": "Выбрать все эпизоды", "LabelSelectEpisodesShowing": "Выберите {0} эпизодов для показа", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Отправить e-книгу в...", "LabelSequence": "Последовательность", "LabelSeries": "Серия", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index fa815fab..5d3de27a 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏", "LabelAddToPlaylist": "添加到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "全部", "LabelAllUsers": "所有用户", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "已存在你的库中", "LabelAppend": "附加", "LabelAuthor": "作者", @@ -229,6 +232,7 @@ "LabelDeselectAll": "全部取消选择", "LabelDevice": "设备", "LabelDeviceInfo": "设备信息", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "目录", "LabelDiscFromFilename": "从文件名获取光盘", "LabelDiscFromMetadata": "从元数据获取光盘", @@ -394,6 +398,7 @@ "LabelSeason": "季", "LabelSelectAllEpisodes": "选择所有剧集", "LabelSelectEpisodesShowing": "选择正在播放的 {0} 剧集", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "发送电子书到...", "LabelSequence": "序列", "LabelSeries": "系列", diff --git a/server/controllers/EmailController.js b/server/controllers/EmailController.js index fefc23b6..fcbc4905 100644 --- a/server/controllers/EmailController.js +++ b/server/controllers/EmailController.js @@ -51,32 +51,45 @@ class EmailController { }) } + /** + * Send ebook to device + * User must have access to device and library item + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async sendEBookToDevice(req, res) { - Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`) + Logger.debug(`[EmailController] Send ebook to device requested by user "${req.user.username}" for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`) + + const device = Database.emailSettings.getEReaderDevice(req.body.deviceName) + if (!device) { + return res.status(404).send('Ereader device not found') + } + + // Check user has access to device + if (!Database.emailSettings.checkUserCanAccessDevice(device, req.user)) { + return res.sendStatus(403) + } const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId) if (!libraryItem) { return res.status(404).send('Library item not found') } + // Check user has access to library item if (!req.user.checkCanAccessLibraryItem(libraryItem)) { return res.sendStatus(403) } const ebookFile = libraryItem.media.ebookFile if (!ebookFile) { - return res.status(404).send('EBook file not found') - } - - const device = Database.emailSettings.getEReaderDevice(req.body.deviceName) - if (!device) { - return res.status(404).send('E-reader device not found') + return res.status(404).send('Ebook file not found') } this.emailManager.sendEBookToDevice(ebookFile, device, res) } - middleware(req, res, next) { + adminMiddleware(req, res, next) { if (!req.user.isAdminOrUp) { return res.sendStatus(404) } diff --git a/server/objects/settings/EmailSettings.js b/server/objects/settings/EmailSettings.js index 40648887..81e31d53 100644 --- a/server/objects/settings/EmailSettings.js +++ b/server/objects/settings/EmailSettings.js @@ -1,6 +1,14 @@ const Logger = require('../../Logger') const { areEquivalent, copyValue, isNullOrNaN } = require('../../utils') +/** + * @typedef EreaderDeviceObject + * @property {string} name + * @property {string} email + * @property {string} availabilityOption + * @property {string[]} users + */ + // REF: https://nodemailer.com/smtp/ class EmailSettings { constructor(settings = null) { @@ -13,7 +21,7 @@ class EmailSettings { this.testAddress = null this.fromAddress = null - // Array of { name:String, email:String } + /** @type {EreaderDeviceObject[]} */ this.ereaderDevices = [] if (settings) { @@ -57,6 +65,26 @@ class EmailSettings { if (payload.ereaderDevices !== undefined && !Array.isArray(payload.ereaderDevices)) payload.ereaderDevices = undefined + if (payload.ereaderDevices?.length) { + // Validate ereader devices + payload.ereaderDevices = payload.ereaderDevices.map((device) => { + if (!device.name || !device.email) { + Logger.error(`[EmailSettings] Update ereader device is invalid`, device) + return null + } + if (!device.availabilityOption || !['adminOrUp', 'userOrUp', 'guestOrUp', 'specificUsers'].includes(device.availabilityOption)) { + device.availabilityOption = 'adminOrUp' + } + if (device.availabilityOption === 'specificUsers' && !device.users?.length) { + device.availabilityOption = 'adminOrUp' + } + if (device.availabilityOption !== 'specificUsers' && device.users?.length) { + device.users = [] + } + return device + }).filter(d => d) + } + let hasUpdates = false const json = this.toJSON() @@ -88,15 +116,40 @@ class EmailSettings { return payload } - getEReaderDevices(user) { - // Only accessible to admin or up - if (!user.isAdminOrUp) { - return [] + /** + * + * @param {EreaderDeviceObject} device + * @param {import('../user/User')} user + * @returns {boolean} + */ + checkUserCanAccessDevice(device, user) { + let deviceAvailability = device.availabilityOption || 'adminOrUp' + if (deviceAvailability === 'adminOrUp' && user.isAdminOrUp) return true + if (deviceAvailability === 'userOrUp' && (user.isAdminOrUp || user.isUser)) return true + if (deviceAvailability === 'guestOrUp') return true + if (deviceAvailability === 'specificUsers') { + let deviceUsers = device.users || [] + return deviceUsers.includes(user.id) } - - return this.ereaderDevices.map(d => ({ ...d })) + return false } + /** + * Get ereader devices accessible to user + * + * @param {import('../user/User')} user + * @returns {EreaderDeviceObject[]} + */ + getEReaderDevices(user) { + return this.ereaderDevices.filter((device) => this.checkUserCanAccessDevice(device, user)) + } + + /** + * Get ereader device by name + * + * @param {string} deviceName + * @returns {EreaderDeviceObject} + */ getEReaderDevice(deviceName) { return this.ereaderDevices.find(d => d.name === deviceName) } diff --git a/server/objects/user/User.js b/server/objects/user/User.js index a9c9c767..5192752a 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -35,6 +35,9 @@ class User { get isAdmin() { return this.type === 'admin' } + get isUser() { + return this.type === 'user' + } get isGuest() { return this.type === 'guest' } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 41b24716..bb91e9b5 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -255,11 +255,11 @@ class ApiRouter { // // Email Routes (Admin and up) // - this.router.get('/emails/settings', EmailController.middleware.bind(this), EmailController.getSettings.bind(this)) - this.router.patch('/emails/settings', EmailController.middleware.bind(this), EmailController.updateSettings.bind(this)) - this.router.post('/emails/test', EmailController.middleware.bind(this), EmailController.sendTest.bind(this)) - this.router.post('/emails/ereader-devices', EmailController.middleware.bind(this), EmailController.updateEReaderDevices.bind(this)) - this.router.post('/emails/send-ebook-to-device', EmailController.middleware.bind(this), EmailController.sendEBookToDevice.bind(this)) + this.router.get('/emails/settings', EmailController.adminMiddleware.bind(this), EmailController.getSettings.bind(this)) + this.router.patch('/emails/settings', EmailController.adminMiddleware.bind(this), EmailController.updateSettings.bind(this)) + this.router.post('/emails/test', EmailController.adminMiddleware.bind(this), EmailController.sendTest.bind(this)) + this.router.post('/emails/ereader-devices', EmailController.adminMiddleware.bind(this), EmailController.updateEReaderDevices.bind(this)) + this.router.post('/emails/send-ebook-to-device', EmailController.sendEBookToDevice.bind(this)) // // Search Routes From 2ef11e5ad05406ed5199d0259f9ee35b075bf7fd Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 29 Oct 2023 12:58:00 -0500 Subject: [PATCH 81/84] Version bump v2.5.0 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 25000ab0..1dc72e4c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.4.4", + "version": "2.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.4.4", + "version": "2.5.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 21cae124..c815d388 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.4.4", + "version": "2.5.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 7178ac98..888c3beb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.4.4", + "version": "2.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.4.4", + "version": "2.5.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", @@ -4704,4 +4704,4 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index f8ea7dee..4bef0e42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.4.4", + "version": "2.5.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 9616d996408d079edddfdcb8de29bf38da5e0cbe Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 30 Oct 2023 16:35:41 -0500 Subject: [PATCH 82/84] Fix:Crash when matching with author names ending in ??? by escaping regex strings #2265 --- server/finders/BookFinder.js | 4 ++-- server/utils/index.js | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index a0b64f55..75e5a5f1 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -6,7 +6,7 @@ const Audnexus = require('../providers/Audnexus') const FantLab = require('../providers/FantLab') const AudiobookCovers = require('../providers/AudiobookCovers') const Logger = require('../Logger') -const { levenshteinDistance } = require('../utils/index') +const { levenshteinDistance, escapeRegExp } = require('../utils/index') class BookFinder { constructor() { @@ -201,7 +201,7 @@ class BookFinder { add(title, position = 0) { // if title contains the author, remove it if (this.cleanAuthor) { - const authorRe = new RegExp(`(^| | by |)${this.cleanAuthor}(?= |$)`, "g") + const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g") title = this.bookFinder.cleanAuthorForCompares(title).replace(authorRe, '').trim() } diff --git a/server/utils/index.js b/server/utils/index.js index 84167229..0377b173 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -192,4 +192,16 @@ module.exports.asciiOnlyToLowerCase = (str) => { } } return temp +} + +/** + * Escape string used in RegExp + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + * + * @param {string} str + * @returns {string} + */ +module.exports.escapeRegExp = (str) => { + if (typeof str !== 'string') return '' + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } \ No newline at end of file From 3c21e9d4135f3e9f7cd0b111776c2d65a2455035 Mon Sep 17 00:00:00 2001 From: "clement.dufour" <clement.dufour@tutanota.com> Date: Wed, 1 Nov 2023 11:51:39 +0100 Subject: [PATCH 83/84] Update:Simpler content URL in RSS feeds --- server/objects/FeedEpisode.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/objects/FeedEpisode.js b/server/objects/FeedEpisode.js index eeef5379..b87cf88f 100644 --- a/server/objects/FeedEpisode.js +++ b/server/objects/FeedEpisode.js @@ -1,3 +1,4 @@ +const Path = require('path') const uuidv4 = require("uuid").v4 const date = require('../libs/dateAndTime') const { secondsToTimestamp } = require('../utils/index') @@ -69,7 +70,8 @@ class FeedEpisode { } setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, meta) { - const contentUrl = `/feed/${slug}/item/${episode.id}/${episode.audioFile.metadata.filename}` + const contentFileExtension = Path.extname(episode.audioFile.metadata.filename) + const contentUrl = `/feed/${slug}/item/${episode.id}/media${contentFileExtension}` const media = libraryItem.media const mediaMetadata = media.metadata @@ -108,7 +110,8 @@ class FeedEpisode { // e.g. Track 1 will have a pub date before Track 2 const audiobookPubDate = date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]') - const contentUrl = `/feed/${slug}/item/${episodeId}/${audioTrack.metadata.filename}` + const contentFileExtension = Path.extname(audioTrack.metadata.filename) + const contentUrl = `/feed/${slug}/item/${episodeId}/media${contentFileExtension}` const media = libraryItem.media const mediaMetadata = media.metadata From 1ae20892536ee467bfe146eabfa3629fd7b37b41 Mon Sep 17 00:00:00 2001 From: "clement.dufour" <clement.dufour@tutanota.com> Date: Wed, 1 Nov 2023 12:11:24 +0100 Subject: [PATCH 84/84] Update:Add cover file extension in RSS feeds --- server/Server.js | 2 +- server/objects/Feed.js | 25 +++++++++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/server/Server.js b/server/Server.js index d95bd799..ba63b2bd 100644 --- a/server/Server.js +++ b/server/Server.js @@ -155,7 +155,7 @@ class Server { Logger.info(`[Server] Requesting rss feed ${req.params.slug}`) this.rssFeedManager.getFeed(req, res) }) - router.get('/feed/:slug/cover', (req, res) => { + router.get('/feed/:slug/cover*', (req, res) => { this.rssFeedManager.getFeedCover(req, res) }) router.get('/feed/:slug/item/:episodeId/*', (req, res) => { diff --git a/server/objects/Feed.js b/server/objects/Feed.js index da856de7..08a602ae 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -1,3 +1,4 @@ +const Path = require('path') const uuidv4 = require("uuid").v4 const FeedMeta = require('./FeedMeta') const FeedEpisode = require('./FeedEpisode') @@ -101,11 +102,13 @@ class Feed { this.serverAddress = serverAddress this.feedUrl = feedUrl + const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null + this.meta = new FeedMeta() this.meta.title = mediaMetadata.title this.meta.description = mediaMetadata.description this.meta.author = author - this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png` + this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` this.meta.feedUrl = feedUrl this.meta.link = `${serverAddress}/item/${libraryItem.id}` this.meta.explicit = !!mediaMetadata.explicit @@ -145,10 +148,12 @@ class Feed { this.entityUpdatedAt = libraryItem.updatedAt this.coverPath = media.coverPath || null + const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null + 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` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` this.meta.explicit = !!mediaMetadata.explicit this.meta.type = mediaMetadata.type this.meta.language = mediaMetadata.language @@ -190,11 +195,13 @@ class Feed { this.serverAddress = serverAddress this.feedUrl = feedUrl + const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null + this.meta = new FeedMeta() 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` : `${serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` this.meta.feedUrl = feedUrl this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}` this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit @@ -225,10 +232,12 @@ class Feed { this.entityUpdatedAt = collectionExpanded.lastUpdate this.coverPath = firstItemWithCover?.coverPath || null + const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null + 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` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit this.episodes = [] @@ -267,11 +276,13 @@ class Feed { this.serverAddress = serverAddress this.feedUrl = feedUrl + const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null + this.meta = new FeedMeta() 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` : `${serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` this.meta.feedUrl = feedUrl this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}` this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit @@ -305,10 +316,12 @@ class Feed { this.entityUpdatedAt = seriesExpanded.updatedAt this.coverPath = firstItemWithCover?.coverPath || null + const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null + 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` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit this.episodes = []