From e368ffe29f837ee00787b41012e9a01e9862b970 Mon Sep 17 00:00:00 2001 From: Teekeks Date: Thu, 22 Feb 2024 19:20:49 +0100 Subject: [PATCH 01/16] feat(i18n): added missing translatable string in player ui --- client/components/player/PlayerUi.vue | 2 +- client/strings/en-us.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue index e1b0f96da..bca3e7eed 100644 --- a/client/components/player/PlayerUi.vue +++ b/client/components/player/PlayerUi.vue @@ -53,7 +53,7 @@

- {{ currentChapterName }}  ({{ currentChapterIndex + 1 }} of {{ chapters.length }}) + {{ currentChapterName }}  {{ $setString('LabelPlayerChaperMarker', [currentChapterIndex + 1, chapters.length]) }}

{{ timeRemainingPretty }}

diff --git a/client/strings/en-us.json b/client/strings/en-us.json index e3349d1f1..0df642a78 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -386,6 +386,7 @@ "LabelPermissionsUpdate": "Can Update", "LabelPermissionsUpload": "Can Upload", "LabelPhotoPathURL": "Photo Path/URL", + "LabelPlayerChaperMarker": "({0} of {1})", "LabelPlaylists": "Playlists", "LabelPlayMethod": "Play Method", "LabelPodcast": "Podcast", From 24adc8f66fb14e70bfce3d246c0651f09c698862 Mon Sep 17 00:00:00 2001 From: Machou Date: Tue, 28 May 2024 03:27:01 +0200 Subject: [PATCH 02/16] Update fr.json --- client/strings/fr.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index ae7f60f3b..1d90fd6c4 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -191,7 +191,7 @@ "LabelAbridged": "Version courte", "LabelAbridgedChecked": "Abrégé (vérifié)", "LabelAbridgedUnchecked": "Intégral (non vérifié)", - "LabelAccessibleBy": "Accessible by", + "LabelAccessibleBy": "Accessible par", "LabelAccountType": "Type de compte", "LabelAccountTypeAdmin": "Admin", "LabelAccountTypeGuest": "Invité", @@ -462,7 +462,7 @@ "LabelSetEbookAsSupplementary": "Définir comme supplémentaire", "LabelSettingsAudiobooksOnly": "Livres audios seulement", "LabelSettingsAudiobooksOnlyHelp": "L’activation de ce paramètre ignorera les fichiers de type « livre numériques », sauf s’ils se trouvent dans un dossier spécifique , auquel cas ils seront définis comme des livres numériques supplémentaires.", - "LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois", + "LabelSettingsBookshelfViewHelp": "Interface skeumorphique avec étagères en bois", "LabelSettingsChromecastSupport": "Support du Chromecast", "LabelSettingsDateFormat": "Format de date", "LabelSettingsDisableWatcher": "Désactiver la surveillance", @@ -471,16 +471,16 @@ "LabelSettingsEnableWatcher": "Activer la veille", "LabelSettingsEnableWatcherForLibrary": "Activer la surveillance des dossiers pour la bibliothèque", "LabelSettingsEnableWatcherHelp": "Active la mise à jour automatique automatique lorsque des modifications de fichiers sont détectées. * nécessite le redémarrage du serveur", - "LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs", - "LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.", + "LabelSettingsEpubsAllowScriptedContent": "Autoriser le contenu scénarisé pour les fichiers EPUBs", + "LabelSettingsEpubsAllowScriptedContentHelp": "Autoriser les fichiers EPUBs à exécuter des scripts. Il est recommandé de laisser ce paramètre désactivé, sauf si vous faites confiance à la source des fichiers EPUBs.", "LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales", "LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion GitHub.", "LabelSettingsFindCovers": "Chercher des couvertures de livre", "LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, l’analyseur tentera de récupérer une couverture.
Attention, cela peut augmenter le temps d’analyse.", "LabelSettingsHideSingleBookSeries": "Masquer les séries de livres uniques", "LabelSettingsHideSingleBookSeriesHelp": "Les séries qui ne comportent qu’un seul livre seront masquées sur la page de la série et sur les étagères de la page d’accueil.", - "LabelSettingsHomePageBookshelfView": "La page d’accueil utilise la vue étagère", - "LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère", + "LabelSettingsHomePageBookshelfView": "Utiliser la vue étagère sur la page d’accueil", + "LabelSettingsLibraryBookshelfView": "Utiliser la vue étagère pour la bibliothèque", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sauter les livres précédents dans « Continuer la série »", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "L’étagère de la page d’accueil « Continuer la série » affiche le premier livre non commencé dans les séries dont au moins un livre est terminé et aucun livre n’est en cours. L’activation de ce paramètre permet de poursuivre la série à partir du dernier livre terminé au lieu du premier livre non commencé.", "LabelSettingsParseSubtitles": "Analyser les sous-titres", @@ -633,7 +633,7 @@ "MessageDragFilesIntoTrackOrder": "Faites glisser les fichiers dans l’ordre correct des pistes", "MessageEmbedFinished": "Intégration terminée !", "MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement", - "MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.", + "MessageEreaderDevices": "Pour garantir l’envoie des livres électroniques, il se peut que vous deviez ajouter l’adresse électronique ci-dessus en tant qu’expéditeur valide pour chaque appareil répertorié ci-dessous.", "MessageFeedURLWillBe": "L’URL du flux sera {0}", "MessageFetching": "Récupération…", "MessageForceReScanDescription": "analysera de nouveau tous les fichiers. Les étiquettes ID3 des fichiers audio, les fichiers OPF et les fichiers texte seront analysés comme s’ils étaient nouveaux.", @@ -807,4 +807,4 @@ "ToastSortingPrefixesUpdateSuccess": "Mise à jour des préfixes de tri ({0} élément)", "ToastUserDeleteFailed": "Échec de la suppression de l’utilisateur", "ToastUserDeleteSuccess": "Utilisateur supprimé" -} \ No newline at end of file +} From b0924e4ce81b0c756141676897a53c7596cb1bc8 Mon Sep 17 00:00:00 2001 From: Machou Date: Tue, 28 May 2024 03:55:20 +0200 Subject: [PATCH 03/16] Update fr.json --- client/strings/fr.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index 1d90fd6c4..0635e8230 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -471,8 +471,8 @@ "LabelSettingsEnableWatcher": "Activer la veille", "LabelSettingsEnableWatcherForLibrary": "Activer la surveillance des dossiers pour la bibliothèque", "LabelSettingsEnableWatcherHelp": "Active la mise à jour automatique automatique lorsque des modifications de fichiers sont détectées. * nécessite le redémarrage du serveur", - "LabelSettingsEpubsAllowScriptedContent": "Autoriser le contenu scénarisé pour les fichiers EPUBs", - "LabelSettingsEpubsAllowScriptedContentHelp": "Autoriser les fichiers EPUBs à exécuter des scripts. Il est recommandé de laisser ce paramètre désactivé, sauf si vous faites confiance à la source des fichiers EPUBs.", + "LabelSettingsEpubsAllowScriptedContent": "Autoriser le contenu scénarisé pour les fichiers EPUB", + "LabelSettingsEpubsAllowScriptedContentHelp": "Autoriser les fichiers EPUB à exécuter des scripts. Il est recommandé de laisser ce paramètre désactivé, sauf si vous faites confiance à la source des fichiers EPUB.", "LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales", "LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion GitHub.", "LabelSettingsFindCovers": "Chercher des couvertures de livre", @@ -487,8 +487,8 @@ "LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du livre audio.
Les sous-titres doivent être séparés par des « - »
c’est-à-dire : « Titre du livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »", "LabelSettingsPreferMatchedMetadata": "Préférer les métadonnées par correspondance", "LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de l’article lors d’une recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.", - "LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN", - "LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ISBN", + "LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance pour les livres ayant déjà un ASIN", + "LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance pour les livres ayant déjà un ISBN", "LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri", "LabelSettingsSortingIgnorePrefixesHelp": "c’est-à-dire : pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »", "LabelSettingsSquareBookCovers": "Utiliser des couvertures carrées", @@ -614,7 +614,7 @@ "MessageConfirmPurgeCache": "La purge du cache supprimera l’intégralité du répertoire à /metadata/cache.

Êtes-vous sûr de vouloir supprimer le répertoire de cache ?", "MessageConfirmQuickEmbed": "Attention ! L’intégration rapide ne sauvegardera pas vos fichiers audio. Assurez-vous d’avoir effectuer une sauvegarde de vos fichiers audio.

Souhaitez-vous continuer ?", "MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?", - "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", + "MessageConfirmRemoveAuthor": "Êtes-vous sûr de vouloir supprimer l’auteur « {0} » ?", "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?", "MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?", "MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?", From 3fd290c518b7b6ae99a366a83a4a24417348d118 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 28 May 2024 17:24:02 -0500 Subject: [PATCH 04/16] Remove unused functions, jsdoc updates, auto-formatting --- server/models/Author.js | 102 +++++------ server/models/Book.js | 183 ++++++++++--------- server/models/BookAuthor.js | 29 +-- server/models/BookSeries.js | 31 ++-- server/models/Collection.js | 275 ++++++++++++++--------------- server/models/CollectionBook.js | 29 +-- server/models/Device.js | 39 ++-- server/models/Feed.js | 75 ++++---- server/models/FeedEpisode.js | 59 ++++--- server/models/Library.js | 73 ++++---- server/models/LibraryFolder.js | 38 ++-- server/models/LibraryItem.js | 259 ++++++++++++++------------- server/models/MediaProgress.js | 61 ++++--- server/models/PlaybackSession.js | 60 ++++--- server/models/Playlist.js | 27 +-- server/models/PlaylistMediaItem.js | 35 ++-- server/models/Podcast.js | 73 ++++---- server/models/PodcastEpisode.js | 69 ++++---- server/models/Series.js | 97 +++++----- server/models/Setting.js | 34 ++-- server/models/User.js | 113 ++++++------ 21 files changed, 889 insertions(+), 872 deletions(-) diff --git a/server/models/Author.js b/server/models/Author.js index c6537ec12..cb695386e 100644 --- a/server/models/Author.js +++ b/server/models/Author.js @@ -26,11 +26,6 @@ class Author extends Model { this.createdAt } - static async getOldAuthors() { - const authors = await this.findAll() - return authors.map(au => au.getOldAuthor()) - } - getOldAuthor() { return new oldAuthor({ id: this.id, @@ -85,7 +80,7 @@ class Author extends Model { /** * Get oldAuthor by id - * @param {string} authorId + * @param {string} authorId * @returns {Promise} */ static async getOldById(authorId) { @@ -96,7 +91,7 @@ class Author extends Model { /** * Check if author exists - * @param {string} authorId + * @param {string} authorId * @returns {Promise} */ static async checkExistsById(authorId) { @@ -106,60 +101,67 @@ class Author extends Model { /** * Get old author by name and libraryId. name case insensitive * TODO: Look for authors ignoring punctuation - * - * @param {string} authorName - * @param {string} libraryId + * + * @param {string} authorName + * @param {string} libraryId * @returns {Promise} */ static async getOldByNameAndLibrary(authorName, libraryId) { - const author = (await this.findOne({ - where: [ - where(fn('lower', col('name')), authorName.toLowerCase()), - { - libraryId - } - ] - }))?.getOldAuthor() + const author = ( + await this.findOne({ + where: [ + where(fn('lower', col('name')), authorName.toLowerCase()), + { + libraryId + } + ] + }) + )?.getOldAuthor() return author } /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - name: DataTypes.STRING, - lastFirst: DataTypes.STRING, - asin: DataTypes.STRING, - description: DataTypes.TEXT, - imagePath: DataTypes.STRING - }, { - sequelize, - modelName: 'author', - indexes: [ - { - fields: [{ - name: 'name', - collate: 'NOCASE' - }] + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true }, - // { - // fields: [{ - // name: 'lastFirst', - // collate: 'NOCASE' - // }] - // }, - { - fields: ['libraryId'] - } - ] - }) + name: DataTypes.STRING, + lastFirst: DataTypes.STRING, + asin: DataTypes.STRING, + description: DataTypes.TEXT, + imagePath: DataTypes.STRING + }, + { + sequelize, + modelName: 'author', + indexes: [ + { + fields: [ + { + name: 'name', + collate: 'NOCASE' + } + ] + }, + // { + // fields: [{ + // name: 'lastFirst', + // collate: 'NOCASE' + // }] + // }, + { + fields: ['libraryId'] + } + ] + } + ) const { library } = sequelize.models library.hasMany(Author, { diff --git a/server/models/Book.js b/server/models/Book.js index e2b56fbe3..a8ccf73d6 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -21,13 +21,13 @@ const Logger = require('../Logger') /** * @typedef SeriesExpandedProperties * @property {{sequence:string}} bookSeries - * + * * @typedef {import('./Series') & SeriesExpandedProperties} SeriesExpanded - * + * * @typedef BookExpandedProperties * @property {import('./Author')[]} authors * @property {SeriesExpanded[]} series - * + * * @typedef {Book & BookExpandedProperties} BookExpanded */ @@ -112,29 +112,31 @@ class Book extends Model { const bookExpanded = libraryItemExpanded.media let authors = [] if (bookExpanded.authors?.length) { - authors = bookExpanded.authors.map(au => { + authors = bookExpanded.authors.map((au) => { return { id: au.id, name: au.name } }) } else if (bookExpanded.bookAuthors?.length) { - authors = bookExpanded.bookAuthors.map(ba => { - if (ba.author) { - return { - id: ba.author.id, - name: ba.author.name + authors = bookExpanded.bookAuthors + .map((ba) => { + if (ba.author) { + return { + id: ba.author.id, + name: ba.author.name + } + } else { + Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba) + return null } - } else { - Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba) - return null - } - }).filter(a => a) + }) + .filter((a) => a) } let series = [] if (bookExpanded.series?.length) { - series = bookExpanded.series.map(se => { + series = bookExpanded.series.map((se) => { return { id: se.id, name: se.name, @@ -142,18 +144,20 @@ class Book extends Model { } }) } else if (bookExpanded.bookSeries?.length) { - series = bookExpanded.bookSeries.map(bs => { - if (bs.series) { - return { - id: bs.series.id, - name: bs.series.name, - sequence: bs.sequence + series = bookExpanded.bookSeries + .map((bs) => { + if (bs.series) { + return { + id: bs.series.id, + name: bs.series.name, + sequence: bs.sequence + } + } else { + Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs) + return null } - } else { - Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs) - return null - } - }).filter(s => s) + }) + .filter((s) => s) } return { @@ -185,7 +189,7 @@ class Book extends Model { } /** - * @param {object} oldBook + * @param {object} oldBook * @returns {boolean} true if updated */ static saveFromOld(oldBook) { @@ -194,10 +198,12 @@ class Book extends Model { where: { id: book.id } - }).then(result => result[0] > 0).catch((error) => { - Logger.error(`[Book] Failed to save book ${book.id}`, error) - return false }) + .then((result) => result[0] > 0) + .catch((error) => { + Logger.error(`[Book] Failed to save book ${book.id}`, error) + return false + }) } static getFromOld(oldBook) { @@ -219,7 +225,7 @@ class Book extends Model { ebookFile: oldBook.ebookFile?.toJSON() || null, coverPath: oldBook.coverPath, duration: oldBook.duration, - audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [], + audioFiles: oldBook.audioFiles?.map((af) => af.toJSON()) || [], chapters: oldBook.chapters, tags: oldBook.tags, genres: oldBook.metadata.genres @@ -229,12 +235,12 @@ class Book extends Model { getAbsMetadataJson() { return { tags: this.tags || [], - chapters: this.chapters?.map(c => ({ ...c })) || [], + chapters: this.chapters?.map((c) => ({ ...c })) || [], title: this.title, subtitle: this.subtitle, - authors: this.authors.map(a => a.name), + authors: this.authors.map((a) => a.name), narrators: this.narrators, - series: this.series.map(se => { + series: this.series.map((se) => { const sequence = se.bookSeries?.sequence || '' if (!sequence) return se.name return `${se.name} #${sequence}` @@ -254,61 +260,66 @@ class Book extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - title: DataTypes.STRING, - titleIgnorePrefix: DataTypes.STRING, - subtitle: DataTypes.STRING, - publishedYear: DataTypes.STRING, - publishedDate: DataTypes.STRING, - publisher: DataTypes.STRING, - description: DataTypes.TEXT, - isbn: DataTypes.STRING, - asin: DataTypes.STRING, - language: DataTypes.STRING, - explicit: DataTypes.BOOLEAN, - abridged: DataTypes.BOOLEAN, - coverPath: DataTypes.STRING, - duration: DataTypes.FLOAT, + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: DataTypes.STRING, + titleIgnorePrefix: DataTypes.STRING, + subtitle: DataTypes.STRING, + publishedYear: DataTypes.STRING, + publishedDate: DataTypes.STRING, + publisher: DataTypes.STRING, + description: DataTypes.TEXT, + isbn: DataTypes.STRING, + asin: DataTypes.STRING, + language: DataTypes.STRING, + explicit: DataTypes.BOOLEAN, + abridged: DataTypes.BOOLEAN, + coverPath: DataTypes.STRING, + duration: DataTypes.FLOAT, - narrators: DataTypes.JSON, - audioFiles: DataTypes.JSON, - ebookFile: DataTypes.JSON, - chapters: DataTypes.JSON, - tags: DataTypes.JSON, - genres: DataTypes.JSON - }, { - sequelize, - modelName: 'book', - indexes: [ - { - fields: [{ - name: 'title', - collate: 'NOCASE' - }] - }, - // { - // fields: [{ - // name: 'titleIgnorePrefix', - // collate: 'NOCASE' - // }] - // }, - { - fields: ['publishedYear'] - }, - // { - // fields: ['duration'] - // } - ] - }) + narrators: DataTypes.JSON, + audioFiles: DataTypes.JSON, + ebookFile: DataTypes.JSON, + chapters: DataTypes.JSON, + tags: DataTypes.JSON, + genres: DataTypes.JSON + }, + { + sequelize, + modelName: 'book', + indexes: [ + { + fields: [ + { + name: 'title', + collate: 'NOCASE' + } + ] + }, + // { + // fields: [{ + // name: 'titleIgnorePrefix', + // collate: 'NOCASE' + // }] + // }, + { + fields: ['publishedYear'] + } + // { + // fields: ['duration'] + // } + ] + } + ) } } -module.exports = Book \ No newline at end of file +module.exports = Book diff --git a/server/models/BookAuthor.js b/server/models/BookAuthor.js index 671e94709..45a84f1f9 100644 --- a/server/models/BookAuthor.js +++ b/server/models/BookAuthor.js @@ -25,21 +25,24 @@ class BookAuthor extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + } + }, + { + sequelize, + modelName: 'bookAuthor', + timestamps: true, + updatedAt: false } - }, { - sequelize, - modelName: 'bookAuthor', - timestamps: true, - updatedAt: false - }) + ) // Super Many-to-Many // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship @@ -58,4 +61,4 @@ class BookAuthor extends Model { BookAuthor.belongsTo(author) } } -module.exports = BookAuthor \ No newline at end of file +module.exports = BookAuthor diff --git a/server/models/BookSeries.js b/server/models/BookSeries.js index fe2a07a59..fad547181 100644 --- a/server/models/BookSeries.js +++ b/server/models/BookSeries.js @@ -27,22 +27,25 @@ class BookSeries extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + sequence: DataTypes.STRING }, - sequence: DataTypes.STRING - }, { - sequelize, - modelName: 'bookSeries', - timestamps: true, - updatedAt: false - }) + { + sequelize, + modelName: 'bookSeries', + timestamps: true, + updatedAt: false + } + ) // Super Many-to-Many // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship @@ -62,4 +65,4 @@ class BookSeries extends Model { } } -module.exports = BookSeries \ No newline at end of file +module.exports = BookSeries diff --git a/server/models/Collection.js b/server/models/Collection.js index 9d3a8e0a1..5fa0310d9 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -2,7 +2,6 @@ const { DataTypes, Model, Sequelize } = require('sequelize') const oldCollection = require('../objects/Collection') - class Collection extends Model { constructor(values, options) { super(values, options) @@ -20,27 +19,13 @@ class Collection extends Model { /** @type {Date} */ this.createdAt } - /** - * Get all old collections - * @returns {Promise} - */ - static async getOldCollections() { - const collections = await this.findAll({ - include: { - model: this.sequelize.models.book, - include: this.sequelize.models.libraryItem - }, - order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']] - }) - return collections.map(c => this.getOldCollection(c)) - } /** * Get all old collections toJSONExpanded, items filtered for user permissions - * @param {[oldUser]} user - * @param {[string]} libraryId - * @param {[string[]]} include - * @returns {Promise} oldCollection.toJSONExpanded + * @param {oldUser} [user] + * @param {string} [libraryId] + * @param {string[]} [include] + * @returns {Promise} oldCollection.toJSONExpanded */ static async getOldCollectionsJsonExpanded(user, libraryId, include) { let collectionWhere = null @@ -78,8 +63,7 @@ class Collection extends Model { through: { attributes: ['sequence'] } - }, - + } ] }, ...collectionIncludes @@ -87,11 +71,84 @@ class Collection extends Model { order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']] }) // TODO: Handle user permission restrictions on initial query - return collections.map(c => { - const oldCollection = this.getOldCollection(c) + return collections + .map((c) => { + const oldCollection = this.getOldCollection(c) - // Filter books using user permissions - const books = c.books?.filter(b => { + // Filter books using user permissions + const books = + c.books?.filter((b) => { + if (user) { + if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { + return false + } + if (b.explicit === true && !user.canAccessExplicitContent) { + return false + } + } + return true + }) || [] + + // Map to library items + const libraryItems = books.map((b) => { + const libraryItem = b.libraryItem + delete b.libraryItem + libraryItem.media = b + return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) + }) + + // Users with restricted permissions will not see this collection + if (!books.length && oldCollection.books.length) { + return null + } + + const collectionExpanded = oldCollection.toJSONExpanded(libraryItems) + + // Map feed if found + if (c.feeds?.length) { + collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0]) + } + + return collectionExpanded + }) + .filter((c) => c) + } + + /** + * Get old collection toJSONExpanded, items filtered for user permissions + * @param {oldUser} [user] + * @param {string[]} [include] + * @returns {Promise} oldCollection.toJSONExpanded + */ + async getOldJsonExpanded(user, include) { + this.books = + (await this.getBooks({ + include: [ + { + model: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + } + ], + order: [Sequelize.literal('`collectionBook.order` ASC')] + })) || [] + + const oldCollection = this.sequelize.models.collection.getOldCollection(this) + + // Filter books using user permissions + // TODO: Handle user permission restrictions on initial query + const books = + this.books?.filter((b) => { if (user) { if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { return false @@ -103,77 +160,8 @@ class Collection extends Model { return true }) || [] - // Map to library items - const libraryItems = books.map(b => { - const libraryItem = b.libraryItem - delete b.libraryItem - libraryItem.media = b - return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) - }) - - // Users with restricted permissions will not see this collection - if (!books.length && oldCollection.books.length) { - return null - } - - const collectionExpanded = oldCollection.toJSONExpanded(libraryItems) - - // Map feed if found - if (c.feeds?.length) { - collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0]) - } - - return collectionExpanded - }).filter(c => c) - } - - /** - * Get old collection toJSONExpanded, items filtered for user permissions - * @param {[oldUser]} user - * @param {[string[]]} include - * @returns {Promise} oldCollection.toJSONExpanded - */ - async getOldJsonExpanded(user, include) { - this.books = await this.getBooks({ - include: [ - { - model: this.sequelize.models.libraryItem - }, - { - model: this.sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['sequence'] - } - }, - - ], - order: [Sequelize.literal('`collectionBook.order` ASC')] - }) || [] - - const oldCollection = this.sequelize.models.collection.getOldCollection(this) - - // Filter books using user permissions - // TODO: Handle user permission restrictions on initial query - const books = this.books?.filter(b => { - if (user) { - if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { - return false - } - if (b.explicit === true && !user.canAccessExplicitContent) { - return false - } - } - return true - }) || [] - // Map to library items - const libraryItems = books.map(b => { + const libraryItems = books.map((b) => { const libraryItem = b.libraryItem delete b.libraryItem libraryItem.media = b @@ -199,11 +187,11 @@ class Collection extends Model { /** * Get old collection from Collection - * @param {Collection} collectionExpanded + * @param {Collection} collectionExpanded * @returns {oldCollection} */ static getOldCollection(collectionExpanded) { - const libraryItemIds = collectionExpanded.books?.map(b => b.libraryItem?.id || null).filter(lid => lid) || [] + const libraryItemIds = collectionExpanded.books?.map((b) => b.libraryItem?.id || null).filter((lid) => lid) || [] return new oldCollection({ id: collectionExpanded.id, libraryId: collectionExpanded.libraryId, @@ -215,6 +203,11 @@ class Collection extends Model { }) } + /** + * + * @param {oldCollection} oldCollection + * @returns {Promise} + */ static createFromOld(oldCollection) { const collection = this.getFromOld(oldCollection) return this.create(collection) @@ -239,7 +232,7 @@ class Collection extends Model { /** * Get old collection by id - * @param {string} collectionId + * @param {string} collectionId * @returns {Promise} returns null if not found */ static async getOldById(collectionId) { @@ -260,34 +253,34 @@ class Collection extends Model { * @returns {Promise} */ async getOld() { - this.books = await this.getBooks({ - include: [ - { - model: this.sequelize.models.libraryItem - }, - { - model: this.sequelize.models.author, - through: { - attributes: [] + this.books = + (await this.getBooks({ + include: [ + { + model: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['sequence'] - } - }, - - ], - order: [Sequelize.literal('`collectionBook.order` ASC')] - }) || [] + ], + order: [Sequelize.literal('`collectionBook.order` ASC')] + })) || [] return this.sequelize.models.collection.getOldCollection(this) } /** * Remove all collections belonging to library - * @param {string} libraryId + * @param {string} libraryId * @returns {Promise} number of collections destroyed */ static async removeAllForLibrary(libraryId) { @@ -299,38 +292,26 @@ class Collection extends Model { }) } - static async getAllForBook(bookId) { - const collections = await this.findAll({ - include: { - model: this.sequelize.models.book, - where: { - id: bookId - }, - required: true, - include: this.sequelize.models.libraryItem - }, - order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']] - }) - return collections.map(c => this.getOldCollection(c)) - } - /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + description: DataTypes.TEXT }, - name: DataTypes.STRING, - description: DataTypes.TEXT - }, { - sequelize, - modelName: 'collection' - }) + { + sequelize, + modelName: 'collection' + } + ) const { library } = sequelize.models @@ -339,4 +320,4 @@ class Collection extends Model { } } -module.exports = Collection \ No newline at end of file +module.exports = Collection diff --git a/server/models/CollectionBook.js b/server/models/CollectionBook.js index aab3a1d3e..e04da3b24 100644 --- a/server/models/CollectionBook.js +++ b/server/models/CollectionBook.js @@ -26,19 +26,22 @@ class CollectionBook extends Model { } static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + order: DataTypes.INTEGER }, - order: DataTypes.INTEGER - }, { - sequelize, - timestamps: true, - updatedAt: false, - modelName: 'collectionBook' - }) + { + sequelize, + timestamps: true, + updatedAt: false, + modelName: 'collectionBook' + } + ) // Super Many-to-Many // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship @@ -58,4 +61,4 @@ class CollectionBook extends Model { } } -module.exports = CollectionBook \ No newline at end of file +module.exports = CollectionBook diff --git a/server/models/Device.js b/server/models/Device.js index 24cd22762..896967e4e 100644 --- a/server/models/Device.js +++ b/server/models/Device.js @@ -114,26 +114,29 @@ class Device extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + deviceId: DataTypes.STRING, + clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android + clientVersion: DataTypes.STRING, // e.g. Server version or mobile version + ipAddress: DataTypes.STRING, + deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3 + deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK + extraData: DataTypes.JSON }, - deviceId: DataTypes.STRING, - clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android - clientVersion: DataTypes.STRING, // e.g. Server version or mobile version - ipAddress: DataTypes.STRING, - deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3 - deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'device' - }) + { + sequelize, + modelName: 'device' + } + ) const { user } = sequelize.models @@ -144,4 +147,4 @@ class Device extends Model { } } -module.exports = Device \ No newline at end of file +module.exports = Device diff --git a/server/models/Feed.js b/server/models/Feed.js index d8c5a2a70..72321da92 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -58,7 +58,7 @@ class Feed extends Model { model: this.sequelize.models.feedEpisode } }) - return feeds.map(f => this.getOldFeed(f)) + return feeds.map((f) => this.getOldFeed(f)) } /** @@ -117,7 +117,7 @@ class Feed extends Model { entityType: 'libraryItem' } }) - return feeds.map(f => f.entityId).filter(f => f) || [] + return feeds.map((f) => f.entityId).filter((f) => f) || [] } /** @@ -179,7 +179,7 @@ class Feed extends Model { // Remove and update existing feed episodes for (const feedEpisode of existingFeed.feedEpisodes) { - const oldFeedEpisode = oldFeedEpisodes.find(ep => ep.id === feedEpisode.id) + const oldFeedEpisode = oldFeedEpisodes.find((ep) => ep.id === feedEpisode.id) // Episode removed if (!oldFeedEpisode) { feedEpisode.destroy() @@ -200,7 +200,7 @@ class Feed extends Model { // Add new feed episodes for (const episode of oldFeedEpisodes) { - if (!existingFeed.feedEpisodes.some(fe => fe.id === episode.id)) { + if (!existingFeed.feedEpisodes.some((fe) => fe.id === episode.id)) { await this.sequelize.models.feedEpisode.createFromOld(feedObj.id, episode) hasUpdates = true } @@ -258,41 +258,44 @@ class Feed extends Model { /** * Initialize model - * + * * Polymorphic association: Feeds can be created from LibraryItem, Collection, Playlist or Series * @see https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ - * - * @param {import('../Database').sequelize} sequelize + * + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + slug: DataTypes.STRING, + entityType: DataTypes.STRING, + entityId: DataTypes.UUIDV4, + entityUpdatedAt: DataTypes.DATE, + serverAddress: DataTypes.STRING, + feedURL: DataTypes.STRING, + imageURL: DataTypes.STRING, + siteURL: DataTypes.STRING, + title: DataTypes.STRING, + description: DataTypes.TEXT, + author: DataTypes.STRING, + podcastType: DataTypes.STRING, + language: DataTypes.STRING, + ownerName: DataTypes.STRING, + ownerEmail: DataTypes.STRING, + explicit: DataTypes.BOOLEAN, + preventIndexing: DataTypes.BOOLEAN, + coverPath: DataTypes.STRING }, - slug: DataTypes.STRING, - entityType: DataTypes.STRING, - entityId: DataTypes.UUIDV4, - entityUpdatedAt: DataTypes.DATE, - serverAddress: DataTypes.STRING, - feedURL: DataTypes.STRING, - imageURL: DataTypes.STRING, - siteURL: DataTypes.STRING, - title: DataTypes.STRING, - description: DataTypes.TEXT, - author: DataTypes.STRING, - podcastType: DataTypes.STRING, - language: DataTypes.STRING, - ownerName: DataTypes.STRING, - ownerEmail: DataTypes.STRING, - explicit: DataTypes.BOOLEAN, - preventIndexing: DataTypes.BOOLEAN, - coverPath: DataTypes.STRING - }, { - sequelize, - modelName: 'feed' - }) + { + sequelize, + modelName: 'feed' + } + ) const { user, libraryItem, collection, series, playlist } = sequelize.models @@ -335,7 +338,7 @@ class Feed extends Model { }) Feed.belongsTo(playlist, { foreignKey: 'entityId', constraints: false }) - Feed.addHook('afterFind', findResult => { + Feed.addHook('afterFind', (findResult) => { if (!findResult) return if (!Array.isArray(findResult)) findResult = [findResult] @@ -368,4 +371,4 @@ class Feed extends Model { } } -module.exports = Feed \ No newline at end of file +module.exports = Feed diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js index 016592557..442cc165c 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -65,9 +65,9 @@ class FeedEpisode extends Model { /** * Create feed episode from old model - * - * @param {string} feedId - * @param {Object} oldFeedEpisode + * + * @param {string} feedId + * @param {Object} oldFeedEpisode * @returns {Promise} */ static createFromOld(feedId, oldFeedEpisode) { @@ -98,33 +98,36 @@ class FeedEpisode extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: DataTypes.STRING, + author: DataTypes.STRING, + description: DataTypes.TEXT, + siteURL: DataTypes.STRING, + enclosureURL: DataTypes.STRING, + enclosureType: DataTypes.STRING, + enclosureSize: DataTypes.BIGINT, + pubDate: DataTypes.STRING, + season: DataTypes.STRING, + episode: DataTypes.STRING, + episodeType: DataTypes.STRING, + duration: DataTypes.FLOAT, + filePath: DataTypes.STRING, + explicit: DataTypes.BOOLEAN }, - title: DataTypes.STRING, - author: DataTypes.STRING, - description: DataTypes.TEXT, - siteURL: DataTypes.STRING, - enclosureURL: DataTypes.STRING, - enclosureType: DataTypes.STRING, - enclosureSize: DataTypes.BIGINT, - pubDate: DataTypes.STRING, - season: DataTypes.STRING, - episode: DataTypes.STRING, - episodeType: DataTypes.STRING, - duration: DataTypes.FLOAT, - filePath: DataTypes.STRING, - explicit: DataTypes.BOOLEAN - }, { - sequelize, - modelName: 'feedEpisode' - }) + { + sequelize, + modelName: 'feedEpisode' + } + ) const { feed } = sequelize.models @@ -135,4 +138,4 @@ class FeedEpisode extends Model { } } -module.exports = FeedEpisode \ No newline at end of file +module.exports = FeedEpisode diff --git a/server/models/Library.js b/server/models/Library.js index 49b54d682..103d14b68 100644 --- a/server/models/Library.js +++ b/server/models/Library.js @@ -10,7 +10,7 @@ const oldLibrary = require('../objects/Library') * @property {boolean} skipMatchingMediaWithIsbn * @property {string} autoScanCronExpression * @property {boolean} audiobooksOnly - * @property {boolean} hideSingleBookSeries Do not show series that only have 1 book + * @property {boolean} hideSingleBookSeries Do not show series that only have 1 book * @property {boolean} onlyShowLaterBooksInContinueSeries Skip showing books that are earlier than the max sequence read * @property {string[]} metadataPrecedence */ @@ -54,16 +54,16 @@ class Library extends Model { include: this.sequelize.models.libraryFolder, order: [['displayOrder', 'ASC']] }) - return libraries.map(lib => this.getOldLibrary(lib)) + return libraries.map((lib) => this.getOldLibrary(lib)) } /** * Convert expanded Library to oldLibrary - * @param {Library} libraryExpanded + * @param {Library} libraryExpanded * @returns {Promise} */ static getOldLibrary(libraryExpanded) { - const folders = libraryExpanded.libraryFolders.map(folder => { + const folders = libraryExpanded.libraryFolders.map((folder) => { return { id: folder.id, fullPath: folder.path, @@ -90,13 +90,13 @@ class Library extends Model { } /** - * @param {object} oldLibrary + * @param {object} oldLibrary * @returns {Library|null} */ static async createFromOld(oldLibrary) { const library = this.getFromOld(oldLibrary) - library.libraryFolders = oldLibrary.folders.map(folder => { + library.libraryFolders = oldLibrary.folders.map((folder) => { return { id: folder.id, path: folder.fullPath @@ -113,8 +113,8 @@ class Library extends Model { /** * Update library and library folders - * @param {object} oldLibrary - * @returns + * @param {object} oldLibrary + * @returns */ static async updateFromOld(oldLibrary) { const existingLibrary = await this.findByPk(oldLibrary.id, { @@ -127,7 +127,7 @@ class Library extends Model { const library = this.getFromOld(oldLibrary) - const libraryFolders = oldLibrary.folders.map(folder => { + const libraryFolders = oldLibrary.folders.map((folder) => { return { id: folder.id, path: folder.fullPath, @@ -135,7 +135,7 @@ class Library extends Model { } }) for (const libraryFolder of libraryFolders) { - const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id) + const existingLibraryFolder = existingLibrary.libraryFolders.find((lf) => lf.id === libraryFolder.id) if (!existingLibraryFolder) { await this.sequelize.models.libraryFolder.create(libraryFolder) } else if (existingLibraryFolder.path !== libraryFolder.path) { @@ -143,7 +143,7 @@ class Library extends Model { } } - const libraryFoldersRemoved = existingLibrary.libraryFolders.filter(lf => !libraryFolders.some(_lf => _lf.id === lf.id)) + const libraryFoldersRemoved = existingLibrary.libraryFolders.filter((lf) => !libraryFolders.some((_lf) => _lf.id === lf.id)) for (const existingLibraryFolder of libraryFoldersRemoved) { await existingLibraryFolder.destroy() } @@ -177,8 +177,8 @@ class Library extends Model { /** * Destroy library by id - * @param {string} libraryId - * @returns + * @param {string} libraryId + * @returns */ static removeById(libraryId) { return this.destroy({ @@ -197,12 +197,12 @@ class Library extends Model { attributes: ['id', 'displayOrder'], order: [['displayOrder', 'ASC']] }) - return libraries.map(l => l.id) + return libraries.map((l) => l.id) } /** * Find Library by primary key & return oldLibrary - * @param {string} libraryId + * @param {string} libraryId * @returns {Promise} Returns null if not found */ static async getOldById(libraryId) { @@ -244,29 +244,32 @@ class Library extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + displayOrder: DataTypes.INTEGER, + icon: DataTypes.STRING, + mediaType: DataTypes.STRING, + provider: DataTypes.STRING, + lastScan: DataTypes.DATE, + lastScanVersion: DataTypes.STRING, + settings: DataTypes.JSON, + extraData: DataTypes.JSON }, - name: DataTypes.STRING, - displayOrder: DataTypes.INTEGER, - icon: DataTypes.STRING, - mediaType: DataTypes.STRING, - provider: DataTypes.STRING, - lastScan: DataTypes.DATE, - lastScanVersion: DataTypes.STRING, - settings: DataTypes.JSON, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'library' - }) + { + sequelize, + modelName: 'library' + } + ) } } -module.exports = Library \ No newline at end of file +module.exports = Library diff --git a/server/models/LibraryFolder.js b/server/models/LibraryFolder.js index 6ae7a8ac7..db607547d 100644 --- a/server/models/LibraryFolder.js +++ b/server/models/LibraryFolder.js @@ -16,33 +16,25 @@ class LibraryFolder extends Model { this.updatedAt } - /** - * Gets all library folder path strings - * @returns {Promise} array of library folder paths - */ - static async getAllLibraryFolderPaths() { - const libraryFolders = await this.findAll({ - attributes: ['path'] - }) - return libraryFolders.map(l => l.path) - } - /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + path: DataTypes.STRING }, - path: DataTypes.STRING - }, { - sequelize, - modelName: 'libraryFolder' - }) + { + sequelize, + modelName: 'libraryFolder' + } + ) const { library } = sequelize.models library.hasMany(LibraryFolder, { @@ -52,4 +44,4 @@ class LibraryFolder extends Model { } } -module.exports = LibraryFolder \ No newline at end of file +module.exports = LibraryFolder diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 5a35a5d6a..2eccee199 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -21,8 +21,8 @@ const Podcast = require('./Podcast') /** * @typedef LibraryItemExpandedProperties - * @property {Book.BookExpanded|Podcast.PodcastExpanded} media - * + * @property {Book.BookExpanded|Podcast.PodcastExpanded} media + * * @typedef {LibraryItem & LibraryItemExpandedProperties} LibraryItemExpanded */ @@ -77,7 +77,7 @@ class LibraryItem extends Model { /** * Gets library items partially expanded, not including podcast episodes * @todo temporary solution - * + * * @param {number} offset * @param {number} limit * @returns {Promise} LibraryItem @@ -154,13 +154,13 @@ class LibraryItem extends Model { } ] }) - return libraryItems.map(ti => this.getOldLibraryItem(ti)) + return libraryItems.map((ti) => this.getOldLibraryItem(ti)) } /** * Convert an expanded LibraryItem into an old library item - * - * @param {Model} libraryItemExpanded + * + * @param {Model} libraryItemExpanded * @returns {oldLibraryItem} */ static getOldLibraryItem(libraryItemExpanded) { @@ -231,8 +231,8 @@ class LibraryItem extends Model { /** * Updates libraryItem, book, authors and series from old library item - * - * @param {oldLibraryItem} oldLibraryItem + * + * @param {oldLibraryItem} oldLibraryItem * @returns {Promise} true if updates were made */ static async fullUpdateFromOld(oldLibraryItem) { @@ -280,14 +280,14 @@ class LibraryItem extends Model { for (const existingPodcastEpisode of existingPodcastEpisodes) { // Episode was removed - if (!updatedPodcastEpisodes.some(ep => ep.id === existingPodcastEpisode.id)) { + if (!updatedPodcastEpisodes.some((ep) => ep.id === existingPodcastEpisode.id)) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`) await existingPodcastEpisode.destroy() hasUpdates = true } } for (const updatedPodcastEpisode of updatedPodcastEpisodes) { - const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id) + const existingEpisodeMatch = existingPodcastEpisodes.find((ep) => ep.id === updatedPodcastEpisode.id) if (!existingEpisodeMatch) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode) @@ -316,12 +316,12 @@ class LibraryItem extends Model { const existingAuthors = libraryItemExpanded.media.authors || [] const existingSeriesAll = libraryItemExpanded.media.series || [] const updatedAuthors = oldLibraryItem.media.metadata.authors || [] - const uniqueUpdatedAuthors = updatedAuthors.filter((au, idx) => updatedAuthors.findIndex(a => a.id === au.id) === idx) + const uniqueUpdatedAuthors = updatedAuthors.filter((au, idx) => updatedAuthors.findIndex((a) => a.id === au.id) === idx) const updatedSeriesAll = oldLibraryItem.media.metadata.series || [] for (const existingAuthor of existingAuthors) { // Author was removed from Book - if (!uniqueUpdatedAuthors.some(au => au.id === existingAuthor.id)) { + if (!uniqueUpdatedAuthors.some((au) => au.id === existingAuthor.id)) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id) hasUpdates = true @@ -329,7 +329,7 @@ class LibraryItem extends Model { } for (const updatedAuthor of uniqueUpdatedAuthors) { // Author was added - if (!existingAuthors.some(au => au.id === updatedAuthor.id)) { + if (!existingAuthors.some((au) => au.id === updatedAuthor.id)) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`) await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id }) hasUpdates = true @@ -337,7 +337,7 @@ class LibraryItem extends Model { } for (const existingSeries of existingSeriesAll) { // Series was removed - if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) { + if (!updatedSeriesAll.some((se) => se.id === existingSeries.id)) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id) hasUpdates = true @@ -345,7 +345,7 @@ class LibraryItem extends Model { } for (const updatedSeries of updatedSeriesAll) { // Series was added/updated - const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id) + const existingSeriesMatch = existingSeriesAll.find((se) => se.id === updatedSeries.id) if (!existingSeriesMatch) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`) await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence }) @@ -420,7 +420,7 @@ class LibraryItem extends Model { lastScanVersion: oldLibraryItem.scanVersion, libraryId: oldLibraryItem.libraryId, libraryFolderId: oldLibraryItem.folderId, - libraryFiles: oldLibraryItem.libraryFiles?.map(lf => lf.toJSON()) || [], + libraryFiles: oldLibraryItem.libraryFiles?.map((lf) => lf.toJSON()) || [], extraData } } @@ -435,8 +435,8 @@ class LibraryItem extends Model { } /** - * - * @param {string} libraryItemId + * + * @param {string} libraryItemId * @returns {Promise} */ static async getExpandedById(libraryItemId) { @@ -485,7 +485,7 @@ class LibraryItem extends Model { /** * Get old library item by id - * @param {string} libraryItemId + * @param {string} libraryItemId * @returns {oldLibraryItem} */ static async getOldById(libraryItemId) { @@ -534,9 +534,9 @@ class LibraryItem extends Model { /** * Get library items using filter and sort - * @param {oldLibrary} library - * @param {oldUser} user - * @param {object} options + * @param {oldLibrary} library + * @param {oldUser} user + * @param {object} options * @returns {object} { libraryItems:oldLibraryItem[], count:number } */ static async getByFilterAndSort(library, user, options) { @@ -545,7 +545,7 @@ class LibraryItem extends Model { Logger.debug(`Loaded ${libraryItems.length} of ${count} items for libary page in ${((Date.now() - start) / 1000).toFixed(2)}s`) return { - libraryItems: libraryItems.map(li => { + libraryItems: libraryItems.map((li) => { const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified() if (li.collapsedSeries) { oldLibraryItem.collapsedSeries = li.collapsedSeries @@ -574,10 +574,10 @@ class LibraryItem extends Model { /** * Get home page data personalized shelves - * @param {oldLibrary} library - * @param {oldUser} user - * @param {string[]} include - * @param {number} limit + * @param {oldLibrary} library + * @param {oldUser} user + * @param {string[]} include + * @param {number} limit * @returns {object[]} array of shelf objects */ static async getPersonalizedShelves(library, user, include, limit) { @@ -588,8 +588,8 @@ class LibraryItem extends Model { // "Continue Listening" shelf const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false) if (itemsInProgressPayload.items.length) { - const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter(li => li.media.isEBookOnly) - const audioOnlyItemsInProgress = itemsInProgressPayload.items.filter(li => !li.media.isEBookOnly) + const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.isEBookOnly) + const audioOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => !li.media.isEBookOnly) shelves.push({ id: 'continue-listening', @@ -697,8 +697,8 @@ class LibraryItem extends Model { // "Listen Again" shelf const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit) if (mediaFinishedPayload.items.length) { - const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter(li => li.media.isEBookOnly) - const audioOnlyItemsInProgress = mediaFinishedPayload.items.filter(li => !li.media.isEBookOnly) + const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.isEBookOnly) + const audioOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => !li.media.isEBookOnly) shelves.push({ id: 'listen-again', @@ -748,27 +748,27 @@ class LibraryItem extends Model { /** * Get book library items for author, optional use user permissions * @param {oldAuthor} author - * @param {[oldUser]} user + * @param {[oldUser]} user * @returns {Promise} */ static async getForAuthor(author, user = null) { const { libraryItems } = await libraryFilters.getLibraryItemsForAuthor(author, user, undefined, undefined) - return libraryItems.map(li => this.getOldLibraryItem(li)) + return libraryItems.map((li) => this.getOldLibraryItem(li)) } /** * Get book library items in a collection - * @param {oldCollection} collection + * @param {oldCollection} collection * @returns {Promise} */ static async getForCollection(collection) { const libraryItems = await libraryFilters.getLibraryItemsForCollection(collection) - return libraryItems.map(li => this.getOldLibraryItem(li)) + return libraryItems.map((li) => this.getOldLibraryItem(li)) } /** * Check if library item exists - * @param {string} libraryItemId + * @param {string} libraryItemId * @returns {Promise} */ static async checkExistsById(libraryItemId) { @@ -776,8 +776,8 @@ class LibraryItem extends Model { } /** - * - * @param {import('sequelize').WhereOptions} where + * + * @param {import('sequelize').WhereOptions} where * @param {import('sequelize').BindOrReplacements} replacements * @returns {Object} oldLibraryItem */ @@ -822,8 +822,8 @@ class LibraryItem extends Model { } /** - * - * @param {import('sequelize').FindOptions} options + * + * @param {import('sequelize').FindOptions} options * @returns {Promise} */ getMedia(options) { @@ -833,7 +833,7 @@ class LibraryItem extends Model { } /** - * + * * @returns {Promise} */ getMediaExpanded() { @@ -870,7 +870,7 @@ class LibraryItem extends Model { } /** - * + * * @returns {Promise} */ async saveMetadataFile() { @@ -887,18 +887,18 @@ class LibraryItem extends Model { const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) // Expanded with series, authors, podcastEpisodes - const mediaExpanded = this.media || await this.getMediaExpanded() + const mediaExpanded = this.media || (await this.getMediaExpanded()) let jsonObject = {} if (this.mediaType === 'book') { jsonObject = { tags: mediaExpanded.tags || [], - chapters: mediaExpanded.chapters?.map(c => ({ ...c })) || [], + chapters: mediaExpanded.chapters?.map((c) => ({ ...c })) || [], title: mediaExpanded.title, subtitle: mediaExpanded.subtitle, - authors: mediaExpanded.authors.map(a => a.name), + authors: mediaExpanded.authors.map((a) => a.name), narrators: mediaExpanded.narrators, - series: mediaExpanded.series.map(se => { + series: mediaExpanded.series.map((se) => { const sequence = se.bookSeries?.sequence || '' if (!sequence) return se.name return `${se.name} #${sequence}` @@ -934,96 +934,101 @@ class LibraryItem extends Model { } } - - return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, 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) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - metadataLibraryFile = newLibraryFile.toJSON() - 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 + return fsExtra + .writeFile(metadataFilePath, JSON.stringify(jsonObject, 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) { + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) + metadataLibraryFile = newLibraryFile.toJSON() + 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.mtime = libraryItemDirTimestamps.mtimeMs + this.ctime = libraryItemDirTimestamps.ctimeMs + let size = 0 + this.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + this.size = size + await this.save() } } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) - if (libraryItemDirTimestamps) { - this.mtime = libraryItemDirTimestamps.mtimeMs - this.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - this.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - this.size = size - await this.save() - } - } - Logger.debug(`Success saving abmetadata to "${metadataFilePath}"`) + Logger.debug(`Success saving abmetadata to "${metadataFilePath}"`) - return metadataLibraryFile - }).catch((error) => { - Logger.error(`Failed to save json file at "${metadataFilePath}"`, error) - return null - }) + return metadataLibraryFile + }) + .catch((error) => { + Logger.error(`Failed to save json file at "${metadataFilePath}"`, error) + return null + }) } /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + ino: DataTypes.STRING, + path: DataTypes.STRING, + relPath: DataTypes.STRING, + mediaId: DataTypes.UUIDV4, + mediaType: DataTypes.STRING, + isFile: DataTypes.BOOLEAN, + isMissing: DataTypes.BOOLEAN, + isInvalid: DataTypes.BOOLEAN, + mtime: DataTypes.DATE(6), + ctime: DataTypes.DATE(6), + birthtime: DataTypes.DATE(6), + size: DataTypes.BIGINT, + lastScan: DataTypes.DATE, + lastScanVersion: DataTypes.STRING, + libraryFiles: DataTypes.JSON, + extraData: DataTypes.JSON }, - ino: DataTypes.STRING, - path: DataTypes.STRING, - relPath: DataTypes.STRING, - mediaId: DataTypes.UUIDV4, - mediaType: DataTypes.STRING, - isFile: DataTypes.BOOLEAN, - isMissing: DataTypes.BOOLEAN, - isInvalid: DataTypes.BOOLEAN, - mtime: DataTypes.DATE(6), - ctime: DataTypes.DATE(6), - birthtime: DataTypes.DATE(6), - size: DataTypes.BIGINT, - lastScan: DataTypes.DATE, - lastScanVersion: DataTypes.STRING, - libraryFiles: DataTypes.JSON, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'libraryItem', - indexes: [ - { - fields: ['createdAt'] - }, - { - fields: ['mediaId'] - }, - { - fields: ['libraryId', 'mediaType'] - }, - { - fields: ['libraryId', 'mediaId', 'mediaType'] - }, - { - fields: ['birthtime'] - }, - { - fields: ['mtime'] - } - ] - }) + { + sequelize, + modelName: 'libraryItem', + indexes: [ + { + fields: ['createdAt'] + }, + { + fields: ['mediaId'] + }, + { + fields: ['libraryId', 'mediaType'] + }, + { + fields: ['libraryId', 'mediaId', 'mediaType'] + }, + { + fields: ['birthtime'] + }, + { + fields: ['mtime'] + } + ] + } + ) const { library, libraryFolder, book, podcast } = sequelize.models library.hasMany(LibraryItem) @@ -1050,7 +1055,7 @@ class LibraryItem extends Model { }) LibraryItem.belongsTo(podcast, { foreignKey: 'mediaId', constraints: false }) - LibraryItem.addHook('afterFind', findResult => { + LibraryItem.addHook('afterFind', (findResult) => { if (!findResult) return if (!Array.isArray(findResult)) findResult = [findResult] @@ -1070,7 +1075,7 @@ class LibraryItem extends Model { } }) - LibraryItem.addHook('afterDestroy', async instance => { + LibraryItem.addHook('afterDestroy', async (instance) => { if (!instance) return const media = await instance.getMedia() if (media) { diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index 6214d6495..5c571c739 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -100,38 +100,41 @@ class MediaProgress extends Model { /** * Initialize model - * + * * Polymorphic association: Book has many MediaProgress. PodcastEpisode has many MediaProgress. * @see https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ - * - * @param {import('../Database').sequelize} sequelize + * + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + mediaItemId: DataTypes.UUIDV4, + mediaItemType: DataTypes.STRING, + duration: DataTypes.FLOAT, + currentTime: DataTypes.FLOAT, + isFinished: DataTypes.BOOLEAN, + hideFromContinueListening: DataTypes.BOOLEAN, + ebookLocation: DataTypes.STRING, + ebookProgress: DataTypes.FLOAT, + finishedAt: DataTypes.DATE, + extraData: DataTypes.JSON }, - mediaItemId: DataTypes.UUIDV4, - mediaItemType: DataTypes.STRING, - duration: DataTypes.FLOAT, - currentTime: DataTypes.FLOAT, - isFinished: DataTypes.BOOLEAN, - hideFromContinueListening: DataTypes.BOOLEAN, - ebookLocation: DataTypes.STRING, - ebookProgress: DataTypes.FLOAT, - finishedAt: DataTypes.DATE, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'mediaProgress', - indexes: [ - { - fields: ['updatedAt'] - } - ] - }) + { + sequelize, + modelName: 'mediaProgress', + indexes: [ + { + fields: ['updatedAt'] + } + ] + } + ) const { book, podcastEpisode, user } = sequelize.models @@ -153,7 +156,7 @@ class MediaProgress extends Model { }) MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) - MediaProgress.addHook('afterFind', findResult => { + MediaProgress.addHook('afterFind', (findResult) => { if (!findResult) return if (!Array.isArray(findResult)) findResult = [findResult] @@ -181,4 +184,4 @@ class MediaProgress extends Model { } } -module.exports = MediaProgress \ No newline at end of file +module.exports = MediaProgress diff --git a/server/models/PlaybackSession.js b/server/models/PlaybackSession.js index cca73cc57..5442387f6 100644 --- a/server/models/PlaybackSession.js +++ b/server/models/PlaybackSession.js @@ -2,7 +2,6 @@ const { DataTypes, Model } = require('sequelize') const oldPlaybackSession = require('../objects/PlaybackSession') - class PlaybackSession extends Model { constructor(values, options) { super(values, options) @@ -62,7 +61,7 @@ class PlaybackSession extends Model { } ] }) - return playbackSessions.map(session => this.getOldPlaybackSession(session)) + return playbackSessions.map((session) => this.getOldPlaybackSession(session)) } static async getById(sessionId) { @@ -170,35 +169,38 @@ class PlaybackSession extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + mediaItemId: DataTypes.UUIDV4, + mediaItemType: DataTypes.STRING, + displayTitle: DataTypes.STRING, + displayAuthor: DataTypes.STRING, + duration: DataTypes.FLOAT, + playMethod: DataTypes.INTEGER, + mediaPlayer: DataTypes.STRING, + startTime: DataTypes.FLOAT, + currentTime: DataTypes.FLOAT, + serverVersion: DataTypes.STRING, + coverPath: DataTypes.STRING, + timeListening: DataTypes.INTEGER, + mediaMetadata: DataTypes.JSON, + date: DataTypes.STRING, + dayOfWeek: DataTypes.STRING, + extraData: DataTypes.JSON }, - mediaItemId: DataTypes.UUIDV4, - mediaItemType: DataTypes.STRING, - displayTitle: DataTypes.STRING, - displayAuthor: DataTypes.STRING, - duration: DataTypes.FLOAT, - playMethod: DataTypes.INTEGER, - mediaPlayer: DataTypes.STRING, - startTime: DataTypes.FLOAT, - currentTime: DataTypes.FLOAT, - serverVersion: DataTypes.STRING, - coverPath: DataTypes.STRING, - timeListening: DataTypes.INTEGER, - mediaMetadata: DataTypes.JSON, - date: DataTypes.STRING, - dayOfWeek: DataTypes.STRING, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'playbackSession' - }) + { + sequelize, + modelName: 'playbackSession' + } + ) const { book, podcastEpisode, user, device, library } = sequelize.models @@ -229,7 +231,7 @@ class PlaybackSession extends Model { }) PlaybackSession.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) - PlaybackSession.addHook('afterFind', findResult => { + PlaybackSession.addHook('afterFind', (findResult) => { if (!findResult) return if (!Array.isArray(findResult)) findResult = [findResult] diff --git a/server/models/Playlist.js b/server/models/Playlist.js index fedc83b2d..fbc5f96aa 100644 --- a/server/models/Playlist.js +++ b/server/models/Playlist.js @@ -23,29 +23,6 @@ class Playlist extends Model { this.updatedAt } - static async getOldPlaylists() { - const playlists = await this.findAll({ - include: { - model: this.sequelize.models.playlistMediaItem, - include: [ - { - model: this.sequelize.models.book, - include: this.sequelize.models.libraryItem - }, - { - model: this.sequelize.models.podcastEpisode, - include: { - model: this.sequelize.models.podcast, - include: this.sequelize.models.libraryItem - } - } - ] - }, - order: [['playlistMediaItems', 'order', 'ASC']] - }) - return playlists.map((p) => this.getOldPlaylist(p)) - } - static getOldPlaylist(playlistExpanded) { const items = playlistExpanded.playlistMediaItems .map((pmi) => { @@ -76,8 +53,8 @@ class Playlist extends Model { /** * Get old playlist toJSONExpanded - * @param {[string[]]} include - * @returns {Promise} oldPlaylist.toJSONExpanded + * @param {string[]} [include] + * @returns {Promise} oldPlaylist.toJSONExpanded */ async getOldJsonExpanded(include) { this.playlistMediaItems = diff --git a/server/models/PlaylistMediaItem.js b/server/models/PlaylistMediaItem.js index 8decc7ed7..25e7b8c55 100644 --- a/server/models/PlaylistMediaItem.js +++ b/server/models/PlaylistMediaItem.js @@ -35,24 +35,27 @@ class PlaylistMediaItem extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + mediaItemId: DataTypes.UUIDV4, + mediaItemType: DataTypes.STRING, + order: DataTypes.INTEGER }, - mediaItemId: DataTypes.UUIDV4, - mediaItemType: DataTypes.STRING, - order: DataTypes.INTEGER - }, { - sequelize, - timestamps: true, - updatedAt: false, - modelName: 'playlistMediaItem' - }) + { + sequelize, + timestamps: true, + updatedAt: false, + modelName: 'playlistMediaItem' + } + ) const { book, podcastEpisode, playlist } = sequelize.models @@ -74,7 +77,7 @@ class PlaylistMediaItem extends Model { }) PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) - PlaylistMediaItem.addHook('afterFind', findResult => { + PlaylistMediaItem.addHook('afterFind', (findResult) => { if (!findResult) return if (!Array.isArray(findResult)) findResult = [findResult] diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 940ae0ab1..60f879d0e 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -3,7 +3,7 @@ const { DataTypes, Model } = require('sequelize') /** * @typedef PodcastExpandedProperties * @property {import('./PodcastEpisode')[]} podcastEpisodes - * + * * @typedef {Podcast & PodcastExpandedProperties} PodcastExpanded */ @@ -61,7 +61,7 @@ class Podcast extends Model { static getOldPodcast(libraryItemExpanded) { const podcastExpanded = libraryItemExpanded.media - const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index) + const podcastEpisodes = podcastExpanded.podcastEpisodes?.map((ep) => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index) return { id: podcastExpanded.id, libraryItemId: libraryItemExpanded.id, @@ -140,42 +140,45 @@ class Podcast extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - title: DataTypes.STRING, - titleIgnorePrefix: DataTypes.STRING, - author: DataTypes.STRING, - releaseDate: DataTypes.STRING, - feedURL: DataTypes.STRING, - imageURL: DataTypes.STRING, - description: DataTypes.TEXT, - itunesPageURL: DataTypes.STRING, - itunesId: DataTypes.STRING, - itunesArtistId: DataTypes.STRING, - language: DataTypes.STRING, - podcastType: DataTypes.STRING, - explicit: DataTypes.BOOLEAN, + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: DataTypes.STRING, + titleIgnorePrefix: DataTypes.STRING, + author: DataTypes.STRING, + releaseDate: DataTypes.STRING, + feedURL: DataTypes.STRING, + imageURL: DataTypes.STRING, + description: DataTypes.TEXT, + itunesPageURL: DataTypes.STRING, + itunesId: DataTypes.STRING, + itunesArtistId: DataTypes.STRING, + language: DataTypes.STRING, + podcastType: DataTypes.STRING, + explicit: DataTypes.BOOLEAN, - autoDownloadEpisodes: DataTypes.BOOLEAN, - autoDownloadSchedule: DataTypes.STRING, - lastEpisodeCheck: DataTypes.DATE, - maxEpisodesToKeep: DataTypes.INTEGER, - maxNewEpisodesToDownload: DataTypes.INTEGER, - coverPath: DataTypes.STRING, - tags: DataTypes.JSON, - genres: DataTypes.JSON - }, { - sequelize, - modelName: 'podcast' - }) + autoDownloadEpisodes: DataTypes.BOOLEAN, + autoDownloadSchedule: DataTypes.STRING, + lastEpisodeCheck: DataTypes.DATE, + maxEpisodesToKeep: DataTypes.INTEGER, + maxNewEpisodesToDownload: DataTypes.INTEGER, + coverPath: DataTypes.STRING, + tags: DataTypes.JSON, + genres: DataTypes.JSON + }, + { + sequelize, + modelName: 'podcast' + } + ) } } -module.exports = Podcast \ No newline at end of file +module.exports = Podcast diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 2fdefb86b..1707fbd5f 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -54,7 +54,7 @@ class PodcastEpisode extends Model { } /** - * @param {string} libraryItemId + * @param {string} libraryItemId * @returns {oldPodcastEpisode} */ getOldPodcastEpisode(libraryItemId = null) { @@ -125,40 +125,43 @@ class PodcastEpisode extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - index: DataTypes.INTEGER, - season: DataTypes.STRING, - episode: DataTypes.STRING, - episodeType: DataTypes.STRING, - title: DataTypes.STRING, - subtitle: DataTypes.STRING(1000), - description: DataTypes.TEXT, - pubDate: DataTypes.STRING, - enclosureURL: DataTypes.STRING, - enclosureSize: DataTypes.BIGINT, - enclosureType: DataTypes.STRING, - publishedAt: DataTypes.DATE, + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + index: DataTypes.INTEGER, + season: DataTypes.STRING, + episode: DataTypes.STRING, + episodeType: DataTypes.STRING, + title: DataTypes.STRING, + subtitle: DataTypes.STRING(1000), + description: DataTypes.TEXT, + pubDate: DataTypes.STRING, + enclosureURL: DataTypes.STRING, + enclosureSize: DataTypes.BIGINT, + enclosureType: DataTypes.STRING, + publishedAt: DataTypes.DATE, - audioFile: DataTypes.JSON, - chapters: DataTypes.JSON, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'podcastEpisode', - indexes: [ - { - fields: ['createdAt'] - } - ] - }) + audioFile: DataTypes.JSON, + chapters: DataTypes.JSON, + extraData: DataTypes.JSON + }, + { + sequelize, + modelName: 'podcastEpisode', + indexes: [ + { + fields: ['createdAt'] + } + ] + } + ) const { podcast } = sequelize.models podcast.hasMany(PodcastEpisode, { @@ -168,4 +171,4 @@ class PodcastEpisode extends Model { } } -module.exports = PodcastEpisode \ No newline at end of file +module.exports = PodcastEpisode diff --git a/server/models/Series.js b/server/models/Series.js index 81c27a8bd..9f8f1c561 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -24,7 +24,7 @@ class Series extends Model { static async getAllOldSeries() { const series = await this.findAll() - return series.map(se => se.getOldSeries()) + return series.map((se) => se.getOldSeries()) } getOldSeries() { @@ -77,7 +77,7 @@ class Series extends Model { /** * Get oldSeries by id - * @param {string} seriesId + * @param {string} seriesId * @returns {Promise} */ static async getOldById(seriesId) { @@ -88,7 +88,7 @@ class Series extends Model { /** * Check if series exists - * @param {string} seriesId + * @param {string} seriesId * @returns {Promise} */ static async checkExistsById(seriesId) { @@ -97,58 +97,65 @@ class Series extends Model { /** * Get old series by name and libraryId. name case insensitive - * - * @param {string} seriesName - * @param {string} libraryId + * + * @param {string} seriesName + * @param {string} libraryId * @returns {Promise} */ static async getOldByNameAndLibrary(seriesName, libraryId) { - const series = (await this.findOne({ - where: [ - where(fn('lower', col('name')), seriesName.toLowerCase()), - { - libraryId - } - ] - }))?.getOldSeries() + const series = ( + await this.findOne({ + where: [ + where(fn('lower', col('name')), seriesName.toLowerCase()), + { + libraryId + } + ] + }) + )?.getOldSeries() return series } /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - name: DataTypes.STRING, - nameIgnorePrefix: DataTypes.STRING, - description: DataTypes.TEXT - }, { - sequelize, - modelName: 'series', - indexes: [ - { - fields: [{ - name: 'name', - collate: 'NOCASE' - }] + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true }, - // { - // fields: [{ - // name: 'nameIgnorePrefix', - // collate: 'NOCASE' - // }] - // }, - { - fields: ['libraryId'] - } - ] - }) + name: DataTypes.STRING, + nameIgnorePrefix: DataTypes.STRING, + description: DataTypes.TEXT + }, + { + sequelize, + modelName: 'series', + indexes: [ + { + fields: [ + { + name: 'name', + collate: 'NOCASE' + } + ] + }, + // { + // fields: [{ + // name: 'nameIgnorePrefix', + // collate: 'NOCASE' + // }] + // }, + { + fields: ['libraryId'] + } + ] + } + ) const { library } = sequelize.models library.hasMany(Series, { @@ -158,4 +165,4 @@ class Series extends Model { } } -module.exports = Series \ No newline at end of file +module.exports = Series diff --git a/server/models/Setting.js b/server/models/Setting.js index c3348e246..1fffa32c1 100644 --- a/server/models/Setting.js +++ b/server/models/Setting.js @@ -19,12 +19,11 @@ class Setting extends Model { } static async getOldSettings() { - const settings = (await this.findAll()).map(se => se.value) + const settings = (await this.findAll()).map((se) => se.value) - - const emailSettingsJson = settings.find(se => se.id === 'email-settings') - const serverSettingsJson = settings.find(se => se.id === 'server-settings') - const notificationSettingsJson = settings.find(se => se.id === 'notification-settings') + const emailSettingsJson = settings.find((se) => se.id === 'email-settings') + const serverSettingsJson = settings.find((se) => se.id === 'server-settings') + const notificationSettingsJson = settings.find((se) => se.id === 'notification-settings') return { settings, @@ -43,20 +42,23 @@ class Setting extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - key: { - type: DataTypes.STRING, - primaryKey: true + super.init( + { + key: { + type: DataTypes.STRING, + primaryKey: true + }, + value: DataTypes.JSON }, - value: DataTypes.JSON - }, { - sequelize, - modelName: 'setting' - }) + { + sequelize, + modelName: 'setting' + } + ) } } -module.exports = Setting \ No newline at end of file +module.exports = Setting diff --git a/server/models/User.js b/server/models/User.js index 220c0c406..a714ca0f8 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -1,4 +1,4 @@ -const uuidv4 = require("uuid").v4 +const uuidv4 = require('uuid').v4 const sequelize = require('sequelize') const Logger = require('../Logger') const oldUser = require('../objects/user/User') @@ -45,17 +45,17 @@ class User extends Model { const users = await this.findAll({ include: this.sequelize.models.mediaProgress }) - return users.map(u => this.getOldUser(u)) + return users.map((u) => this.getOldUser(u)) } /** * Get old user model from new - * - * @param {Object} userExpanded + * + * @param {Object} userExpanded * @returns {oldUser} */ static getOldUser(userExpanded) { - const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress()) + const mediaProgress = userExpanded.mediaProgresses.map((mp) => mp.getOldMediaProgress()) const librariesAccessible = userExpanded.permissions?.librariesAccessible || [] const itemTagsSelected = userExpanded.permissions?.itemTagsSelected || [] @@ -86,8 +86,8 @@ class User extends Model { } /** - * - * @param {oldUser} oldUser + * + * @param {oldUser} oldUser * @returns {Promise} */ static createFromOld(oldUser) { @@ -97,8 +97,8 @@ class User extends Model { /** * Update User from old user model - * - * @param {oldUser} oldUser + * + * @param {oldUser} oldUser * @param {boolean} [hooks=true] Run before / after bulk update hooks? * @returns {Promise} */ @@ -109,16 +109,18 @@ class User extends Model { where: { id: user.id } - }).then((result) => result[0] > 0).catch((error) => { - Logger.error(`[User] Failed to save user ${oldUser.id}`, error) - return false }) + .then((result) => result[0] > 0) + .catch((error) => { + Logger.error(`[User] Failed to save user ${oldUser.id}`, error) + return false + }) } /** * Get new User model from old - * - * @param {oldUser} oldUser + * + * @param {oldUser} oldUser * @returns {Object} */ static getFromOld(oldUser) { @@ -160,9 +162,9 @@ class User extends Model { /** * Create root user - * @param {string} username - * @param {string} pash - * @param {Auth} auth + * @param {string} username + * @param {string} pash + * @param {Auth} auth * @returns {Promise} */ static async createRootUser(username, pash, auth) { @@ -185,15 +187,15 @@ class User extends Model { /** * Create user from openid userinfo - * @param {Object} userinfo - * @param {Auth} auth + * @param {Object} userinfo + * @param {Auth} auth * @returns {Promise} */ static async createUserFromOpenIdUserInfo(userinfo, auth) { const userId = uuidv4() // TODO: Ensure username is unique? const username = userinfo.preferred_username || userinfo.name || userinfo.sub - const email = (userinfo.email && userinfo.email_verified) ? userinfo.email : null + const email = userinfo.email && userinfo.email_verified ? userinfo.email : null const token = await auth.generateAccessToken({ id: userId, username }) @@ -218,7 +220,7 @@ class User extends Model { /** * Get a user by id or by the old database id * @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id - * @param {string} userId + * @param {string} userId * @returns {Promise} null if not found */ static async getUserByIdOrOldId(userId) { @@ -244,7 +246,7 @@ class User extends Model { /** * Get user by username case insensitive - * @param {string} username + * @param {string} username * @returns {Promise} returns null if not found */ static async getUserByUsername(username) { @@ -263,7 +265,7 @@ class User extends Model { /** * Get user by email case insensitive - * @param {string} username + * @param {string} username * @returns {Promise} returns null if not found */ static async getUserByEmail(email) { @@ -282,7 +284,7 @@ class User extends Model { /** * Get user by id - * @param {string} userId + * @param {string} userId * @returns {Promise} returns null if not found */ static async getUserById(userId) { @@ -296,7 +298,7 @@ class User extends Model { /** * Get user by openid sub - * @param {string} sub + * @param {string} sub * @returns {Promise} returns null if not found */ static async getUserByOpenIDSub(sub) { @@ -317,7 +319,7 @@ class User extends Model { const users = await this.findAll({ attributes: ['id', 'username'] }) - return users.map(u => { + return users.map((u) => { return { id: u.id, username: u.username @@ -340,37 +342,40 @@ class User extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + username: DataTypes.STRING, + email: DataTypes.STRING, + pash: DataTypes.STRING, + type: DataTypes.STRING, + token: DataTypes.STRING, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + isLocked: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + lastSeen: DataTypes.DATE, + permissions: DataTypes.JSON, + bookmarks: DataTypes.JSON, + extraData: DataTypes.JSON }, - username: DataTypes.STRING, - email: DataTypes.STRING, - pash: DataTypes.STRING, - type: DataTypes.STRING, - token: DataTypes.STRING, - isActive: { - type: DataTypes.BOOLEAN, - defaultValue: false - }, - isLocked: { - type: DataTypes.BOOLEAN, - defaultValue: false - }, - lastSeen: DataTypes.DATE, - permissions: DataTypes.JSON, - bookmarks: DataTypes.JSON, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'user' - }) + { + sequelize, + modelName: 'user' + } + ) } } -module.exports = User \ No newline at end of file +module.exports = User From 6edbab863a658139adfa90f02d7077e303b82e4c Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 29 May 2024 16:23:47 -0500 Subject: [PATCH 05/16] Update:nodemailer version bump to 6.9.13 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 41bbdf54b..cc38ce70c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", "node-tone": "^1.0.1", - "nodemailer": "^6.9.2", + "nodemailer": "^6.9.13", "openid-client": "^5.6.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", @@ -3619,9 +3619,9 @@ "integrity": "sha512-wi7L0taDZMN6tM5l85TDKHsYzdhqJTtPNgvgpk2zHeZzPt6ZIUZ9vBLTJRRDpm0xzCvbsvFHjAaudeQjLHTE4w==" }, "node_modules/nodemailer": { - "version": "6.9.8", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.8.tgz", - "integrity": "sha512-cfrYUk16e67Ks051i4CntM9kshRYei1/o/Gi8K1d+R34OIs21xdFnW7Pt7EucmVKA0LKtqUGNcjMZ7ehjl49mQ==", + "version": "6.9.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz", + "integrity": "sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==", "engines": { "node": ">=6.0.0" } diff --git a/package.json b/package.json index e31a28fc1..989614e27 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", "node-tone": "^1.0.1", - "nodemailer": "^6.9.2", + "nodemailer": "^6.9.13", "openid-client": "^5.6.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", From 941f3248d84eb15b66eb3cb005897a315f4e99dc Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 29 May 2024 16:59:43 -0500 Subject: [PATCH 06/16] Add:SMTP email setting to disable certificate verification #3030 --- client/pages/config/email.vue | 31 +++++++++-- client/strings/bg.json | 2 + client/strings/bn.json | 2 + client/strings/cs.json | 2 + client/strings/da.json | 2 + client/strings/de.json | 4 +- client/strings/en-us.json | 2 + client/strings/es.json | 2 + client/strings/et.json | 2 + client/strings/fr.json | 4 +- client/strings/gu.json | 2 + client/strings/he.json | 2 + client/strings/hi.json | 2 + client/strings/hr.json | 2 + client/strings/hu.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/pt-br.json | 2 + client/strings/ru.json | 2 + client/strings/sv.json | 2 + client/strings/uk.json | 2 + client/strings/vi-vn.json | 2 + client/strings/zh-cn.json | 2 + client/strings/zh-tw.json | 2 + server/objects/settings/EmailSettings.js | 71 +++++++++++++++--------- 28 files changed, 123 insertions(+), 35 deletions(-) diff --git a/client/pages/config/email.vue b/client/pages/config/email.vue index 47aec6215..ef864fbc2 100644 --- a/client/pages/config/email.vue +++ b/client/pages/config/email.vue @@ -20,13 +20,30 @@
- - -
- {{ $strings.LabelEmailSettingsSecure }} - info_outlined +
+ +
+ + +
+ {{ $strings.LabelEmailSettingsSecure }} + info_outlined +
+
- +
+
+ +
+ + +
+ {{ $strings.LabelEmailSettingsRejectUnauthorized }} + info_outlined +
+
+
+
@@ -119,6 +136,7 @@ export default { host: null, port: 465, secure: true, + rejectUnauthorized: true, user: null, pass: null, testAddress: null, @@ -257,6 +275,7 @@ export default { host: this.newSettings.host, port: this.newSettings.port, secure: this.newSettings.secure, + rejectUnauthorized: this.newSettings.rejectUnauthorized, user: this.newSettings.user, pass: this.newSettings.pass, testAddress: this.newSettings.testAddress, diff --git a/client/strings/bg.json b/client/strings/bg.json index 0858856e4..a54e3cd59 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -279,6 +279,8 @@ "LabelEdit": "Редакция", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "От Адрес", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Сигурна", "LabelEmailSettingsSecureHelp": "Ако е вярно възката ще изполва TLS когате се свързва със сървъра. Ако не е то TLS ще се използва ако сървъра поддържа разширението STARTTLS. В повечето случаи задайте тази стойност на истина ако се свързвате към порт 465. За порт 587 или 25 оставете я на лъжа. (от nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Тестов Адрес", diff --git a/client/strings/bn.json b/client/strings/bn.json index 89fe78fef..a07db3d4c 100644 --- a/client/strings/bn.json +++ b/client/strings/bn.json @@ -279,6 +279,8 @@ "LabelEdit": "সম্পাদনা করুন", "LabelEmail": "ইমেইল", "LabelEmailSettingsFromAddress": "ঠিকানা থেকে", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "নিরাপদ", "LabelEmailSettingsSecureHelp": "যদি সত্য হয় সার্ভারের সাথে সংযোগ করার সময় সংযোগটি TLS ব্যবহার করবে। মিথ্যা হলে TLS ব্যবহার করা হবে যদি সার্ভার STARTTLS এক্সটেনশন সমর্থন করে। বেশিরভাগ ক্ষেত্রে এই মানটিকে সত্য হিসাবে সেট করুন যদি আপনি পোর্ট 465-এর সাথে সংযোগ করছেন। পোর্ট 587 বা পোর্টের জন্য 25 এটি মিথ্যা রাখুন। (nodemailer.com/smtp/#authentication থেকে)", "LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা", diff --git a/client/strings/cs.json b/client/strings/cs.json index b326996d1..3460675bb 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -279,6 +279,8 @@ "LabelEdit": "Upravit", "LabelEmail": "E-mail", "LabelEmailSettingsFromAddress": "Z adresy", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Zabezpečené", "LabelEmailSettingsSecureHelp": "Pokud je true, připojení bude při připojování k serveru používat TLS. Pokud je false, použije se protokol TLS, pokud server podporuje rozšíření STARTTLS. Ve většině případů nastavte tuto hodnotu na true, pokud se připojujete k portu 465. Pro port 587 nebo 25 ponechte hodnotu false. (z nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Testovací adresa", diff --git a/client/strings/da.json b/client/strings/da.json index 96a0d04b2..aafd06472 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -279,6 +279,8 @@ "LabelEdit": "Rediger", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "Fra Adresse", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "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", diff --git a/client/strings/de.json b/client/strings/de.json index 50bb4c3b3..435b11ac8 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -279,6 +279,8 @@ "LabelEdit": "Bearbeiten", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "Von Adresse", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Sicher", "LabelEmailSettingsSecureHelp": "Wenn \"an\", verwendet die Verbindung TLS, wenn du eine Verbindung zum Server herstellst. Bei \"aus\" wird TLS verwendet, wenn der Server die STARTTLS-Erweiterung unterstützt. In den meisten Fällen solltest du diesen Wert auf \"an\" schalten, wenn du eine Verbindung zu Port 465 herstellst. Für Port 587 oder 25 behalte den Wert \"aus\" bei. (von nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test Adresse", @@ -807,4 +809,4 @@ "ToastSortingPrefixesUpdateSuccess": "Die Sortier-Prefixe wirden geupdated ({0} Einträge)", "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 2a390b5fa..f2bd04e3d 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -279,6 +279,8 @@ "LabelEdit": "Edit", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "From Address", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "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", diff --git a/client/strings/es.json b/client/strings/es.json index cead84e25..78ff1eba2 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -279,6 +279,8 @@ "LabelEdit": "Editar", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "Remitente", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "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", diff --git a/client/strings/et.json b/client/strings/et.json index fcf7c4ce2..a3ee16591 100644 --- a/client/strings/et.json +++ b/client/strings/et.json @@ -279,6 +279,8 @@ "LabelEdit": "Muuda", "LabelEmail": "E-post", "LabelEmailSettingsFromAddress": "Saatja aadress", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Turvaline", "LabelEmailSettingsSecureHelp": "Kui see on tõene, kasutab ühendus serveriga ühenduse loomisel TLS-i. Kui see on väär, kasutatakse TLS-i, kui server toetab STARTTLS-i laiendust. Enamikul juhtudest seadke see väärtus tõeks, kui ühendate pordile 465. Pordi 587 või 25 korral hoidke seda väär. (nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Testi aadress", diff --git a/client/strings/fr.json b/client/strings/fr.json index 0635e8230..015b110e4 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -279,6 +279,8 @@ "LabelEdit": "Modifier", "LabelEmail": "Courriel", "LabelEmailSettingsFromAddress": "Expéditeur", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Sécurisé", "LabelEmailSettingsSecureHelp": "Utiliser TLS lors de la connexion au serveur, autrement TLS sera utilisé si le serveur prend en charge l’extension STARTTLS. Dans la plupart des cas, actviez l’option si vous vous connectez au port 465. Désactivez l’option pour utiliser port 587 ou 25. (source: nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Adresse de test", @@ -807,4 +809,4 @@ "ToastSortingPrefixesUpdateSuccess": "Mise à jour des préfixes de tri ({0} élément)", "ToastUserDeleteFailed": "Échec de la suppression de l’utilisateur", "ToastUserDeleteSuccess": "Utilisateur supprimé" -} +} \ No newline at end of file diff --git a/client/strings/gu.json b/client/strings/gu.json index 11c475043..88ff67993 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -279,6 +279,8 @@ "LabelEdit": "Edit", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "From Address", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "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", diff --git a/client/strings/he.json b/client/strings/he.json index 7d17a2a88..ee6bf07b7 100644 --- a/client/strings/he.json +++ b/client/strings/he.json @@ -279,6 +279,8 @@ "LabelEdit": "עריכה", "LabelEmail": "דואר אלקטרוני", "LabelEmailSettingsFromAddress": "מאת", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "מאובטח", "LabelEmailSettingsSecureHelp": "אם מופעל, החיבור ישתמש ב-TLS בעת ההתחברות לשרת. אם לא, אז TLS יהיה בשימוש אם השרת תומך בהרחבת STARTTLS. ברוב המקרים מומלץ להפעיל את הגדרה זו אם אתה מתחבר לפורט 465. לפורט 587 או 25, השאר כבוי. (from nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "כתובת לבדיקה", diff --git a/client/strings/hi.json b/client/strings/hi.json index 83b7f011a..1f0657bc1 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -279,6 +279,8 @@ "LabelEdit": "Edit", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "From Address", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "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", diff --git a/client/strings/hr.json b/client/strings/hr.json index a3a88e9ce..42c6a78c5 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -279,6 +279,8 @@ "LabelEdit": "Uredi", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "From Address", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "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", diff --git a/client/strings/hu.json b/client/strings/hu.json index 971c45fc8..6a117a0a9 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -279,6 +279,8 @@ "LabelEdit": "Szerkesztés", "LabelEmail": "E-mail", "LabelEmailSettingsFromAddress": "Feladó címe", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Biztonságos", "LabelEmailSettingsSecureHelp": "Ha igaz, a kapcsolat TLS-t használ a szerverhez való csatlakozáskor. Ha hamis, akkor TLS-t használ, ha a szerver támogatja a STARTTLS kiterjesztést. A legtöbb esetben állítsa ezt az értéket igazra, ha a 465-ös portra csatlakozik. A 587-es vagy 25-ös port esetében tartsa hamis értéken. (a nodemailer.com/smtp/#authentication oldalról)", "LabelEmailSettingsTestAddress": "Teszt cím", diff --git a/client/strings/it.json b/client/strings/it.json index 549ea94d4..11af343d6 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -279,6 +279,8 @@ "LabelEdit": "Modifica", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "Da Indirizzo", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Secure", "LabelEmailSettingsSecureHelp": "Se vero, la connessione utilizzerà TLS durante la connessione al server. Se false, viene utilizzato TLS se il server supporta l'estensione STARTTLS. Nella maggior parte dei casi impostare questo valore su true se ci si connette alla porta 465. Per la porta 587 o 25 mantenerlo false. (da nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test Indirizzo", diff --git a/client/strings/lt.json b/client/strings/lt.json index 5bd42bdf2..e9f36ebed 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -279,6 +279,8 @@ "LabelEdit": "Redaguoti", "LabelEmail": "El. paštas", "LabelEmailSettingsFromAddress": "Siuntėjo adresas", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Apsaugota", "LabelEmailSettingsSecureHelp": "Jei ši reikšmė yra \"true\", ryšys naudos TLS protokolą. Jei \"false\", TLS bus naudojamas tik tada, jei serveris palaiko STARTTLS plėtinį. Daugumos atveju, jei jungiamasi prie 465 prievado, šią reikšmę turėtumėte nustatyti kaip \"true\". Jei jungiamasi prie 587 arba 25 prievado, turi būti nustatyta \"false\". (iš nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Testinis adresas", diff --git a/client/strings/nl.json b/client/strings/nl.json index 28c3ada1c..a6662248d 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -279,6 +279,8 @@ "LabelEdit": "Wijzig", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "Van-adres", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Veilig", "LabelEmailSettingsSecureHelp": "Als 'waar', dan gebruikt de verbinding TLS om met de server te verbinden. Als 'onwaar', dan wordt TLS gebruikt als de server de STARTTLS-extensie ondersteunt. In de meeste gevallen kies je voor 'waar' verbindt met poort 465. Voo poort 587 of 25, laat op 'onwaar'. (van nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test-adres", diff --git a/client/strings/no.json b/client/strings/no.json index 4c4be82fa..d600446fc 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -279,6 +279,8 @@ "LabelEdit": "Rediger", "LabelEmail": "Epost", "LabelEmailSettingsFromAddress": "Fra Adresse", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Sikker", "LabelEmailSettingsSecureHelp": "Hvis aktivert, vil tilkoblingen bruke TLS under tilkobling til tjeneren. Ellers vil TLS bli brukt hvis tjeneren støtter STARTTLS utvidelsen. I de fleste tilfeller aktiver valget hvis du kobler til med port 465. Med port 587 eller 25 deaktiver valget. (fra nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test Adresse", diff --git a/client/strings/pl.json b/client/strings/pl.json index ce0109d9e..5234a1e27 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -279,6 +279,8 @@ "LabelEdit": "Edytuj", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "From Address", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "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", diff --git a/client/strings/pt-br.json b/client/strings/pt-br.json index f88f6a5e7..4942385f0 100644 --- a/client/strings/pt-br.json +++ b/client/strings/pt-br.json @@ -279,6 +279,8 @@ "LabelEdit": "Editar", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "Remetente", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Seguro", "LabelEmailSettingsSecureHelp": "Se ativado, a conexão utilizará TLS para a conexão ao servidor. Se desativado TLS será usado se o servidor suportar a extensão STARTTLS. Na maioria dos casos ative esse valor se estiver conectando pela porta 465. Para portas 587 ou 25, mantenha inativo. (de nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Endereço de teste", diff --git a/client/strings/ru.json b/client/strings/ru.json index 7b6e9c7c1..83f634073 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -279,6 +279,8 @@ "LabelEdit": "Редактировать", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "Адрес От", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Безопасность", "LabelEmailSettingsSecureHelp": "Если значение истинно, то соединение будет использовать TLS при подключении к серверу. Если значение ложно, то TLS будет использован, если сервер поддерживает расширение STARTTLS. В большинстве случаев установите это значение в истину, если вы подключаетесь к порту 465. Для порта 587 или 25 оставьте значение ложным. (из nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Тестовый адрес", diff --git a/client/strings/sv.json b/client/strings/sv.json index fd8c6e692..7347939ed 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -279,6 +279,8 @@ "LabelEdit": "Redigera", "LabelEmail": "E-post", "LabelEmailSettingsFromAddress": "Från adress", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Säker", "LabelEmailSettingsSecureHelp": "Om sant kommer anslutningen att använda TLS vid anslutning till servern. Om falskt används TLS om servern stöder STARTTLS-tillägget. I de flesta fall, om du ansluter till port 465, bör du ställa in detta värde till sant. För port 587 eller 25, låt det vara falskt. (från nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Testadress", diff --git a/client/strings/uk.json b/client/strings/uk.json index 6d8c311ca..2e96e85a4 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -279,6 +279,8 @@ "LabelEdit": "Редагувати", "LabelEmail": "Електронна пошта", "LabelEmailSettingsFromAddress": "Адреса відправника", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Безпечне", "LabelEmailSettingsSecureHelp": "Увімкніть, аби використовувати TLS при підключенні до сервера. Якщо вимкнути, то TLS буде використано, якщо сервер підтримує STARTTLS. Увімкніть, якщо ви підключаєтеся до порту 465. Вимкніть для портів 587 або 25. (з nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Тестова адреса", diff --git a/client/strings/vi-vn.json b/client/strings/vi-vn.json index 7c6d09b05..eaa9f2cef 100644 --- a/client/strings/vi-vn.json +++ b/client/strings/vi-vn.json @@ -279,6 +279,8 @@ "LabelEdit": "Chỉnh Sửa", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "Địa chỉ Gửi từ", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "Bảo Mật", "LabelEmailSettingsSecureHelp": "Nếu đúng thì kết nối sẽ sử dụng TLS khi kết nối đến máy chủ. Nếu sai thì TLS sẽ được sử dụng nếu máy chủ hỗ trợ phần mở rộng STARTTLS. Trong hầu hết các trường hợp, hãy đặt giá trị này là đúng nếu bạn kết nối đến cổng 465. Đối với cổng 587 hoặc 25, giữ nó sai. (từ nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Địa Chỉ Kiểm Tra", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index fb375aec1..2e0bf62b2 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -279,6 +279,8 @@ "LabelEdit": "编辑", "LabelEmail": "邮箱", "LabelEmailSettingsFromAddress": "发件人地址", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "安全", "LabelEmailSettingsSecureHelp": "如果选是, 则连接将在连接到服务器时使用TLS. 如果选否, 则若服务器支持STARTTLS扩展, 则使用TLS. 在大多数情况下, 如果连接到端口465, 请将该值设置为是. 对于端口587或25, 请保持为否. (来自nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "测试地址", diff --git a/client/strings/zh-tw.json b/client/strings/zh-tw.json index 54e7849b4..d3c020718 100644 --- a/client/strings/zh-tw.json +++ b/client/strings/zh-tw.json @@ -279,6 +279,8 @@ "LabelEdit": "編輯", "LabelEmail": "郵箱", "LabelEmailSettingsFromAddress": "發件人位址", + "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", + "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", "LabelEmailSettingsSecure": "安全", "LabelEmailSettingsSecureHelp": "如果選是, 則連接將在連接到伺服器時使用TLS. 如果選否, 則若伺服器支援STARTTLS擴展, 則使用TLS. 在大多數情況下, 如果連接到465埠, 請將該值設定為是. 對於587或25埠, 請保持為否. (來自nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "測試位址", diff --git a/server/objects/settings/EmailSettings.js b/server/objects/settings/EmailSettings.js index 13e37ddcc..330e1b9ca 100644 --- a/server/objects/settings/EmailSettings.js +++ b/server/objects/settings/EmailSettings.js @@ -16,6 +16,7 @@ class EmailSettings { this.host = null this.port = 465 this.secure = true + this.rejectUnauthorized = true this.user = null this.pass = null this.testAddress = null @@ -33,11 +34,17 @@ class EmailSettings { this.host = settings.host this.port = settings.port this.secure = !!settings.secure + this.rejectUnauthorized = !!settings.rejectUnauthorized this.user = settings.user this.pass = settings.pass this.testAddress = settings.testAddress this.fromAddress = settings.fromAddress - this.ereaderDevices = settings.ereaderDevices?.map(d => ({ ...d })) || [] + this.ereaderDevices = settings.ereaderDevices?.map((d) => ({ ...d })) || [] + + // rejectUnauthorized added after v2.10.1 - defaults to true + if (settings.rejectUnauthorized === undefined) { + this.rejectUnauthorized = true + } } toJSON() { @@ -46,11 +53,12 @@ class EmailSettings { host: this.host, port: this.port, secure: this.secure, + rejectUnauthorized: this.rejectUnauthorized, user: this.user, pass: this.pass, testAddress: this.testAddress, fromAddress: this.fromAddress, - ereaderDevices: this.ereaderDevices.map(d => ({ ...d })) + ereaderDevices: this.ereaderDevices.map((d) => ({ ...d })) } } @@ -62,27 +70,30 @@ class EmailSettings { else payload.port = Number(payload.port) } if (payload.secure !== undefined) payload.secure = !!payload.secure + if (payload.rejectUnauthorized !== undefined) payload.rejectUnauthorized = !!payload.rejectUnauthorized 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) + 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 @@ -116,14 +127,20 @@ class EmailSettings { pass: this.pass } } + // Allow self-signed certs (https://nodemailer.com/smtp/#3-allow-self-signed-certificates) + if (!this.rejectUnauthorized) { + payload.tls = { + rejectUnauthorized: false + } + } return payload } /** - * - * @param {EreaderDeviceObject} device - * @param {import('../user/User')} user + * + * @param {EreaderDeviceObject} device + * @param {import('../user/User')} user * @returns {boolean} */ checkUserCanAccessDevice(device, user) { @@ -140,8 +157,8 @@ class EmailSettings { /** * Get ereader devices accessible to user - * - * @param {import('../user/User')} user + * + * @param {import('../user/User')} user * @returns {EreaderDeviceObject[]} */ getEReaderDevices(user) { @@ -150,12 +167,12 @@ class EmailSettings { /** * Get ereader device by name - * - * @param {string} deviceName + * + * @param {string} deviceName * @returns {EreaderDeviceObject} */ getEReaderDevice(deviceName) { - return this.ereaderDevices.find(d => d.name === deviceName) + return this.ereaderDevices.find((d) => d.name === deviceName) } } -module.exports = EmailSettings \ No newline at end of file +module.exports = EmailSettings From fb86b4fc843bc8b37e58b70dbd5d59e46ba293bd Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 30 May 2024 16:23:27 -0500 Subject: [PATCH 07/16] Fix chapter marker string, map translations --- client/components/player/PlayerUi.vue | 2 +- client/cypress/support/tailwind.compiled.css | 4672 ++++++++++++++++++ client/strings/cs.json | 1 + client/strings/da.json | 1 + client/strings/de.json | 3 +- client/strings/en-us.json | 2 +- client/strings/es.json | 1 + client/strings/fr.json | 1 + client/strings/gu.json | 1 + client/strings/hi.json | 1 + client/strings/hr.json | 1 + client/strings/hu.json | 1 + client/strings/it.json | 3 +- client/strings/lt.json | 1 + client/strings/nl.json | 1 + client/strings/no.json | 1 + client/strings/pl.json | 1 + client/strings/pt-br.json | 3 +- client/strings/ru.json | 1 + client/strings/sv.json | 1 + client/strings/zh-cn.json | 1 + 21 files changed, 4695 insertions(+), 5 deletions(-) create mode 100644 client/cypress/support/tailwind.compiled.css diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue index 4253f6791..503bba083 100644 --- a/client/components/player/PlayerUi.vue +++ b/client/components/player/PlayerUi.vue @@ -53,7 +53,7 @@

- {{ currentChapterName }}  {{ $setString('LabelPlayerChaperMarker', [currentChapterIndex + 1, chapters.length]) }} + {{ currentChapterName }}  ({{ $getString('LabelPlayerChapterNumberMarker', [currentChapterIndex + 1, chapters.length]) }})

{{ timeRemainingPretty }}

diff --git a/client/cypress/support/tailwind.compiled.css b/client/cypress/support/tailwind.compiled.css new file mode 100644 index 000000000..4463326cd --- /dev/null +++ b/client/cypress/support/tailwind.compiled.css @@ -0,0 +1,4672 @@ +/* +! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: Source Sans Pro; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: Ubuntu Mono; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden] { + display: none; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +.container { + width: 100%; +} + +@media (min-width: 640px) { + .container { + max-width: 640px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 768px; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 1024px; + } +} + +@media (min-width: 1280px) { + .container { + max-width: 1280px; + } +} + +@media (min-width: 1536px) { + .container { + max-width: 1536px; + } +} + +.pointer-events-none { + pointer-events: none; +} + +.pointer-events-auto { + pointer-events: auto; +} + +.collapse { + visibility: collapse; +} + +.static { + position: static; +} + +.fixed { + position: fixed; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.sticky { + position: sticky; +} + +.inset-0 { + inset: 0px; +} + +.inset-y-0 { + top: 0px; + bottom: 0px; +} + +.-bottom-1 { + bottom: -0.25rem; +} + +.-bottom-1\.5 { + bottom: -0.375rem; +} + +.-bottom-2 { + bottom: -0.5rem; +} + +.-bottom-5 { + bottom: -1.25rem; +} + +.-bottom-6 { + bottom: -1.5rem; +} + +.-bottom-8 { + bottom: -2rem; +} + +.-left-24 { + left: -6rem; +} + +.-left-3 { + left: -0.75rem; +} + +.-left-\[4\.5rem\] { + left: -4.5rem; +} + +.-right-0 { + right: -0px; +} + +.-right-0\.5 { + right: -0.125rem; +} + +.-right-1 { + right: -0.25rem; +} + +.-right-1\.5 { + right: -0.375rem; +} + +.-right-24 { + right: -6rem; +} + +.-right-3 { + right: -0.75rem; +} + +.-right-4 { + right: -1rem; +} + +.-top-1 { + top: -0.25rem; +} + +.-top-1\.5 { + top: -0.375rem; +} + +.-top-10 { + top: -2.5rem; +} + +.-top-20 { + top: -5rem; +} + +.-top-3 { + top: -0.75rem; +} + +.-top-8 { + top: -2rem; +} + +.bottom-0 { + bottom: 0px; +} + +.bottom-2 { + bottom: 0.5rem; +} + +.bottom-2\.5 { + bottom: 0.625rem; +} + +.bottom-4 { + bottom: 1rem; +} + +.bottom-px { + bottom: 1px; +} + +.left-0 { + left: 0px; +} + +.left-1 { + left: 0.25rem; +} + +.left-1\/2 { + left: 50%; +} + +.left-16 { + left: 4rem; +} + +.left-2 { + left: 0.5rem; +} + +.left-20 { + left: 5rem; +} + +.left-28 { + left: 7rem; +} + +.left-4 { + left: 1rem; +} + +.left-8 { + left: 2rem; +} + +.right-0 { + right: 0px; +} + +.right-1 { + right: 0.25rem; +} + +.right-1\.5 { + right: 0.375rem; +} + +.right-14 { + right: 3.5rem; +} + +.right-2 { + right: 0.5rem; +} + +.right-2\.5 { + right: 0.625rem; +} + +.right-20 { + right: 5rem; +} + +.right-3 { + right: 0.75rem; +} + +.right-36 { + right: 9rem; +} + +.right-4 { + right: 1rem; +} + +.right-40 { + right: 10rem; +} + +.top-0 { + top: 0px; +} + +.top-1 { + top: 0.25rem; +} + +.top-1\.5 { + top: 0.375rem; +} + +.top-10 { + top: 2.5rem; +} + +.top-16 { + top: 4rem; +} + +.top-2 { + top: 0.5rem; +} + +.top-3 { + top: 0.75rem; +} + +.top-4 { + top: 1rem; +} + +.top-7 { + top: 1.75rem; +} + +.top-9 { + top: 2.25rem; +} + +.z-0 { + z-index: 0; +} + +.z-10 { + z-index: 10; +} + +.z-20 { + z-index: 20; +} + +.z-30 { + z-index: 30; +} + +.z-40 { + z-index: 40; +} + +.z-50 { + z-index: 50; +} + +.z-60 { + z-index: 60; +} + +.m-0 { + margin: 0px; +} + +.m-0\.5 { + margin: 0.125rem; +} + +.m-2 { + margin: 0.5rem; +} + +.m-auto { + margin: auto; +} + +.-mx-1 { + margin-left: -0.25rem; + margin-right: -0.25rem; +} + +.-mx-2 { + margin-left: -0.5rem; + margin-right: -0.5rem; +} + +.mx-0 { + margin-left: 0px; + margin-right: 0px; +} + +.mx-0\.5 { + margin-left: 0.125rem; + margin-right: 0.125rem; +} + +.mx-1 { + margin-left: 0.25rem; + margin-right: 0.25rem; +} + +.mx-1\.5 { + margin-left: 0.375rem; + margin-right: 0.375rem; +} + +.mx-2 { + margin-left: 0.5rem; + margin-right: 0.5rem; +} + +.mx-3 { + margin-left: 0.75rem; + margin-right: 0.75rem; +} + +.mx-4 { + margin-left: 1rem; + margin-right: 1rem; +} + +.mx-6 { + margin-left: 1.5rem; + margin-right: 1.5rem; +} + +.mx-8 { + margin-left: 2rem; + margin-right: 2rem; +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.mx-px { + margin-left: 1px; + margin-right: 1px; +} + +.my-0 { + margin-top: 0px; + margin-bottom: 0px; +} + +.my-0\.5 { + margin-top: 0.125rem; + margin-bottom: 0.125rem; +} + +.my-1 { + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +.my-12 { + margin-top: 3rem; + margin-bottom: 3rem; +} + +.my-2 { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.my-2\.5 { + margin-top: 0.625rem; + margin-bottom: 0.625rem; +} + +.my-4 { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.my-5 { + margin-top: 1.25rem; + margin-bottom: 1.25rem; +} + +.my-6 { + margin-top: 1.5rem; + margin-bottom: 1.5rem; +} + +.my-8 { + margin-top: 2rem; + margin-bottom: 2rem; +} + +.my-auto { + margin-top: auto; + margin-bottom: auto; +} + +.\!mb-4 { + margin-bottom: 1rem !important; +} + +.-mb-0 { + margin-bottom: -0px; +} + +.-mb-0\.5 { + margin-bottom: -0.125rem; +} + +.-mb-px { + margin-bottom: -1px; +} + +.-ml-2 { + margin-left: -0.5rem; +} + +.-ml-px { + margin-left: -1px; +} + +.-mt-6 { + margin-top: -1.5rem; +} + +.-mt-px { + margin-top: -1px; +} + +.mb-0 { + margin-bottom: 0px; +} + +.mb-0\.5 { + margin-bottom: 0.125rem; +} + +.mb-1 { + margin-bottom: 0.25rem; +} + +.mb-10 { + margin-bottom: 2.5rem; +} + +.mb-12 { + margin-bottom: 3rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-24 { + margin-bottom: 6rem; +} + +.mb-3 { + margin-bottom: 0.75rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mb-5 { + margin-bottom: 1.25rem; +} + +.mb-6 { + margin-bottom: 1.5rem; +} + +.mb-8 { + margin-bottom: 2rem; +} + +.mb-px { + margin-bottom: 1px; +} + +.ml-1 { + margin-left: 0.25rem; +} + +.ml-1\.5 { + margin-left: 0.375rem; +} + +.ml-10 { + margin-left: 2.5rem; +} + +.ml-14 { + margin-left: 3.5rem; +} + +.ml-2 { + margin-left: 0.5rem; +} + +.ml-3 { + margin-left: 0.75rem; +} + +.ml-4 { + margin-left: 1rem; +} + +.ml-6 { + margin-left: 1.5rem; +} + +.ml-auto { + margin-left: auto; +} + +.ml-px { + margin-left: 1px; +} + +.mr-0 { + margin-right: 0px; +} + +.mr-0\.5 { + margin-right: 0.125rem; +} + +.mr-1 { + margin-right: 0.25rem; +} + +.mr-1\.5 { + margin-right: 0.375rem; +} + +.mr-2 { + margin-right: 0.5rem; +} + +.mr-3 { + margin-right: 0.75rem; +} + +.mr-4 { + margin-right: 1rem; +} + +.mr-6 { + margin-right: 1.5rem; +} + +.mr-px { + margin-right: 1px; +} + +.mt-0 { + margin-top: 0px; +} + +.mt-0\.5 { + margin-top: 0.125rem; +} + +.mt-1 { + margin-top: 0.25rem; +} + +.mt-1\.5 { + margin-top: 0.375rem; +} + +.mt-10 { + margin-top: 2.5rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-3 { + margin-top: 0.75rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mt-5 { + margin-top: 1.25rem; +} + +.mt-6 { + margin-top: 1.5rem; +} + +.mt-8 { + margin-top: 2rem; +} + +.mt-\[1\.375rem\] { + margin-top: 1.375rem; +} + +.line-clamp-2 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.line-clamp-4 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; +} + +.\!block { + display: block !important; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +.flex { + display: flex; +} + +.inline-flex { + display: inline-flex; +} + +.table { + display: table; +} + +.contents { + display: contents; +} + +.\!hidden { + display: none !important; +} + +.hidden { + display: none; +} + +.h-0 { + height: 0px; +} + +.h-0\.5 { + height: 0.125rem; +} + +.h-1 { + height: 0.25rem; +} + +.h-1\.5 { + height: 0.375rem; +} + +.h-1\/3 { + height: 33.333333%; +} + +.h-10 { + height: 2.5rem; +} + +.h-11 { + height: 2.75rem; +} + +.h-12 { + height: 3rem; +} + +.h-14 { + height: 3.5rem; +} + +.h-16 { + height: 4rem; +} + +.h-18 { + height: 4.5rem; +} + +.h-2 { + height: 0.5rem; +} + +.h-2\.5 { + height: 0.625rem; +} + +.h-20 { + height: 5rem; +} + +.h-24 { + height: 6rem; +} + +.h-3 { + height: 0.75rem; +} + +.h-3\.5 { + height: 0.875rem; +} + +.h-32 { + height: 8rem; +} + +.h-36 { + height: 9rem; +} + +.h-4 { + height: 1rem; +} + +.h-40 { + height: 10rem; +} + +.h-44 { + height: 11rem; +} + +.h-45 { + height: 11.25rem; +} + +.h-48 { + height: 12rem; +} + +.h-5 { + height: 1.25rem; +} + +.h-6 { + height: 1.5rem; +} + +.h-60 { + height: 15rem; +} + +.h-7 { + height: 1.75rem; +} + +.h-7\.5 { + height: 1.75rem; +} + +.h-72 { + height: 18rem; +} + +.h-8 { + height: 2rem; +} + +.h-80 { + height: 20rem; +} + +.h-9 { + height: 2.25rem; +} + +.h-\[2\.375rem\] { + height: 2.375rem; +} + +.h-\[calc\(100\%-110px\)\] { + height: calc(100% - 110px); +} + +.h-\[calc\(100\%-270px\)\] { + height: calc(100% - 270px); +} + +.h-\[calc\(100\%-40px\)\] { + height: calc(100% - 40px); +} + +.h-full { + height: 100%; +} + +.h-px { + height: 1px; +} + +.h-screen { + height: 100vh; +} + +.max-h-12 { + max-height: 3rem; +} + +.max-h-56 { + max-height: 14rem; +} + +.max-h-72 { + max-height: 18rem; +} + +.max-h-80 { + max-height: 20rem; +} + +.max-h-96 { + max-height: 24rem; +} + +.max-h-full { + max-height: 100%; +} + +.max-h-screen { + max-height: 100vh; +} + +.min-h-40 { + min-height: 10rem; +} + +.min-h-\[176px\] { + min-height: 176px; +} + +.w-0 { + width: 0px; +} + +.w-0\.5 { + width: 0.125rem; +} + +.w-1 { + width: 0.25rem; +} + +.w-1\/2 { + width: 50%; +} + +.w-1\/3 { + width: 33.333333%; +} + +.w-1\/4 { + width: 25%; +} + +.w-1\/5 { + width: 20%; +} + +.w-10 { + width: 2.5rem; +} + +.w-11 { + width: 2.75rem; +} + +.w-12 { + width: 3rem; +} + +.w-14 { + width: 3.5rem; +} + +.w-16 { + width: 4rem; +} + +.w-18 { + width: 4.5rem; +} + +.w-2 { + width: 0.5rem; +} + +.w-2\.5 { + width: 0.625rem; +} + +.w-2\/3 { + width: 66.666667%; +} + +.w-2\/5 { + width: 40%; +} + +.w-20 { + width: 5rem; +} + +.w-24 { + width: 6rem; +} + +.w-28 { + width: 7rem; +} + +.w-3 { + width: 0.75rem; +} + +.w-3\.5 { + width: 0.875rem; +} + +.w-3\/4 { + width: 75%; +} + +.w-3\/5 { + width: 60%; +} + +.w-32 { + width: 8rem; +} + +.w-36 { + width: 9rem; +} + +.w-4 { + width: 1rem; +} + +.w-40 { + width: 10rem; +} + +.w-44 { + width: 11rem; +} + +.w-48 { + width: 12rem; +} + +.w-5 { + width: 1.25rem; +} + +.w-52 { + width: 13rem; +} + +.w-56 { + width: 14rem; +} + +.w-6 { + width: 1.5rem; +} + +.w-60 { + width: 15rem; +} + +.w-64 { + width: 16rem; +} + +.w-7 { + width: 1.75rem; +} + +.w-72 { + width: 18rem; +} + +.w-8 { + width: 2rem; +} + +.w-80 { + width: 20rem; +} + +.w-9 { + width: 2.25rem; +} + +.w-96 { + width: 24rem; +} + +.w-full { + width: 100%; +} + +.w-px { + width: 1px; +} + +.w-screen { + width: 100vw; +} + +.min-w-0 { + min-width: 0px; +} + +.min-w-10 { + min-width: 2.5rem; +} + +.min-w-12 { + min-width: 3rem; +} + +.min-w-16 { + min-width: 4rem; +} + +.min-w-20 { + min-width: 5rem; +} + +.min-w-24 { + min-width: 6rem; +} + +.min-w-26 { + min-width: 6.5rem; +} + +.min-w-32 { + min-width: 8rem; +} + +.min-w-44 { + min-width: 11rem; +} + +.min-w-48 { + min-width: 12rem; +} + +.min-w-5 { + min-width: 1.25rem; +} + +.min-w-6 { + min-width: 1.5rem; +} + +.min-w-8 { + min-width: 2rem; +} + +.min-w-\[160px\] { + min-width: 160px; +} + +.min-w-\[224px\] { + min-width: 224px; +} + +.max-w-12 { + max-width: 3rem; +} + +.max-w-16 { + max-width: 4rem; +} + +.max-w-20 { + max-width: 5rem; +} + +.max-w-24 { + max-width: 6rem; +} + +.max-w-2xl { + max-width: 42rem; +} + +.max-w-32 { + max-width: 8rem; +} + +.max-w-3xl { + max-width: 48rem; +} + +.max-w-40 { + max-width: 10rem; +} + +.max-w-48 { + max-width: 12rem; +} + +.max-w-4xl { + max-width: 56rem; +} + +.max-w-52 { + max-width: 13rem; +} + +.max-w-5xl { + max-width: 64rem; +} + +.max-w-6 { + max-width: 1.5rem; +} + +.max-w-64 { + max-width: 16rem; +} + +.max-w-6xl { + max-width: 72rem; +} + +.max-w-72 { + max-width: 18rem; +} + +.max-w-7xl { + max-width: 80rem; +} + +.max-w-\[600px\] { + max-width: 600px; +} + +.max-w-\[800px\] { + max-width: 800px; +} + +.max-w-\[calc\(100\%-80px\)\] { + max-width: calc(100% - 80px); +} + +.max-w-\[calc\(100vw-10rem\)\] { + max-width: calc(100vw - 10rem); +} + +.max-w-\[calc\(100vw-2rem\)\] { + max-width: calc(100vw - 2rem); +} + +.max-w-full { + max-width: 100%; +} + +.max-w-lg { + max-width: 32rem; +} + +.max-w-max { + max-width: -moz-max-content; + max-width: max-content; +} + +.max-w-md { + max-width: 28rem; +} + +.max-w-sm { + max-width: 24rem; +} + +.max-w-xl { + max-width: 36rem; +} + +.max-w-xs { + max-width: 20rem; +} + +.flex-1 { + flex: 1 1 0%; +} + +.flex-shrink-0 { + flex-shrink: 0; +} + +.flex-grow { + flex-grow: 1; +} + +.border-collapse { + border-collapse: collapse; +} + +.origin-bottom-left { + transform-origin: bottom left; +} + +.origin-center { + transform-origin: center; +} + +.origin-top-left { + transform-origin: top left; +} + +.-translate-x-1\/2 { + --tw-translate-x: -50%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.-translate-x-12 { + --tw-translate-x: -3rem; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.-translate-x-24 { + --tw-translate-x: -6rem; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.-translate-x-44 { + --tw-translate-x: -11rem; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.-translate-x-96 { + --tw-translate-x: -24rem; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.translate-x-0 { + --tw-translate-x: 0px; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.translate-x-24 { + --tw-translate-x: 6rem; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.translate-x-40 { + --tw-translate-x: 10rem; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.translate-x-5 { + --tw-translate-x: 1.25rem; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.rotate-180 { + --tw-rotate: 180deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.scale-125 { + --tw-scale-x: 1.25; + --tw-scale-y: 1.25; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.scale-75 { + --tw-scale-x: .75; + --tw-scale-y: .75; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.transform { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +@keyframes ping { + 75%, 100% { + transform: scale(2); + opacity: 0; + } +} + +.animate-ping { + animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +@keyframes pulse { + 50% { + opacity: .5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +.cursor-default { + cursor: default; +} + +.cursor-not-allowed { + cursor: not-allowed; +} + +.cursor-pointer { + cursor: pointer; +} + +.cursor-text { + cursor: text; +} + +.select-none { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.resize { + resize: both; +} + +.list-inside { + list-style-position: inside; +} + +.list-disc { + list-style-type: disc; +} + +.flex-row { + flex-direction: row; +} + +.flex-col { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.flex-wrap-reverse { + flex-wrap: wrap-reverse; +} + +.flex-nowrap { + flex-wrap: nowrap; +} + +.place-items-end { + place-items: end; +} + +.items-start { + align-items: flex-start; +} + +.items-end { + align-items: flex-end; +} + +.items-center { + align-items: center; +} + +.justify-start { + justify-content: flex-start; +} + +.justify-end { + justify-content: flex-end; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.space-x-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(1rem * var(--tw-space-x-reverse)); + margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); +} + +.self-center { + align-self: center; +} + +.overflow-auto { + overflow: auto; +} + +.overflow-hidden { + overflow: hidden; +} + +.overflow-x-auto { + overflow-x: auto; +} + +.overflow-y-auto { + overflow-y: auto; +} + +.overflow-x-hidden { + overflow-x: hidden; +} + +.overflow-y-hidden { + overflow-y: hidden; +} + +.overflow-x-scroll { + overflow-x: scroll; +} + +.overflow-y-scroll { + overflow-y: scroll; +} + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.overflow-ellipsis { + text-overflow: ellipsis; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.whitespace-pre-line { + white-space: pre-line; +} + +.whitespace-pre-wrap { + white-space: pre-wrap; +} + +.break-words { + overflow-wrap: break-word; +} + +.break-all { + word-break: break-all; +} + +.break-keep { + word-break: keep-all; +} + +.rounded { + border-radius: 0.25rem; +} + +.rounded-full { + border-radius: 9999px; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded-md { + border-radius: 0.375rem; +} + +.rounded-sm { + border-radius: 0.125rem; +} + +.rounded-xl { + border-radius: 0.75rem; +} + +.rounded-b { + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.rounded-b-lg { + border-bottom-right-radius: 0.5rem; + border-bottom-left-radius: 0.5rem; +} + +.rounded-b-md { + border-bottom-right-radius: 0.375rem; + border-bottom-left-radius: 0.375rem; +} + +.rounded-l-md { + border-top-left-radius: 0.375rem; + border-bottom-left-radius: 0.375rem; +} + +.rounded-r-full { + border-top-right-radius: 9999px; + border-bottom-right-radius: 9999px; +} + +.rounded-r-md { + border-top-right-radius: 0.375rem; + border-bottom-right-radius: 0.375rem; +} + +.rounded-t-lg { + border-top-left-radius: 0.5rem; + border-top-right-radius: 0.5rem; +} + +.rounded-tl-md { + border-top-left-radius: 0.375rem; +} + +.rounded-tr-lg { + border-top-right-radius: 0.5rem; +} + +.rounded-tr-md { + border-top-right-radius: 0.375rem; +} + +.border { + border-width: 1px; +} + +.border-2 { + border-width: 2px; +} + +.border-b { + border-bottom-width: 1px; +} + +.border-l { + border-left-width: 1px; +} + +.border-r { + border-right-width: 1px; +} + +.border-t { + border-top-width: 1px; +} + +.border-dashed { + border-style: dashed; +} + +.border-bg { + --tw-border-opacity: 1; + border-color: rgb(55 56 56 / var(--tw-border-opacity)); +} + +.border-black { + --tw-border-opacity: 1; + border-color: rgb(0 0 0 / var(--tw-border-opacity)); +} + +.border-black-100 { + --tw-border-opacity: 1; + border-color: rgb(102 102 102 / var(--tw-border-opacity)); +} + +.border-black-200 { + --tw-border-opacity: 1; + border-color: rgb(85 85 85 / var(--tw-border-opacity)); +} + +.border-black-300 { + --tw-border-opacity: 1; + border-color: rgb(68 68 68 / var(--tw-border-opacity)); +} + +.border-black-50 { + --tw-border-opacity: 1; + border-color: rgb(187 187 187 / var(--tw-border-opacity)); +} + +.border-black\/20 { + border-color: rgb(0 0 0 / 0.2); +} + +.border-error { + --tw-border-opacity: 1; + border-color: rgb(255 82 82 / var(--tw-border-opacity)); +} + +.border-gray-300 { + --tw-border-opacity: 1; + border-color: rgb(209 213 219 / var(--tw-border-opacity)); +} + +.border-gray-400 { + --tw-border-opacity: 1; + border-color: rgb(156 163 175 / var(--tw-border-opacity)); +} + +.border-gray-500 { + --tw-border-opacity: 1; + border-color: rgb(107 114 128 / var(--tw-border-opacity)); +} + +.border-gray-600 { + --tw-border-opacity: 1; + border-color: rgb(75 85 99 / var(--tw-border-opacity)); +} + +.border-gray-700 { + --tw-border-opacity: 1; + border-color: rgb(55 65 81 / var(--tw-border-opacity)); +} + +.border-primary { + --tw-border-opacity: 1; + border-color: rgb(35 35 35 / var(--tw-border-opacity)); +} + +.border-red-300 { + --tw-border-opacity: 1; + border-color: rgb(252 165 165 / var(--tw-border-opacity)); +} + +.border-transparent { + border-color: transparent; +} + +.border-warning { + --tw-border-opacity: 1; + border-color: rgb(251 140 0 / var(--tw-border-opacity)); +} + +.border-white { + --tw-border-opacity: 1; + border-color: rgb(255 255 255 / var(--tw-border-opacity)); +} + +.border-white\/10 { + border-color: rgb(255 255 255 / 0.1); +} + +.border-yellow-200 { + --tw-border-opacity: 1; + border-color: rgb(254 240 138 / var(--tw-border-opacity)); +} + +.border-yellow-300 { + --tw-border-opacity: 1; + border-color: rgb(253 224 71 / var(--tw-border-opacity)); +} + +.border-yellow-400 { + --tw-border-opacity: 1; + border-color: rgb(250 204 21 / var(--tw-border-opacity)); +} + +.border-b-bg { + --tw-border-opacity: 1; + border-bottom-color: rgb(55 56 56 / var(--tw-border-opacity)); +} + +.border-opacity-0 { + --tw-border-opacity: 0; +} + +.border-opacity-10 { + --tw-border-opacity: 0.1; +} + +.border-opacity-20 { + --tw-border-opacity: 0.2; +} + +.border-opacity-25 { + --tw-border-opacity: 0.25; +} + +.border-opacity-30 { + --tw-border-opacity: 0.3; +} + +.border-opacity-5 { + --tw-border-opacity: 0.05; +} + +.border-opacity-60 { + --tw-border-opacity: 0.6; +} + +.border-opacity-70 { + --tw-border-opacity: 0.7; +} + +.\!bg-error\/10 { + background-color: rgb(255 82 82 / 0.1) !important; +} + +.bg-accent { + --tw-bg-opacity: 1; + background-color: rgb(26 214 145 / var(--tw-bg-opacity)); +} + +.bg-bg { + --tw-bg-opacity: 1; + background-color: rgb(55 56 56 / var(--tw-bg-opacity)); +} + +.bg-black { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); +} + +.bg-black-100 { + --tw-bg-opacity: 1; + background-color: rgb(102 102 102 / var(--tw-bg-opacity)); +} + +.bg-black-200 { + --tw-bg-opacity: 1; + background-color: rgb(85 85 85 / var(--tw-bg-opacity)); +} + +.bg-black-300 { + --tw-bg-opacity: 1; + background-color: rgb(68 68 68 / var(--tw-bg-opacity)); +} + +.bg-black-400 { + --tw-bg-opacity: 1; + background-color: rgb(51 51 51 / var(--tw-bg-opacity)); +} + +.bg-black\/10 { + background-color: rgb(0 0 0 / 0.1); +} + +.bg-black\/20 { + background-color: rgb(0 0 0 / 0.2); +} + +.bg-black\/25 { + background-color: rgb(0 0 0 / 0.25); +} + +.bg-black\/40 { + background-color: rgb(0 0 0 / 0.4); +} + +.bg-black\/50 { + background-color: rgb(0 0 0 / 0.5); +} + +.bg-error { + --tw-bg-opacity: 1; + background-color: rgb(255 82 82 / var(--tw-bg-opacity)); +} + +.bg-gray-100 { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); +} + +.bg-gray-200 { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity)); +} + +.bg-gray-300 { + --tw-bg-opacity: 1; + background-color: rgb(209 213 219 / var(--tw-bg-opacity)); +} + +.bg-gray-400 { + --tw-bg-opacity: 1; + background-color: rgb(156 163 175 / var(--tw-bg-opacity)); +} + +.bg-gray-50 { + --tw-bg-opacity: 1; + background-color: rgb(249 250 251 / var(--tw-bg-opacity)); +} + +.bg-gray-500 { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); +} + +.bg-gray-600 { + --tw-bg-opacity: 1; + background-color: rgb(75 85 99 / var(--tw-bg-opacity)); +} + +.bg-gray-700 { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); +} + +.bg-green-500 { + --tw-bg-opacity: 1; + background-color: rgb(34 197 94 / var(--tw-bg-opacity)); +} + +.bg-info { + --tw-bg-opacity: 1; + background-color: rgb(33 150 243 / var(--tw-bg-opacity)); +} + +.bg-neutral-600 { + --tw-bg-opacity: 1; + background-color: rgb(82 82 82 / var(--tw-bg-opacity)); +} + +.bg-primary { + --tw-bg-opacity: 1; + background-color: rgb(35 35 35 / var(--tw-bg-opacity)); +} + +.bg-primary\/20 { + background-color: rgb(35 35 35 / 0.2); +} + +.bg-primary\/25 { + background-color: rgb(35 35 35 / 0.25); +} + +.bg-primary\/40 { + background-color: rgb(35 35 35 / 0.4); +} + +.bg-red-100 { + --tw-bg-opacity: 1; + background-color: rgb(254 226 226 / var(--tw-bg-opacity)); +} + +.bg-red-600 { + --tw-bg-opacity: 1; + background-color: rgb(220 38 38 / var(--tw-bg-opacity)); +} + +.bg-slate-200\/10 { + background-color: rgb(226 232 240 / 0.1); +} + +.bg-success { + --tw-bg-opacity: 1; + background-color: rgb(76 175 80 / var(--tw-bg-opacity)); +} + +.bg-success\/50 { + background-color: rgb(76 175 80 / 0.5); +} + +.bg-transparent { + background-color: transparent; +} + +.bg-warning { + --tw-bg-opacity: 1; + background-color: rgb(251 140 0 / var(--tw-bg-opacity)); +} + +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.bg-white\/10 { + background-color: rgb(255 255 255 / 0.1); +} + +.bg-white\/5 { + background-color: rgb(255 255 255 / 0.05); +} + +.bg-yellow-400 { + --tw-bg-opacity: 1; + background-color: rgb(250 204 21 / var(--tw-bg-opacity)); +} + +.bg-opacity-0 { + --tw-bg-opacity: 0; +} + +.bg-opacity-10 { + --tw-bg-opacity: 0.1; +} + +.bg-opacity-100 { + --tw-bg-opacity: 1; +} + +.bg-opacity-20 { + --tw-bg-opacity: 0.2; +} + +.bg-opacity-25 { + --tw-bg-opacity: 0.25; +} + +.bg-opacity-30 { + --tw-bg-opacity: 0.3; +} + +.bg-opacity-40 { + --tw-bg-opacity: 0.4; +} + +.bg-opacity-5 { + --tw-bg-opacity: 0.05; +} + +.bg-opacity-50 { + --tw-bg-opacity: 0.5; +} + +.bg-opacity-60 { + --tw-bg-opacity: 0.6; +} + +.bg-opacity-70 { + --tw-bg-opacity: 0.7; +} + +.bg-opacity-75 { + --tw-bg-opacity: 0.75; +} + +.bg-opacity-80 { + --tw-bg-opacity: 0.8; +} + +.bg-opacity-90 { + --tw-bg-opacity: 0.9; +} + +.bg-opacity-95 { + --tw-bg-opacity: 0.95; +} + +.bg-gradient-to-b { + background-image: linear-gradient(to bottom, var(--tw-gradient-stops)); +} + +.bg-gradient-to-t { + background-image: linear-gradient(to top, var(--tw-gradient-stops)); +} + +.from-black-600 { + --tw-gradient-from: #111111 var(--tw-gradient-from-position); + --tw-gradient-to: rgb(17 17 17 / 0) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.from-transparent { + --tw-gradient-from: transparent var(--tw-gradient-from-position); + --tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.via-black-500 { + --tw-gradient-to: rgb(34 34 34 / 0) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-from), #222222 var(--tw-gradient-via-position), var(--tw-gradient-to); +} + +.to-black-700 { + --tw-gradient-to: #101010 var(--tw-gradient-to-position); +} + +.to-transparent { + --tw-gradient-to: transparent var(--tw-gradient-to-position); +} + +.fill-current { + fill: currentColor; +} + +.object-contain { + -o-object-fit: contain; + object-fit: contain; +} + +.object-cover { + -o-object-fit: cover; + object-fit: cover; +} + +.object-fill { + -o-object-fit: fill; + object-fit: fill; +} + +.object-scale-down { + -o-object-fit: scale-down; + object-fit: scale-down; +} + +.p-0 { + padding: 0px; +} + +.p-0\.5 { + padding: 0.125rem; +} + +.p-1 { + padding: 0.25rem; +} + +.p-2 { + padding: 0.5rem; +} + +.p-20 { + padding: 5rem; +} + +.p-3 { + padding: 0.75rem; +} + +.p-4 { + padding: 1rem; +} + +.p-5 { + padding: 1.25rem; +} + +.p-6 { + padding: 1.5rem; +} + +.p-8 { + padding: 2rem; +} + +.px-0 { + padding-left: 0px; + padding-right: 0px; +} + +.px-0\.5 { + padding-left: 0.125rem; + padding-right: 0.125rem; +} + +.px-1 { + padding-left: 0.25rem; + padding-right: 0.25rem; +} + +.px-1\.5 { + padding-left: 0.375rem; + padding-right: 0.375rem; +} + +.px-12 { + padding-left: 3rem; + padding-right: 3rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.px-5 { + padding-left: 1.25rem; + padding-right: 1.25rem; +} + +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.px-8 { + padding-left: 2rem; + padding-right: 2rem; +} + +.px-px { + padding-left: 1px; + padding-right: 1px; +} + +.py-0 { + padding-top: 0px; + padding-bottom: 0px; +} + +.py-0\.5 { + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-1\.5 { + padding-top: 0.375rem; + padding-bottom: 0.375rem; +} + +.py-12 { + padding-top: 3rem; + padding-bottom: 3rem; +} + +.py-16 { + padding-top: 4rem; + padding-bottom: 4rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.py-5 { + padding-top: 1.25rem; + padding-bottom: 1.25rem; +} + +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + +.py-8 { + padding-top: 2rem; + padding-bottom: 2rem; +} + +.py-px { + padding-top: 1px; + padding-bottom: 1px; +} + +.pb-0 { + padding-bottom: 0px; +} + +.pb-0\.5 { + padding-bottom: 0.125rem; +} + +.pb-1 { + padding-bottom: 0.25rem; +} + +.pb-2 { + padding-bottom: 0.5rem; +} + +.pb-20 { + padding-bottom: 5rem; +} + +.pb-4 { + padding-bottom: 1rem; +} + +.pb-52 { + padding-bottom: 13rem; +} + +.pb-6 { + padding-bottom: 1.5rem; +} + +.pb-8 { + padding-bottom: 2rem; +} + +.pb-px { + padding-bottom: 1px; +} + +.pl-1 { + padding-left: 0.25rem; +} + +.pl-12 { + padding-left: 3rem; +} + +.pl-16 { + padding-left: 4rem; +} + +.pl-18 { + padding-left: 4.5rem; +} + +.pl-2 { + padding-left: 0.5rem; +} + +.pl-3 { + padding-left: 0.75rem; +} + +.pl-4 { + padding-left: 1rem; +} + +.pl-6 { + padding-left: 1.5rem; +} + +.pl-8 { + padding-left: 2rem; +} + +.pl-9 { + padding-left: 2.25rem; +} + +.pl-96 { + padding-left: 24rem; +} + +.pl-px { + padding-left: 1px; +} + +.pr-1 { + padding-right: 0.25rem; +} + +.pr-12 { + padding-right: 3rem; +} + +.pr-2 { + padding-right: 0.5rem; +} + +.pr-3 { + padding-right: 0.75rem; +} + +.pr-4 { + padding-right: 1rem; +} + +.pr-8 { + padding-right: 2rem; +} + +.pr-9 { + padding-right: 2.25rem; +} + +.pr-px { + padding-right: 1px; +} + +.pt-0 { + padding-top: 0px; +} + +.pt-0\.5 { + padding-top: 0.125rem; +} + +.pt-1 { + padding-top: 0.25rem; +} + +.pt-1\.5 { + padding-top: 0.375rem; +} + +.pt-12 { + padding-top: 3rem; +} + +.pt-2 { + padding-top: 0.5rem; +} + +.pt-20 { + padding-top: 5rem; +} + +.pt-4 { + padding-top: 1rem; +} + +.pt-6 { + padding-top: 1.5rem; +} + +.pt-7 { + padding-top: 1.75rem; +} + +.pt-8 { + padding-top: 2rem; +} + +.pt-px { + padding-top: 1px; +} + +.text-left { + text-align: left; +} + +.text-center { + text-align: center; +} + +.text-right { + text-align: right; +} + +.align-middle { + vertical-align: middle; +} + +.align-text-bottom { + vertical-align: text-bottom; +} + +.font-mono { + font-family: Ubuntu Mono; +} + +.font-sans { + font-family: Source Sans Pro; +} + +.text-1\.5xl { + font-size: 1.375rem; +} + +.text-2\.5xl { + font-size: 1.6875rem; +} + +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-4\.5xl { + font-size: 2.625rem; +} + +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} + +.text-5xl { + font-size: 3rem; + line-height: 1; +} + +.text-6xl { + font-size: 3.75rem; + line-height: 1; +} + +.text-\[10rem\] { + font-size: 10rem; +} + +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.text-xxs { + font-size: 0.625rem; +} + +.font-bold { + font-weight: 700; +} + +.font-light { + font-weight: 300; +} + +.font-normal { + font-weight: 400; +} + +.font-semibold { + font-weight: 600; +} + +.uppercase { + text-transform: uppercase; +} + +.capitalize { + text-transform: capitalize; +} + +.italic { + font-style: italic; +} + +.leading-3 { + line-height: .75rem; +} + +.leading-4 { + line-height: 1rem; +} + +.leading-5 { + line-height: 1.25rem; +} + +.leading-6 { + line-height: 1.5rem; +} + +.leading-7 { + line-height: 1.75rem; +} + +.leading-none { + line-height: 1; +} + +.-tracking-widest { + letter-spacing: -0.1em; +} + +.tracking-wide { + letter-spacing: 0.025em; +} + +.text-black { + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +.text-black-50 { + --tw-text-opacity: 1; + color: rgb(187 187 187 / var(--tw-text-opacity)); +} + +.text-blue-200 { + --tw-text-opacity: 1; + color: rgb(191 219 254 / var(--tw-text-opacity)); +} + +.text-blue-400 { + --tw-text-opacity: 1; + color: rgb(96 165 250 / var(--tw-text-opacity)); +} + +.text-error { + --tw-text-opacity: 1; + color: rgb(255 82 82 / var(--tw-text-opacity)); +} + +.text-gray-100 { + --tw-text-opacity: 1; + color: rgb(243 244 246 / var(--tw-text-opacity)); +} + +.text-gray-200 { + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity)); +} + +.text-gray-300 { + --tw-text-opacity: 1; + color: rgb(209 213 219 / var(--tw-text-opacity)); +} + +.text-gray-400 { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); +} + +.text-gray-50 { + --tw-text-opacity: 1; + color: rgb(249 250 251 / var(--tw-text-opacity)); +} + +.text-gray-500 { + --tw-text-opacity: 1; + color: rgb(107 114 128 / var(--tw-text-opacity)); +} + +.text-green-500 { + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); +} + +.text-primary { + --tw-text-opacity: 1; + color: rgb(35 35 35 / var(--tw-text-opacity)); +} + +.text-red-100 { + --tw-text-opacity: 1; + color: rgb(254 226 226 / var(--tw-text-opacity)); +} + +.text-red-300 { + --tw-text-opacity: 1; + color: rgb(252 165 165 / var(--tw-text-opacity)); +} + +.text-red-500 { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); +} + +.text-slate-300 { + --tw-text-opacity: 1; + color: rgb(203 213 225 / var(--tw-text-opacity)); +} + +.text-success { + --tw-text-opacity: 1; + color: rgb(76 175 80 / var(--tw-text-opacity)); +} + +.text-warning { + --tw-text-opacity: 1; + color: rgb(251 140 0 / var(--tw-text-opacity)); +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.text-white\/30 { + color: rgb(255 255 255 / 0.3); +} + +.text-white\/50 { + color: rgb(255 255 255 / 0.5); +} + +.text-white\/70 { + color: rgb(255 255 255 / 0.7); +} + +.text-white\/80 { + color: rgb(255 255 255 / 0.8); +} + +.text-yellow-200 { + --tw-text-opacity: 1; + color: rgb(254 240 138 / var(--tw-text-opacity)); +} + +.text-yellow-300 { + --tw-text-opacity: 1; + color: rgb(253 224 71 / var(--tw-text-opacity)); +} + +.text-yellow-400 { + --tw-text-opacity: 1; + color: rgb(250 204 21 / var(--tw-text-opacity)); +} + +.text-opacity-0 { + --tw-text-opacity: 0; +} + +.text-opacity-100 { + --tw-text-opacity: 1; +} + +.text-opacity-20 { + --tw-text-opacity: 0.2; +} + +.text-opacity-30 { + --tw-text-opacity: 0.3; +} + +.text-opacity-40 { + --tw-text-opacity: 0.4; +} + +.text-opacity-50 { + --tw-text-opacity: 0.5; +} + +.text-opacity-60 { + --tw-text-opacity: 0.6; +} + +.text-opacity-70 { + --tw-text-opacity: 0.7; +} + +.text-opacity-75 { + --tw-text-opacity: 0.75; +} + +.text-opacity-80 { + --tw-text-opacity: 0.8; +} + +.text-opacity-90 { + --tw-text-opacity: 0.9; +} + +.underline { + text-decoration-line: underline; +} + +.opacity-0 { + opacity: 0; +} + +.opacity-100 { + opacity: 1; +} + +.opacity-25 { + opacity: 0.25; +} + +.opacity-40 { + opacity: 0.4; +} + +.opacity-50 { + opacity: 0.5; +} + +.opacity-80 { + opacity: 0.8; +} + +.opacity-90 { + opacity: 0.9; +} + +.shadow { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-inner { + --tw-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-lg { + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-md { + --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-sm { + --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-xl { + --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.outline-none { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.outline { + outline-style: solid; +} + +.ring-1 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.ring-black { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(0 0 0 / var(--tw-ring-opacity)); +} + +.ring-opacity-5 { + --tw-ring-opacity: 0.05; +} + +.blur { + --tw-blur: blur(8px); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.invert { + --tw-invert: invert(100%); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.filter { + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.transition { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-opacity { + transition-property: opacity; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-transform { + transition-property: transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.duration-100 { + transition-duration: 100ms; +} + +.duration-150 { + transition-duration: 150ms; +} + +.duration-200 { + transition-duration: 200ms; +} + +.duration-300 { + transition-duration: 300ms; +} + +.duration-500 { + transition-duration: 500ms; +} + +.ease-in { + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); +} + +.ease-out { + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); +} + +.hover\:rotate-6:hover { + --tw-rotate: 6deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.hover\:scale-110:hover { + --tw-scale-x: 1.1; + --tw-scale-y: 1.1; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.hover\:scale-125:hover { + --tw-scale-x: 1.25; + --tw-scale-y: 1.25; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.hover\:scale-150:hover { + --tw-scale-x: 1.5; + --tw-scale-y: 1.5; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.hover\:scale-y-125:hover { + --tw-scale-y: 1.25; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.hover\:border-gray-400:hover { + --tw-border-opacity: 1; + border-color: rgb(156 163 175 / var(--tw-border-opacity)); +} + +.hover\:border-yellow-300:hover { + --tw-border-opacity: 1; + border-color: rgb(253 224 71 / var(--tw-border-opacity)); +} + +.hover\:border-opacity-20:hover { + --tw-border-opacity: 0.2; +} + +.hover\:bg-bg:hover { + --tw-bg-opacity: 1; + background-color: rgb(55 56 56 / var(--tw-bg-opacity)); +} + +.hover\:bg-black:hover { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); +} + +.hover\:bg-black-200:hover { + --tw-bg-opacity: 1; + background-color: rgb(85 85 85 / var(--tw-bg-opacity)); +} + +.hover\:bg-black-400:hover { + --tw-bg-opacity: 1; + background-color: rgb(51 51 51 / var(--tw-bg-opacity)); +} + +.hover\:bg-error:hover { + --tw-bg-opacity: 1; + background-color: rgb(255 82 82 / var(--tw-bg-opacity)); +} + +.hover\:bg-gray-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(209 213 219 / var(--tw-bg-opacity)); +} + +.hover\:bg-primary:hover { + --tw-bg-opacity: 1; + background-color: rgb(35 35 35 / var(--tw-bg-opacity)); +} + +.hover\:bg-primary\/60:hover { + background-color: rgb(35 35 35 / 0.6); +} + +.hover\:bg-white:hover { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.hover\:bg-white\/10:hover { + background-color: rgb(255 255 255 / 0.1); +} + +.hover\:bg-white\/5:hover { + background-color: rgb(255 255 255 / 0.05); +} + +.hover\:bg-yellow-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(253 224 71 / var(--tw-bg-opacity)); +} + +.hover\:bg-opacity-10:hover { + --tw-bg-opacity: 0.1; +} + +.hover\:bg-opacity-25:hover { + --tw-bg-opacity: 0.25; +} + +.hover\:bg-opacity-30:hover { + --tw-bg-opacity: 0.3; +} + +.hover\:bg-opacity-40:hover { + --tw-bg-opacity: 0.4; +} + +.hover\:bg-opacity-5:hover { + --tw-bg-opacity: 0.05; +} + +.hover\:bg-opacity-60:hover { + --tw-bg-opacity: 0.6; +} + +.hover\:text-blue-300:hover { + --tw-text-opacity: 1; + color: rgb(147 197 253 / var(--tw-text-opacity)); +} + +.hover\:text-error:hover { + --tw-text-opacity: 1; + color: rgb(255 82 82 / var(--tw-text-opacity)); +} + +.hover\:text-gray-100:hover { + --tw-text-opacity: 1; + color: rgb(243 244 246 / var(--tw-text-opacity)); +} + +.hover\:text-gray-200:hover { + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity)); +} + +.hover\:text-gray-300:hover { + --tw-text-opacity: 1; + color: rgb(209 213 219 / var(--tw-text-opacity)); +} + +.hover\:text-gray-50:hover { + --tw-text-opacity: 1; + color: rgb(249 250 251 / var(--tw-text-opacity)); +} + +.hover\:text-red-400:hover { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); +} + +.hover\:text-success:hover { + --tw-text-opacity: 1; + color: rgb(76 175 80 / var(--tw-text-opacity)); +} + +.hover\:text-warning:hover { + --tw-text-opacity: 1; + color: rgb(251 140 0 / var(--tw-text-opacity)); +} + +.hover\:text-white:hover { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.hover\:text-white\/100:hover { + color: rgb(255 255 255 / 1); +} + +.hover\:text-white\/80:hover { + color: rgb(255 255 255 / 0.8); +} + +.hover\:text-yellow-300:hover { + --tw-text-opacity: 1; + color: rgb(253 224 71 / var(--tw-text-opacity)); +} + +.hover\:text-yellow-400:hover { + --tw-text-opacity: 1; + color: rgb(250 204 21 / var(--tw-text-opacity)); +} + +.hover\:text-yellow-500:hover { + --tw-text-opacity: 1; + color: rgb(234 179 8 / var(--tw-text-opacity)); +} + +.hover\:text-opacity-100:hover { + --tw-text-opacity: 1; +} + +.hover\:text-opacity-90:hover { + --tw-text-opacity: 0.9; +} + +.hover\:text-opacity-95:hover { + --tw-text-opacity: 0.95; +} + +.hover\:underline:hover { + text-decoration-line: underline; +} + +.hover\:opacity-100:hover { + opacity: 1; +} + +.focus\:border-gray-300:focus { + --tw-border-opacity: 1; + border-color: rgb(209 213 219 / var(--tw-border-opacity)); +} + +.focus\:border-gray-500:focus { + --tw-border-opacity: 1; + border-color: rgb(107 114 128 / var(--tw-border-opacity)); +} + +.focus\:border-opacity-100:focus { + --tw-border-opacity: 1; +} + +.focus\:bg-bg:focus { + --tw-bg-opacity: 1; + background-color: rgb(55 56 56 / var(--tw-bg-opacity)); +} + +.focus\:outline-none:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.group:hover .group-hover\:opacity-100 { + opacity: 1; +} + +.group:hover .group-hover\:opacity-30 { + opacity: 0.3; +} + +.group:hover .group-hover\:brightness-75 { + --tw-brightness: brightness(.75); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.data-\[type\=comic\]\:hidden[data-type=comic] { + display: none; +} + +.data-\[theme\=dark\]\:bg-primary[data-theme=dark] { + --tw-bg-opacity: 1; + background-color: rgb(35 35 35 / var(--tw-bg-opacity)); +} + +.data-\[theme\=light\]\:bg-white[data-theme=light] { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.data-\[theme\=dark\]\:text-white[data-theme=dark] { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.data-\[theme\=light\]\:text-black[data-theme=light] { + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +.group[data-theme=dark] .group-data-\[theme\=dark\]\:bg-primary { + --tw-bg-opacity: 1; + background-color: rgb(35 35 35 / var(--tw-bg-opacity)); +} + +.group[data-theme=light] .group-data-\[theme\=light\]\:bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.group[data-theme=dark] .group-data-\[theme\=dark\]\:text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.group[data-theme=light] .group-data-\[theme\=light\]\:text-black { + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +@media (min-width: 640px) { + .sm\:left-20 { + left: 5rem; + } + + .sm\:left-32 { + left: 8rem; + } + + .sm\:left-8 { + left: 2rem; + } + + .sm\:right-16 { + right: 4rem; + } + + .sm\:right-40 { + right: 10rem; + } + + .sm\:mx-2 { + margin-left: 0.5rem; + margin-right: 0.5rem; + } + + .sm\:mb-0 { + margin-bottom: 0px; + } + + .sm\:ml-2 { + margin-left: 0.5rem; + } + + .sm\:ml-3 { + margin-left: 0.75rem; + } + + .sm\:ml-4 { + margin-left: 1rem; + } + + .sm\:ml-8 { + margin-left: 2rem; + } + + .sm\:mr-0 { + margin-right: 0px; + } + + .sm\:mr-1 { + margin-right: 0.25rem; + } + + .sm\:mr-1\.5 { + margin-right: 0.375rem; + } + + .sm\:mr-2 { + margin-right: 0.5rem; + } + + .sm\:mr-4 { + margin-right: 1rem; + } + + .sm\:mt-5 { + margin-top: 1.25rem; + } + + .sm\:block { + display: block; + } + + .sm\:inline-block { + display: inline-block; + } + + .sm\:flex { + display: flex; + } + + .sm\:inline-flex { + display: inline-flex; + } + + .sm\:table-cell { + display: table-cell; + } + + .sm\:\!hidden { + display: none !important; + } + + .sm\:hidden { + display: none; + } + + .sm\:h-10 { + height: 2.5rem; + } + + .sm\:h-\[200px\] { + height: 200px; + } + + .sm\:max-h-80 { + max-height: 20rem; + } + + .sm\:w-1\/2 { + width: 50%; + } + + .sm\:w-10 { + width: 2.5rem; + } + + .sm\:w-28 { + width: 7rem; + } + + .sm\:w-32 { + width: 8rem; + } + + .sm\:w-40 { + width: 10rem; + } + + .sm\:w-44 { + width: 11rem; + } + + .sm\:w-48 { + width: 12rem; + } + + .sm\:w-80 { + width: 20rem; + } + + .sm\:w-full { + width: 100%; + } + + .sm\:min-w-10 { + min-width: 2.5rem; + } + + .sm\:min-w-32 { + min-width: 8rem; + } + + .sm\:min-w-64 { + min-width: 16rem; + } + + .sm\:max-w-48 { + max-width: 12rem; + } + + .sm\:max-w-80 { + max-width: 20rem; + } + + .sm\:flex-grow-0 { + flex-grow: 0; + } + + .sm\:flex-row { + flex-direction: row; + } + + .sm\:flex-nowrap { + flex-wrap: nowrap; + } + + .sm\:items-center { + align-items: center; + } + + .sm\:justify-start { + justify-content: flex-start; + } + + .sm\:overflow-y-scroll { + overflow-y: scroll; + } + + .sm\:p-4 { + padding: 1rem; + } + + .sm\:p-6 { + padding: 1.5rem; + } + + .sm\:px-0 { + padding-left: 0px; + padding-right: 0px; + } + + .sm\:px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; + } + + .sm\:px-4 { + padding-left: 1rem; + padding-right: 1rem; + } + + .sm\:px-8 { + padding-left: 2rem; + padding-right: 2rem; + } + + .sm\:py-0 { + padding-top: 0px; + padding-bottom: 0px; + } + + .sm\:pl-1 { + padding-left: 0.25rem; + } + + .sm\:pl-1\.5 { + padding-left: 0.375rem; + } + + .sm\:pl-16 { + padding-left: 4rem; + } + + .sm\:pl-2 { + padding-left: 0.5rem; + } + + .sm\:pl-24 { + padding-left: 6rem; + } + + .sm\:pl-4 { + padding-left: 1rem; + } + + .sm\:pr-1 { + padding-right: 0.25rem; + } + + .sm\:pr-2 { + padding-right: 0.5rem; + } + + .sm\:pt-0 { + padding-top: 0px; + } + + .sm\:text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } + + .sm\:text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } + + .sm\:text-base { + font-size: 1rem; + line-height: 1.5rem; + } + + .sm\:text-lg { + font-size: 1.125rem; + line-height: 1.75rem; + } + + .sm\:text-sm { + font-size: 0.875rem; + line-height: 1.25rem; + } + + .sm\:text-xl { + font-size: 1.25rem; + line-height: 1.75rem; + } + + .sm\:text-xs { + font-size: 0.75rem; + line-height: 1rem; + } +} + +@media (min-width: 768px) { + .md\:absolute { + position: absolute; + } + + .md\:-right-0 { + right: -0px; + } + + .md\:left-8 { + left: 2rem; + } + + .md\:right-0 { + right: 0px; + } + + .md\:right-5 { + right: 1.25rem; + } + + .md\:top-0 { + top: 0px; + } + + .md\:top-5 { + top: 1.25rem; + } + + .md\:mx-0 { + margin-left: 0px; + margin-right: 0px; + } + + .md\:mb-0 { + margin-bottom: 0px; + } + + .md\:ml-3 { + margin-left: 0.75rem; + } + + .md\:ml-4 { + margin-left: 1rem; + } + + .md\:ml-5 { + margin-left: 1.25rem; + } + + .md\:mr-4 { + margin-right: 1rem; + } + + .md\:mt-0 { + margin-top: 0px; + } + + .md\:\!block { + display: block !important; + } + + .md\:block { + display: block; + } + + .md\:inline-block { + display: inline-block; + } + + .md\:inline { + display: inline; + } + + .md\:flex { + display: flex; + } + + .md\:table-cell { + display: table-cell; + } + + .md\:\!hidden { + display: none !important; + } + + .md\:hidden { + display: none; + } + + .md\:h-10 { + height: 2.5rem; + } + + .md\:h-12 { + height: 3rem; + } + + .md\:h-20 { + height: 5rem; + } + + .md\:h-24 { + height: 6rem; + } + + .md\:h-7 { + height: 1.75rem; + } + + .md\:h-\[5\.5rem\] { + height: 5.5rem; + } + + .md\:h-\[800px\] { + height: 800px; + } + + .md\:h-full { + height: 100%; + } + + .md\:w-1\/2 { + width: 50%; + } + + .md\:w-1\/3 { + width: 33.333333%; + } + + .md\:w-1\/4 { + width: 25%; + } + + .md\:w-12 { + width: 3rem; + } + + .md\:w-16 { + width: 4rem; + } + + .md\:w-18 { + width: 4.5rem; + } + + .md\:w-2\/3 { + width: 66.666667%; + } + + .md\:w-20 { + width: 5rem; + } + + .md\:w-24 { + width: 6rem; + } + + .md\:w-28 { + width: 7rem; + } + + .md\:w-3\/4 { + width: 75%; + } + + .md\:w-32 { + width: 8rem; + } + + .md\:w-40 { + width: 10rem; + } + + .md\:w-48 { + width: 12rem; + } + + .md\:w-52 { + width: 13rem; + } + + .md\:w-56 { + width: 14rem; + } + + .md\:w-7 { + width: 1.75rem; + } + + .md\:w-72 { + width: 18rem; + } + + .md\:w-auto { + width: auto; + } + + .md\:w-fit { + width: -moz-fit-content; + width: fit-content; + } + + .md\:min-w-12 { + min-width: 3rem; + } + + .md\:min-w-20 { + min-width: 5rem; + } + + .md\:min-w-24 { + min-width: 6rem; + } + + .md\:min-w-32 { + min-width: 8rem; + } + + .md\:min-w-80 { + min-width: 20rem; + } + + .md\:max-w-16 { + max-width: 4rem; + } + + .md\:max-w-20 { + max-width: 5rem; + } + + .md\:max-w-md { + max-width: 28rem; + } + + .md\:flex-grow { + flex-grow: 1; + } + + .md\:flex-row { + flex-direction: row; + } + + .md\:flex-nowrap { + flex-wrap: nowrap; + } + + .md\:items-center { + align-items: center; + } + + .md\:justify-start { + justify-content: flex-start; + } + + .md\:bg-opacity-70 { + --tw-bg-opacity: 0.7; + } + + .md\:p-12 { + padding: 3rem; + } + + .md\:p-4 { + padding: 1rem; + } + + .md\:p-6 { + padding: 1.5rem; + } + + .md\:p-8 { + padding: 2rem; + } + + .md\:px-0 { + padding-left: 0px; + padding-right: 0px; + } + + .md\:px-10 { + padding-left: 2.5rem; + padding-right: 2.5rem; + } + + .md\:px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; + } + + .md\:px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; + } + + .md\:px-4 { + padding-left: 1rem; + padding-right: 1rem; + } + + .md\:px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + + .md\:px-8 { + padding-left: 2rem; + padding-right: 2rem; + } + + .md\:py-0 { + padding-top: 0px; + padding-bottom: 0px; + } + + .md\:py-4 { + padding-top: 1rem; + padding-bottom: 1rem; + } + + .md\:py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; + } + + .md\:pl-3 { + padding-left: 0.75rem; + } + + .md\:pl-4 { + padding-left: 1rem; + } + + .md\:pl-6 { + padding-left: 1.5rem; + } + + .md\:pr-10 { + padding-right: 2.5rem; + } + + .md\:pr-2 { + padding-right: 0.5rem; + } + + .md\:pr-4 { + padding-right: 1rem; + } + + .md\:pt-6 { + padding-top: 1.5rem; + } + + .md\:text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } + + .md\:text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } + + .md\:text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } + + .md\:text-5xl { + font-size: 3rem; + line-height: 1; + } + + .md\:text-base { + font-size: 1rem; + line-height: 1.5rem; + } + + .md\:text-lg { + font-size: 1.125rem; + line-height: 1.75rem; + } + + .md\:text-sm { + font-size: 0.875rem; + line-height: 1.25rem; + } + + .md\:text-xl { + font-size: 1.25rem; + line-height: 1.75rem; + } +} + +@media (min-width: 1024px) { + .lg\:left-4 { + left: 1rem; + } + + .lg\:right-2 { + right: 0.5rem; + } + + .lg\:right-5 { + right: 1.25rem; + } + + .lg\:top-0 { + top: 0px; + } + + .lg\:top-5 { + top: 1.25rem; + } + + .lg\:mx-2 { + margin-left: 0.5rem; + margin-right: 0.5rem; + } + + .lg\:mx-8 { + margin-left: 2rem; + margin-right: 2rem; + } + + .lg\:-mt-40 { + margin-top: -10rem; + } + + .lg\:mb-0 { + margin-bottom: 0px; + } + + .lg\:ml-8 { + margin-left: 2rem; + } + + .lg\:mr-8 { + margin-right: 2rem; + } + + .lg\:block { + display: block; + } + + .lg\:flex { + display: flex; + } + + .lg\:table-cell { + display: table-cell; + } + + .lg\:h-18 { + height: 4.5rem; + } + + .lg\:h-40 { + height: 10rem; + } + + .lg\:w-1\/3 { + width: 33.333333%; + } + + .lg\:w-18 { + width: 4.5rem; + } + + .lg\:w-52 { + width: 13rem; + } + + .lg\:scale-100 { + --tw-scale-x: 1; + --tw-scale-y: 1; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + } + + .lg\:flex-row { + flex-direction: row; + } + + .lg\:p-4 { + padding: 1rem; + } + + .lg\:p-5 { + padding: 1.25rem; + } + + .lg\:p-8 { + padding: 2rem; + } + + .lg\:px-4 { + padding-left: 1rem; + padding-right: 1rem; + } + + .lg\:py-0 { + padding-top: 0px; + padding-bottom: 0px; + } + + .lg\:pb-2 { + padding-bottom: 0.5rem; + } + + .lg\:pb-4 { + padding-bottom: 1rem; + } + + .lg\:pt-0 { + padding-top: 0px; + } + + .lg\:text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } + + .lg\:text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } + + .lg\:text-6xl { + font-size: 3.75rem; + line-height: 1; + } + + .lg\:text-xl { + font-size: 1.25rem; + line-height: 1.75rem; + } +} + +@media (min-width: 1280px) { + .xl\:table-cell { + display: table-cell; + } +} + +@media (min-width: 768px) { + @media (orientation: portrait) { + .md\:portrait\:right-5 { + right: 1.25rem; + } + + .md\:portrait\:top-5 { + top: 1.25rem; + } + + .md\:portrait\:p-5 { + padding: 1.25rem; + } + + .md\:portrait\:text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } + + .md\:portrait\:text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } + } +} + +@media (orientation: landscape) { + .landscape\:right-4 { + right: 1rem; + } + + .landscape\:top-4 { + top: 1rem; + } + + .landscape\:px-4 { + padding-left: 1rem; + padding-right: 1rem; + } + + .landscape\:py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + .landscape\:text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } +} + +@media (min-width: 768px) { + @media (orientation: landscape) { + .md\:landscape\:text-lg { + font-size: 1.125rem; + line-height: 1.75rem; + } + } +} \ No newline at end of file diff --git a/client/strings/cs.json b/client/strings/cs.json index 3ab713efb..0a3d5aeb7 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Může aktualizovat", "LabelPermissionsUpload": "Může nahrávat", "LabelPhotoPathURL": "Cesta k fotografii/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Seznamy skladeb", "LabelPlayMethod": "Metoda přehrávání", "LabelPodcast": "Podcast", diff --git a/client/strings/da.json b/client/strings/da.json index d456cd51c..2624a8935 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Kan opdatere", "LabelPermissionsUpload": "Kan uploade", "LabelPhotoPathURL": "Foto sti/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Afspilningslister", "LabelPlayMethod": "Afspilningsmetode", "LabelPodcast": "Podcast", diff --git a/client/strings/de.json b/client/strings/de.json index 5c43ef17c..ffb326618 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Aktualisieren", "LabelPermissionsUpload": "Hochladen", "LabelPhotoPathURL": "Foto Pfad/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Wiedergabelisten", "LabelPlayMethod": "Abspielmethode", "LabelPodcast": "Podcast", @@ -765,4 +766,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 c24a48866..681f09120 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -392,7 +392,7 @@ "LabelPermissionsUpdate": "Can Update", "LabelPermissionsUpload": "Can Upload", "LabelPhotoPathURL": "Photo Path/URL", - "LabelPlayerChaperMarker": "({0} of {1})", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Playlists", "LabelPlayMethod": "Play Method", "LabelPodcast": "Podcast", diff --git a/client/strings/es.json b/client/strings/es.json index 4b85106c1..066b4168b 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Puede Actualizar", "LabelPermissionsUpload": "Puede Subir", "LabelPhotoPathURL": "Ruta de Acceso/URL de Foto", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Lista de Reproducción", "LabelPlayMethod": "Método de Reproducción", "LabelPodcast": "Podcast", diff --git a/client/strings/fr.json b/client/strings/fr.json index 3f04fab8d..992a1b40e 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Peut mettre à jour", "LabelPermissionsUpload": "Peut téléverser", "LabelPhotoPathURL": "Chemin / URL des photos", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Listes de lecture", "LabelPlayMethod": "Méthode d’écoute", "LabelPodcast": "Podcast", diff --git a/client/strings/gu.json b/client/strings/gu.json index daa923dbe..4b4ef64c1 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Can Update", "LabelPermissionsUpload": "Can Upload", "LabelPhotoPathURL": "Photo Path/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Playlists", "LabelPlayMethod": "Play Method", "LabelPodcast": "Podcast", diff --git a/client/strings/hi.json b/client/strings/hi.json index a59b43ecd..54126ed31 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Can Update", "LabelPermissionsUpload": "Can Upload", "LabelPhotoPathURL": "Photo Path/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Playlists", "LabelPlayMethod": "Play Method", "LabelPodcast": "Podcast", diff --git a/client/strings/hr.json b/client/strings/hr.json index 6e105ca29..d608b556e 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Smije aktualizirati", "LabelPermissionsUpload": "Smije uploadati", "LabelPhotoPathURL": "Slika putanja/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Playlists", "LabelPlayMethod": "Vrsta reprodukcije", "LabelPodcast": "Podcast", diff --git a/client/strings/hu.json b/client/strings/hu.json index 9d79f28eb..ea329b1d0 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Frissíthet", "LabelPermissionsUpload": "Feltölthet", "LabelPhotoPathURL": "Fénykép útvonal/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Lejátszási listák", "LabelPlayMethod": "Lejátszási módszer", "LabelPodcast": "Podcast", diff --git a/client/strings/it.json b/client/strings/it.json index 11865aad4..eceef24a7 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Può Aggiornare", "LabelPermissionsUpload": "Può caricare", "LabelPhotoPathURL": "foto Path/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Playlists", "LabelPlayMethod": "Metodo di riproduzione", "LabelPodcast": "Podcast", @@ -765,4 +766,4 @@ "ToastSocketFailedToConnect": "Socket non riesce a connettersi", "ToastUserDeleteFailed": "Errore eliminazione utente", "ToastUserDeleteSuccess": "Utente eliminato" -} +} \ No newline at end of file diff --git a/client/strings/lt.json b/client/strings/lt.json index f98d5aee4..b20a7b4de 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Gali atnaujinti", "LabelPermissionsUpload": "Gali įkelti", "LabelPhotoPathURL": "Nuotraukos kelias/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Grojaraščiai", "LabelPlayMethod": "Grojimo metodas", "LabelPodcast": "Tinklalaidė", diff --git a/client/strings/nl.json b/client/strings/nl.json index 1c1ffa1bc..335fdba1c 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Kan bijwerken", "LabelPermissionsUpload": "Kan uploaden", "LabelPhotoPathURL": "Foto pad/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Afspeellijsten", "LabelPlayMethod": "Afspeelwijze", "LabelPodcast": "Podcast", diff --git a/client/strings/no.json b/client/strings/no.json index 432115dfb..534db19a7 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Kan oppdatere", "LabelPermissionsUpload": "Kan laste opp", "LabelPhotoPathURL": "Bilde sti/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Spilleliste", "LabelPlayMethod": "Avspillingsmetode", "LabelPodcast": "Podcast", diff --git a/client/strings/pl.json b/client/strings/pl.json index 7f0e44972..3bcb09057 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Ma możliwość aktualizowania", "LabelPermissionsUpload": "Ma możliwość dodawania", "LabelPhotoPathURL": "Scieżka/URL do zdjęcia", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Playlists", "LabelPlayMethod": "Metoda odtwarzania", "LabelPodcast": "Podcast", diff --git a/client/strings/pt-br.json b/client/strings/pt-br.json index fc626aff3..ec444019a 100644 --- a/client/strings/pt-br.json +++ b/client/strings/pt-br.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Pode Atualizar", "LabelPermissionsUpload": "Pode Fazer Upload", "LabelPhotoPathURL": "Caminho/URL para Foto", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Listas de Reprodução", "LabelPlayMethod": "Método de Reprodução", "LabelPodcast": "Podcast", @@ -765,4 +766,4 @@ "ToastSocketFailedToConnect": "Falha na conexão do socket", "ToastUserDeleteFailed": "Falha ao apagar usuário", "ToastUserDeleteSuccess": "Usuário apagado" -} +} \ No newline at end of file diff --git a/client/strings/ru.json b/client/strings/ru.json index 3657b596e..29f7817ad 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Может обновлять", "LabelPermissionsUpload": "Может закачивать", "LabelPhotoPathURL": "Путь к фото/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Плейлисты", "LabelPlayMethod": "Метод воспроизведения", "LabelPodcast": "Подкаст", diff --git a/client/strings/sv.json b/client/strings/sv.json index 986f2c4bd..e743b607e 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "Kan uppdatera", "LabelPermissionsUpload": "Kan ladda upp", "LabelPhotoPathURL": "Bildsökväg/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Spellistor", "LabelPlayMethod": "Spelläge", "LabelPodcast": "Podcast", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index fd7fe2903..6c057628d 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -392,6 +392,7 @@ "LabelPermissionsUpdate": "可以更新", "LabelPermissionsUpload": "可以上传", "LabelPhotoPathURL": "图片路径或 URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "播放列表", "LabelPlayMethod": "播放方法", "LabelPodcast": "播客", From 4b7b10a901c870af1b2d751052a51dadb0551501 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 30 May 2024 16:28:46 -0500 Subject: [PATCH 08/16] Add translation string for no results for query --- client/components/app/BookShelfCategorized.vue | 2 +- client/strings/bg.json | 2 ++ client/strings/bn.json | 2 ++ client/strings/cs.json | 1 + client/strings/da.json | 1 + client/strings/de.json | 1 + client/strings/en-us.json | 3 ++- client/strings/es.json | 1 + client/strings/et.json | 2 ++ client/strings/fr.json | 1 + client/strings/gu.json | 1 + client/strings/he.json | 2 ++ client/strings/hi.json | 1 + client/strings/hr.json | 1 + client/strings/hu.json | 1 + client/strings/it.json | 1 + client/strings/lt.json | 1 + client/strings/nl.json | 1 + client/strings/no.json | 1 + client/strings/pl.json | 1 + client/strings/pt-br.json | 1 + client/strings/ru.json | 1 + client/strings/sv.json | 1 + client/strings/uk.json | 2 ++ client/strings/vi-vn.json | 2 ++ client/strings/zh-cn.json | 1 + client/strings/zh-tw.json | 2 ++ 27 files changed, 35 insertions(+), 2 deletions(-) diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index 0c4562a4d..b9b2fbfd0 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -11,7 +11,7 @@
-

No results for query

+

{{ $strings.MessageBookshelfNoResultsForQuery }}

diff --git a/client/strings/bg.json b/client/strings/bg.json index a54e3cd59..b208d3e8a 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -414,6 +414,7 @@ "LabelPermissionsUpload": "Може да качва", "LabelPersonalYearReview": "Your Year in Review ({0})", "LabelPhotoPathURL": "Път/URL на Снимка", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Плейлисти", "LabelPlayMethod": "Метод на Пускане", "LabelPodcast": "Подкаст", @@ -593,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Бързото Съпоставяне ще опита да добави липсващи корици и метаданни за избраните елементи. Активирайте опциите по-долу, за да позволите на Бързото съпоставяне да презапише съществуващите корици и/или метаданни.", "MessageBookshelfNoCollections": "Все още нямате създадени колекции", "MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове", "MessageBookshelfNoSeries": "Нямаш сеЗЙ", "MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига", diff --git a/client/strings/bn.json b/client/strings/bn.json index a07db3d4c..d7e7c7fd4 100644 --- a/client/strings/bn.json +++ b/client/strings/bn.json @@ -414,6 +414,7 @@ "LabelPermissionsUpload": "আপলোড করতে পারবে", "LabelPersonalYearReview": "আপনার বছরের পর্যালোচনা ({0})", "LabelPhotoPathURL": "ছবি পথ/ইউআরএল", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "প্লেলিস্ট", "LabelPlayMethod": "প্লে পদ্ধতি", "LabelPodcast": "পডকাস্ট", @@ -593,6 +594,7 @@ "MessageBatchQuickMatchDescription": "কুইক ম্যাচ নির্বাচিত আইটেমগুলির জন্য অনুপস্থিত কভার এবং মেটাডেটা যোগ করার চেষ্টা করবে। বিদ্যমান কভার এবং/অথবা মেটাডেটা ওভাররাইট করার জন্য দ্রুত ম্যাচকে অনুমতি দিতে নীচের বিকল্পগুলি সক্ষম করুন।", "MessageBookshelfNoCollections": "আপনি এখনও কোনো সংগ্রহ করেননি", "MessageBookshelfNoResultsForFilter": "ফিল্টার \"{0}: {1}\" এর জন্য কোন ফলাফল নেই", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "কোনও RSS ফিড খোলা নেই", "MessageBookshelfNoSeries": "আপনার কোনো সিরিজ নেই", "MessageChapterEndIsAfter": "অধ্যায়ের সমাপ্তি আপনার অডিওবুকের শেষে", diff --git a/client/strings/cs.json b/client/strings/cs.json index 3611a84be..feae458c3 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Rychlá párování se pokusí přidat chybějící obálky a metadata pro vybrané položky. Povolením níže uvedených možností umožníte funkci Rychlé párování přepsat stávající obálky a/nebo metadata.", "MessageBookshelfNoCollections": "Ještě jste nevytvořili žádnou sbírku", "MessageBookshelfNoResultsForFilter": "Filtr \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Nejsou otevřeny žádné RSS kanály", "MessageBookshelfNoSeries": "Nemáte žádnou sérii", "MessageChapterEndIsAfter": "Konec kapitoly přesahuje konec audioknihy", diff --git a/client/strings/da.json b/client/strings/da.json index 220966f24..01da2952d 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -594,6 +594,7 @@ "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}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Ingen RSS-feeds er åbne", "MessageBookshelfNoSeries": "Du har ingen serier", "MessageChapterEndIsAfter": "Kapitelslutningen er efter slutningen af din lydbog", diff --git a/client/strings/de.json b/client/strings/de.json index 194155c79..25b12a1dc 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktiviere die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.", "MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt", "MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für Filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet", "MessageBookshelfNoSeries": "Keine Serien vorhanden", "MessageChapterEndIsAfter": "Ungültige Kapitelendzeit: Kapitelende > Mediumende (Kapitelende liegt nach dem Ende des Mediums)", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index ad450a7dd..1cdab7a3b 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -593,7 +593,8 @@ "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.", "MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.", "MessageBookshelfNoCollections": "You haven't made any collections yet", - "MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForFilter": "No results for filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "No RSS feeds are open", "MessageBookshelfNoSeries": "You have no series", "MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook", diff --git a/client/strings/es.json b/client/strings/es.json index 929118123..1d09f8d0a 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -594,6 +594,7 @@ "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}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Ninguna Fuente RSS esta abierta", "MessageBookshelfNoSeries": "No tienes ninguna serie", "MessageChapterEndIsAfter": "El final del capítulo es después del final de su audiolibro.", diff --git a/client/strings/et.json b/client/strings/et.json index a3ee16591..ca53a1e41 100644 --- a/client/strings/et.json +++ b/client/strings/et.json @@ -414,6 +414,7 @@ "LabelPermissionsUpload": "Saab üles laadida", "LabelPersonalYearReview": "Your Year in Review ({0})", "LabelPhotoPathURL": "Foto tee/URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Mänguloendid", "LabelPlayMethod": "Esitusmeetod", "LabelPodcast": "Podcast", @@ -593,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Kiire sobitamine üritab lisada valitud üksustele puuduvad kaaned ja metaandmed. Luba allpool olevad valikud, et lubada Kiire sobitamine'il üle kirjutada olemasolevaid kaasi ja/või metaandmeid.", "MessageBookshelfNoCollections": "Te pole veel ühtegi kogumit teinud", "MessageBookshelfNoResultsForFilter": "Filtrile \"{0}: {1}\" pole tulemusi", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Ühtegi RSS-i voogu pole avatud", "MessageBookshelfNoSeries": "Teil pole ühtegi seeriat", "MessageChapterEndIsAfter": "Peatüki lõpp on pärast teie heliraamatu lõppu", diff --git a/client/strings/fr.json b/client/strings/fr.json index 6736127c3..a96c0fc64 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer l’option suivante pour autoriser la recherche par correspondance à écraser les données existantes.", "MessageBookshelfNoCollections": "Vous n’avez pas encore de collections", "MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0} : {1} »", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Aucun flux RSS n’est ouvert", "MessageBookshelfNoSeries": "Vous n’avez aucune série", "MessageChapterEndIsAfter": "La fin du chapitre se situe après la fin de votre livre audio.", diff --git a/client/strings/gu.json b/client/strings/gu.json index a0ba97eb9..e1c9917e1 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.", "MessageBookshelfNoCollections": "You haven't made any collections yet", "MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "No RSS feeds are open", "MessageBookshelfNoSeries": "You have no series", "MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook", diff --git a/client/strings/he.json b/client/strings/he.json index ee6bf07b7..1b5d7e0f0 100644 --- a/client/strings/he.json +++ b/client/strings/he.json @@ -414,6 +414,7 @@ "LabelPermissionsUpload": "מותר להעלות", "LabelPersonalYearReview": "השנה שלך בסקירה ({0})", "LabelPhotoPathURL": "נתיב/URL לתמונה", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "רשימות השמעה", "LabelPlayMethod": "שיטת הפעלה", "LabelPodcast": "פודקאסט", @@ -593,6 +594,7 @@ "MessageBatchQuickMatchDescription": "התאמה מהירה תנסה להוסיף כריכות ומטה-נתונים חסרים עבור הפריטים הנבחרים. הפעל את האפשרויות למטה כדי לאפשר להתאמה מהירה להחליף כריכות קיימות ו/או מטה-נתונים.", "MessageBookshelfNoCollections": "עדיין לא יצרת אוספים", "MessageBookshelfNoResultsForFilter": "אין תוצאות עבור סינון \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "אין ערוצי RSS פתוחים", "MessageBookshelfNoSeries": "אין לך סדרות", "MessageChapterEndIsAfter": "זמן סיום הפרק אחרי סיום הספר הקולי שלך", diff --git a/client/strings/hi.json b/client/strings/hi.json index 77adeb854..44f8a9687 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.", "MessageBookshelfNoCollections": "You haven't made any collections yet", "MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "No RSS feeds are open", "MessageBookshelfNoSeries": "You have no series", "MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook", diff --git a/client/strings/hr.json b/client/strings/hr.json index d827612f8..2733f8745 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Quick Match će probati dodati nedostale covere i metapodatke za odabrane stavke. Uključi postavke ispod da omočutie Quick Mathchu da zamijeni postojeće covere i/ili metapodatke.", "MessageBookshelfNoCollections": "You haven't made any collections yet", "MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "No RSS feeds are open", "MessageBookshelfNoSeries": "You have no series", "MessageChapterEndIsAfter": "Kraj poglavlja je nakon kraja audioknjige.", diff --git a/client/strings/hu.json b/client/strings/hu.json index 2b280f97e..cd04af5ce 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "A Gyors egyeztetés megpróbálja hozzáadni a hiányzó borítókat és metaadatokat a kiválasztott elemekhez. Engedélyezze az alábbi opciókat, hogy a Gyors egyeztetés felülírhassa a meglévő borítókat és/vagy metaadatokat.", "MessageBookshelfNoCollections": "Még nem készített gyűjteményeket", "MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák", "MessageBookshelfNoSeries": "Nincsenek sorozatai", "MessageChapterEndIsAfter": "A fejezet vége a hangoskönyv végét követi", diff --git a/client/strings/it.json b/client/strings/it.json index cca3424fe..e0da9d923 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Quick Match tenterà di aggiungere copertine e metadati mancanti per gli elementi selezionati. Attiva l'opzione per consentire a Quick Match di sovrascrivere copertine e/o metadati esistenti.", "MessageBookshelfNoCollections": "Non hai ancora creato nessuna raccolta ", "MessageBookshelfNoResultsForFilter": "Nessun risultato per il filtro \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Nessun RSS feeds aperto", "MessageBookshelfNoSeries": "Non c'è nessuna Serie", "MessageChapterEndIsAfter": "La fine del capitolo è dopo la fine del tuo audiolibro", diff --git a/client/strings/lt.json b/client/strings/lt.json index 8ae777bb3..97f10d800 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Greitas atitikmens rasti bandys pridėti trūkstamus viršelius ir metaduomenis pasirinktiems elementams. Įjunkite žemiau esančias parinktis, kad leistumėte Greitajam atitikmeniui perrašyti esamus viršelius ir/ar metaduomenis.", "MessageBookshelfNoCollections": "Dar nepridėjote jokių kolekcijų", "MessageBookshelfNoResultsForFilter": "Rezultatų pagal filtrą \"{0}: {1}\" nėra", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Nėra atvertų RSS srautų", "MessageBookshelfNoSeries": "Neturite jokių serijų", "MessageChapterEndIsAfter": "Skyriaus pabaiga yra po jūsų garso knygos pabaigos", diff --git a/client/strings/nl.json b/client/strings/nl.json index 9ea7fbf2f..bb852e8cc 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Quick Match zal proberen ontbrekende covers en metadata voor de geselecteerde onderdelen te matchten. Schakel de opties hieronder in om Quick Match toe te staan bestaande covers en/of metadata te overschrijven.", "MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt", "MessageBookshelfNoResultsForFilter": "Geen resultaten voor filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend", "MessageBookshelfNoSeries": "Je hebt geen series", "MessageChapterEndIsAfter": "Hoofdstukeinde is na het einde van je audioboek", diff --git a/client/strings/no.json b/client/strings/no.json index a5ccb047f..b1590da10 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Kjapt søk vil forsøke å legge til manglende omslag og metadata for de valgte gjenstandene. Aktiver dette valget for å tillate Kjapt søk til å overskrive eksisterende omslag og/eller metadata.", "MessageBookshelfNoCollections": "Du har ikke laget noen samlinger ennå", "MessageBookshelfNoResultsForFilter": "Ingen resultat for filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Ingen RSS feed er åpen", "MessageBookshelfNoSeries": "Du har ingen serier", "MessageChapterEndIsAfter": "Kapittel slutt er etter slutt av lydboken", diff --git a/client/strings/pl.json b/client/strings/pl.json index 8eb5f1d79..1fe8f723e 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Quick Match będzie próbował dodać brakujące okładki i metadane dla wybranych elementów. Włącz poniższe opcje, aby umożliwić Quick Match nadpisanie istniejących okładek i/lub metadanych.", "MessageBookshelfNoCollections": "Nie posiadasz jeszcze żadnych kolekcji", "MessageBookshelfNoResultsForFilter": "Nie znaleziono żadnych pozycji przy aktualnym filtrowaniu \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Nie posiadasz żadnych otwartych feedów RSS", "MessageBookshelfNoSeries": "Nie masz jeszcze żadnych serii", "MessageChapterEndIsAfter": "Koniec rozdziału następuje po zakończeniu audiobooka", diff --git a/client/strings/pt-br.json b/client/strings/pt-br.json index a821dc413..440c0a2de 100644 --- a/client/strings/pt-br.json +++ b/client/strings/pt-br.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Consulta Rápida tentará adicionar capas e metadados ausentes para os itens selecionados. Ative as opções abaixo para permitir que a Consulta Rápida sobrescreva capas e/ou metadados existentes.", "MessageBookshelfNoCollections": "Você ainda não criou coleções", "MessageBookshelfNoResultsForFilter": "Sem Resultados para o filtro \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Não existem feeds RSS abertos", "MessageBookshelfNoSeries": "Você não tem séries", "MessageChapterEndIsAfter": "O final do capítulo está além do final do seu audiobook", diff --git a/client/strings/ru.json b/client/strings/ru.json index 87ead8b41..97da31826 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Быстрый Поиск попытается добавить отсутствующие обложки и метаданные для выбранных элементов. Включите параметры ниже, чтобы разрешить Быстрому Поиску перезаписывать существующие обложки и/или метаданные.", "MessageBookshelfNoCollections": "Вы еще не создали ни одной коллекции", "MessageBookshelfNoResultsForFilter": "Нет Результатов для фильтра \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Нет открытых RSS-каналов", "MessageBookshelfNoSeries": "У вас нет серий", "MessageChapterEndIsAfter": "Конец главы после окончания вашей аудиокниги", diff --git a/client/strings/sv.json b/client/strings/sv.json index 7a86bc064..df3ead904 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Quick Match kommer försöka lägga till saknade omslag och metadata för de valda föremålen. Aktivera alternativen nedan för att tillåta Quick Match att överskriva befintliga omslag och/eller metadata.", "MessageBookshelfNoCollections": "Du har ännu inte skapat några samlingar", "MessageBookshelfNoResultsForFilter": "Inga resultat för filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Inga RSS-flöden är öppna", "MessageBookshelfNoSeries": "Du har inga serier", "MessageChapterEndIsAfter": "Kapitelns slut är efter din ljudboks slut", diff --git a/client/strings/uk.json b/client/strings/uk.json index 2e96e85a4..f097cd300 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -414,6 +414,7 @@ "LabelPermissionsUpload": "Може завантажувати", "LabelPersonalYearReview": "Ваші підсумки року ({0})", "LabelPhotoPathURL": "Шлях/URL фото", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Списки відтворення", "LabelPlayMethod": "Метод відтворення", "LabelPodcast": "Подкаст", @@ -593,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Швидкий пошук спробує знайти відсутні обкладинки та метадані обраних елементів. Увімкніть налаштування нижче, аби дозволити заміну наявних обкладинок та/або метаданих під час швидкого пошуку.", "MessageBookshelfNoCollections": "Ви не створили жодної добірки", "MessageBookshelfNoResultsForFilter": "Немає результатів з фільтром \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Немає відкритих RSS-каналів", "MessageBookshelfNoSeries": "Серії відсутні", "MessageChapterEndIsAfter": "Кінець глави знаходиться після закінчення книги", diff --git a/client/strings/vi-vn.json b/client/strings/vi-vn.json index eaa9f2cef..e4c30aaa3 100644 --- a/client/strings/vi-vn.json +++ b/client/strings/vi-vn.json @@ -414,6 +414,7 @@ "LabelPermissionsUpload": "Có Thể Tải Lên", "LabelPersonalYearReview": "Năm của Bạn trong Bài Đánh Giá ({0})", "LabelPhotoPathURL": "Đường dẫn/URL ảnh", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Danh sách phát", "LabelPlayMethod": "Phương pháp phát", "LabelPodcast": "Podcast", @@ -593,6 +594,7 @@ "MessageBatchQuickMatchDescription": "Quick Match sẽ cố gắng thêm các ảnh bìa và siêu dữ liệu bị thiếu cho các mục đã chọn. Bật các tùy chọn dưới đây để cho phép Quick Match ghi đè lên các ảnh bìa hiện có và / hoặc siêu dữ liệu.", "MessageBookshelfNoCollections": "Bạn chưa tạo bất kỳ bộ sưu tập nào", "MessageBookshelfNoResultsForFilter": "Không có Kết quả cho bộ lọc \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "Không có nguồn cung cấp RSS nào đang mở", "MessageBookshelfNoSeries": "Bạn không có bộ sách", "MessageChapterEndIsAfter": "Kết thúc chương sau khi kết thúc sách nói của bạn", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 20b907aa6..f976b04a6 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -594,6 +594,7 @@ "MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.", "MessageBookshelfNoCollections": "你尚未进行任何收藏", "MessageBookshelfNoResultsForFilter": "过滤器无结果 \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "没有打开的 RSS 源", "MessageBookshelfNoSeries": "你没有系列", "MessageChapterEndIsAfter": "章节结束是在有声读物结束之后", diff --git a/client/strings/zh-tw.json b/client/strings/zh-tw.json index d3c020718..17453e462 100644 --- a/client/strings/zh-tw.json +++ b/client/strings/zh-tw.json @@ -414,6 +414,7 @@ "LabelPermissionsUpload": "可以上傳", "LabelPersonalYearReview": "你的年度回顧 ({0})", "LabelPhotoPathURL": "圖片路徑或 URL", + "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "播放列表", "LabelPlayMethod": "播放方法", "LabelPodcast": "播客", @@ -593,6 +594,7 @@ "MessageBatchQuickMatchDescription": "快速匹配將嘗試為所選項目新增缺少的封面和元數據. 啟用以下選項以允許快速匹配覆蓋現有封面和或元數據.", "MessageBookshelfNoCollections": "你尚未進行任何收藏", "MessageBookshelfNoResultsForFilter": "過濾器無結果 \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoRSSFeeds": "沒有打開的 RSS 源", "MessageBookshelfNoSeries": "你沒有系列", "MessageChapterEndIsAfter": "章節結束是在有聲書結束之後", From 95506bc638680c4f50b738374ac0815296d4ca45 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 30 May 2024 17:03:33 -0500 Subject: [PATCH 09/16] Update:Add more translation strings, remove unused string #3027 --- .../controls/LibraryFilterSelect.vue | 31 +++++++++++++++++-- client/components/prompt/Confirm.vue | 14 +++++++-- .../tables/CustomMetadataProviderTable.vue | 2 +- client/pages/config/authentication.vue | 2 +- client/pages/config/index.vue | 3 +- client/strings/bg.json | 8 ++++- client/strings/bn.json | 8 ++++- client/strings/cs.json | 8 ++++- client/strings/da.json | 8 ++++- client/strings/de.json | 8 ++++- client/strings/en-us.json | 8 ++++- client/strings/es.json | 8 ++++- client/strings/et.json | 8 ++++- client/strings/fr.json | 8 ++++- client/strings/gu.json | 8 ++++- client/strings/he.json | 8 ++++- client/strings/hi.json | 8 ++++- client/strings/hr.json | 8 ++++- client/strings/hu.json | 8 ++++- client/strings/it.json | 8 ++++- client/strings/lt.json | 8 ++++- client/strings/nl.json | 8 ++++- client/strings/no.json | 8 ++++- client/strings/pl.json | 8 ++++- client/strings/pt-br.json | 8 ++++- client/strings/ru.json | 8 ++++- client/strings/sv.json | 8 ++++- client/strings/uk.json | 8 ++++- client/strings/vi-vn.json | 8 ++++- client/strings/zh-cn.json | 8 ++++- client/strings/zh-tw.json | 8 ++++- 31 files changed, 226 insertions(+), 34 deletions(-) diff --git a/client/components/controls/LibraryFilterSelect.vue b/client/components/controls/LibraryFilterSelect.vue index e5464293a..fcd7ca508 100644 --- a/client/components/controls/LibraryFilterSelect.vue +++ b/client/components/controls/LibraryFilterSelect.vue @@ -37,12 +37,12 @@ arrow_left
- Back + {{ $strings.ButtonBack }}
  • - No {{ sublist }} + {{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}
  • @@ -57,7 +54,8 @@ export default { rotate: 0, loadedRatio: 0, page: 1, - numPages: 0 + numPages: 0, + pdfDocInitParams: null } }, computed: { @@ -108,14 +106,6 @@ export default { return `/api/items/${this.libraryItemId}/ebook/${this.fileId}` } return `/api/items/${this.libraryItemId}/ebook` - }, - pdfDocInitParams() { - return { - url: this.ebookUrl, - httpHeaders: { - Authorization: `Bearer ${this.userToken}` - } - } } }, methods: { @@ -136,7 +126,7 @@ export default { ebookLocation: this.page, ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages))) } - this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => { + this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload, { progress: false }).catch((error) => { console.error('EpubReader.updateProgress failed:', error) }) }, @@ -149,6 +139,7 @@ export default { this.loadedRatio = progress }, numPagesLoaded(e) { + if (!e) return this.numPages = e }, prev() { @@ -167,15 +158,25 @@ export default { resize() { this.windowWidth = window.innerWidth this.windowHeight = window.innerHeight + }, + init() { + this.pdfDocInitParams = { + url: this.ebookUrl, + httpHeaders: { + Authorization: `Bearer ${this.userToken}` + } + } } }, mounted() { this.windowWidth = window.innerWidth this.windowHeight = window.innerHeight window.addEventListener('resize', this.resize) + + this.init() }, beforeDestroy() { window.removeEventListener('resize', this.resize) } } - \ No newline at end of file + From 4da4cf28852531cdc6379e735e382907934b579d Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 1 Jun 2024 11:19:43 -0500 Subject: [PATCH 11/16] Fix:Fluent ffmpeg not detecting formats in ffmpegv7 #3029 --- server/libs/fluentFfmpeg/capabilities.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/libs/fluentFfmpeg/capabilities.js b/server/libs/fluentFfmpeg/capabilities.js index 0e0a823d9..257b1f85a 100644 --- a/server/libs/fluentFfmpeg/capabilities.js +++ b/server/libs/fluentFfmpeg/capabilities.js @@ -15,7 +15,7 @@ var ffCodecRegexp = /^\s*([D\.])([E\.])([VAS])([I\.])([L\.])([S\.]) ([^ ]+) +(.* var ffEncodersRegexp = /\(encoders:([^\)]+)\)/; var ffDecodersRegexp = /\(decoders:([^\)]+)\)/; var encodersRegexp = /^\s*([VAS\.])([F\.])([S\.])([X\.])([B\.])([D\.]) ([^ ]+) +(.*)$/; -var formatRegexp = /^\s*([D ])([E ]) ([^ ]+) +(.*)$/; +var formatRegexp = /^\s*([D ])([E ])\s+([^ ]+)\s+(.*)$/; var lineBreakRegexp = /\r\n|\r|\n/; var filterRegexp = /^(?: [T\.][S\.][C\.] )?([^ ]+) +(AA?|VV?|\|)->(AA?|VV?|\|) +(.*)$/; From 3f2925029c564c634605729fa557e9be56e40a19 Mon Sep 17 00:00:00 2001 From: Machou Date: Sat, 1 Jun 2024 19:17:29 +0200 Subject: [PATCH 12/16] Update fr.json --- client/strings/fr.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index ed7e92cc6..6f6481959 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -280,8 +280,8 @@ "LabelEdit": "Modifier", "LabelEmail": "Courriel", "LabelEmailSettingsFromAddress": "Expéditeur", - "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", - "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", + "LabelEmailSettingsRejectUnauthorized": "Rejeter les certificats non autorisés", + "LabelEmailSettingsRejectUnauthorizedHelp": "Désactiver la validation du certificat SSL peut exposer votre connexion à des risques de sécurité, tels que des attaques de type « man-in-the-middle ». Ne désactivez cette option que si vous en comprenez les implications et si vous faites confiance au serveur de messagerie auquel vous vous connectez.", "LabelEmailSettingsSecure": "Sécurisé", "LabelEmailSettingsSecureHelp": "Utiliser TLS lors de la connexion au serveur, autrement TLS sera utilisé si le serveur prend en charge l’extension STARTTLS. Dans la plupart des cas, actviez l’option si vous vous connectez au port 465. Désactivez l’option pour utiliser port 587 ou 25. (source: nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Adresse de test", @@ -416,9 +416,9 @@ "LabelPermissionsDownload": "Peut télécharger", "LabelPermissionsUpdate": "Peut mettre à jour", "LabelPermissionsUpload": "Peut téléverser", - "LabelPersonalYearReview": "Your Year in Review ({0})", + "LabelPersonalYearReview": "Bilan de l’année ({0})", "LabelPhotoPathURL": "Chemin / URL des photos", - "LabelPlayerChapterNumberMarker": "{0} of {1}", + "LabelPlayerChapterNumberMarker": "{0} sur {1}", "LabelPlaylists": "Listes de lecture", "LabelPlayMethod": "Méthode d’écoute", "LabelPodcast": "Podcast", @@ -817,4 +817,4 @@ "ToastSortingPrefixesUpdateSuccess": "Mise à jour des préfixes de tri ({0} élément)", "ToastUserDeleteFailed": "Échec de la suppression de l’utilisateur", "ToastUserDeleteSuccess": "Utilisateur supprimé" -} \ No newline at end of file +} From 82dcd2d6fbfc26514fa7c55801447ba4f67d6ffc Mon Sep 17 00:00:00 2001 From: Machou Date: Sat, 1 Jun 2024 21:11:08 +0200 Subject: [PATCH 13/16] Update fr.json --- client/strings/fr.json | 126 ++++++++++++++++++++--------------------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index 6f6481959..7f660012d 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -9,7 +9,7 @@ "ButtonApply": "Appliquer", "ButtonApplyChapters": "Appliquer aux chapitres", "ButtonAuthors": "Auteurs", - "ButtonBack": "Back", + "ButtonBack": "Retour", "ButtonBrowseForFolder": "Naviguer vers le répertoire", "ButtonCancel": "Annuler", "ButtonCancelEncode": "Annuler l’encodage", @@ -55,8 +55,8 @@ "ButtonPlaylists": "Listes de lecture", "ButtonPrevious": "Précédent", "ButtonPreviousChapter": "Chapitre précédent", - "ButtonPurgeAllCache": "Purger le cache", - "ButtonPurgeItemsCache": "Purger le cache des articles", + "ButtonPurgeAllCache": "Purger tout le cache", + "ButtonPurgeItemsCache": "Purger le cache des éléments", "ButtonQueueAddItem": "Ajouter à la liste de lecture", "ButtonQueueRemoveItem": "Supprimer de la liste de lecture", "ButtonQuickMatch": "Recherche rapide", @@ -66,7 +66,7 @@ "ButtonRefresh": "Rafraîchir", "ButtonRemove": "Supprimer", "ButtonRemoveAll": "Supprimer tout", - "ButtonRemoveAllLibraryItems": "Supprimer tous les articles de la bibliothèque", + "ButtonRemoveAllLibraryItems": "Supprimer tous les éléments de la bibliothèque", "ButtonRemoveFromContinueListening": "Ne plus continuer à écouter", "ButtonRemoveFromContinueReading": "Ne plus continuer à lire", "ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série", @@ -75,7 +75,7 @@ "ButtonResetToDefault": "Réinitialiser aux valeurs par défaut", "ButtonRestore": "Rétablir", "ButtonSave": "Sauvegarder", - "ButtonSaveAndClose": "Sauvegarder et Fermer", + "ButtonSaveAndClose": "Sauvegarder et fermer", "ButtonSaveTracklist": "Sauvegarder la liste de lecture", "ButtonScan": "Analyser", "ButtonScanLibrary": "Analyser la bibliothèque", @@ -115,7 +115,7 @@ "HeaderCollectionItems": "Entrées de la collection", "HeaderCover": "Couverture", "HeaderCurrentDownloads": "Téléchargements en cours", - "HeaderCustomMessageOnLogin": "Custom Message on Login", + "HeaderCustomMessageOnLogin": "Message personnalisé lors de la connexion", "HeaderCustomMetadataProviders": "Fournisseurs de métadonnées personnalisés", "HeaderDetails": "Détails", "HeaderDownloadQueue": "File d’attente de téléchargements", @@ -128,7 +128,7 @@ "HeaderFiles": "Fichiers", "HeaderFindChapters": "Trouver les chapitres", "HeaderIgnoredFiles": "Fichiers ignorés", - "HeaderItemFiles": "Fichiers des articles", + "HeaderItemFiles": "Fichiers des éléments", "HeaderItemMetadataUtils": "Outils de gestion des métadonnées", "HeaderLastListeningSession": "Dernière session d’écoute", "HeaderLatestEpisodes": "Dernier épisodes", @@ -174,8 +174,8 @@ "HeaderSettingsGeneral": "Général", "HeaderSettingsScanner": "Analyseur", "HeaderSleepTimer": "Minuterie", - "HeaderStatsLargestItems": "Articles les plus lourd", - "HeaderStatsLongestItems": "Articles les plus long (heures)", + "HeaderStatsLargestItems": "Éléments les plus grands", + "HeaderStatsLongestItems": "Éléments les plus long (hrs)", "HeaderStatsMinutesListeningChart": "Minutes d’écoute (7 derniers jours)", "HeaderStatsRecentSessions": "Sessions récentes", "HeaderStatsTop10Authors": "Top 10 Auteurs", @@ -335,10 +335,10 @@ "LabelIntervalEveryDay": "Tous les jours", "LabelIntervalEveryHour": "Toutes les heures", "LabelInvert": "Inverser", - "LabelItem": "Article", + "LabelItem": "Élément", "LabelLanguage": "Langue", "LabelLanguageDefaultServer": "Langue par défaut", - "LabelLanguages": "Languages", + "LabelLanguages": "Langues", "LabelLastBookAdded": "Dernier livre ajouté", "LabelLastBookUpdated": "Dernier livre mis à jour", "LabelLastSeen": "Vu dernièrement", @@ -350,18 +350,18 @@ "LabelLess": "Moins", "LabelLibrariesAccessibleToUser": "Bibliothèque accessible à l’utilisateur", "LabelLibrary": "Bibliothèque", - "LabelLibraryFilterSublistEmpty": "No {0}", - "LabelLibraryItem": "Article de bibliothèque", + "LabelLibraryFilterSublistEmpty": "Aucun {0}", + "LabelLibraryItem": "Élément de bibliothèque", "LabelLibraryName": "Nom de la bibliothèque", "LabelLimit": "Limite", - "LabelLineSpacing": "Interligne", + "LabelLineSpacing": "Espacement des lignes", "LabelListenAgain": "Écouter à nouveau", "LabelLogLevelDebug": "Debug", "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", - "LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date", + "LabelLookForNewEpisodesAfterDate": "Rechercher les nouveaux épisodes après cette date", "LabelLowestPriority": "Priorité la plus basse", - "LabelMatchExistingUsersBy": "Faire correspondre les utilisateurs existants par", + "LabelMatchExistingUsersBy": "Correspondance avec les utilisateurs existants", "LabelMatchExistingUsersByDescription": "Utilisé pour connecter les utilisateurs existants. Une fois connectés, les utilisateurs seront associés à un identifiant unique provenant de votre fournisseur SSO.", "LabelMediaPlayer": "Lecteur multimédia", "LabelMediaType": "Type de média", @@ -371,8 +371,8 @@ "LabelMetaTags": "Balises de métadonnée", "LabelMinute": "Minute", "LabelMissing": "Manquant", - "LabelMissingEbook": "Ne possède pas de livre numérique", - "LabelMissingSupplementaryEbook": "Ne possède pas de livre numérique supplémentaire", + "LabelMissingEbook": "Ne possède aucun livre numérique", + "LabelMissingSupplementaryEbook": "Ne possède aucun livre numérique supplémentaire", "LabelMobileRedirectURIs": "URI de redirection mobile autorisés", "LabelMobileRedirectURIsDescription": "Il s’agit d’une liste blanche d’URI de redirection valides pour les applications mobiles. Celui par défaut est audiobookshelf://oauth, que vous pouvez supprimer ou compléter avec des URIs supplémentaires pour l’intégration d’applications tierces. L’utilisation d’un astérisque (*) comme seule entrée autorise n’importe quel URI.", "LabelMore": "Plus", @@ -386,13 +386,13 @@ "LabelNewPassword": "Nouveau mot de passe", "LabelNextBackupDate": "Date de la prochaine sauvegarde", "LabelNextScheduledRun": "Prochain lancement prévu", - "LabelNoCustomMetadataProviders": "No custom metadata providers", + "LabelNoCustomMetadataProviders": "Aucun fournisseurs de métadonnées personnalisés", "LabelNoEpisodesSelected": "Aucun épisode sélectionné", "LabelNotes": "Notes", "LabelNotFinished": "Non terminé", "LabelNotificationAppriseURL": "URL(s) d’Apprise", "LabelNotificationAvailableVariables": "Variables disponibles", - "LabelNotificationBodyTemplate": "Modèle de Message", + "LabelNotificationBodyTemplate": "Modèle de message", "LabelNotificationEvent": "Evènement de Notification", "LabelNotificationsMaxFailedAttempts": "Nombres de tentatives d’envoi", "LabelNotificationsMaxFailedAttemptsHelp": "La notification est abandonnée une fois ce seuil atteint", @@ -433,8 +433,8 @@ "LabelProvider": "Fournisseur", "LabelPubDate": "Date de publication", "LabelPublisher": "Éditeur", - "LabelPublishers": "Publishers", - "LabelPublishYear": "Année d’édition", + "LabelPublishers": "Éditeurs", + "LabelPublishYear": "Année de publication", "LabelRead": "Lire", "LabelReadAgain": "Lire à nouveau", "LabelReadEbookWithoutProgress": "Lire le livre numérique sans sauvegarder la progression", @@ -484,7 +484,7 @@ "LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales", "LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion GitHub.", "LabelSettingsFindCovers": "Chercher des couvertures de livre", - "LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, l’analyseur tentera de récupérer une couverture.
    Attention, cela peut augmenter le temps d’analyse.", + "LabelSettingsFindCoversHelp": "Si votre livre audio ne possède aucune couverture intégrée ou une image de couverture dans le dossier, l’analyseur tentera de récupérer une couverture.
    Attention, cela peut augmenter le temps d’analyse.", "LabelSettingsHideSingleBookSeries": "Masquer les séries de livres uniques", "LabelSettingsHideSingleBookSeriesHelp": "Les séries qui ne comportent qu’un seul livre seront masquées sur la page de la série et sur les étagères de la page d’accueil.", "LabelSettingsHomePageBookshelfView": "Utiliser la vue étagère sur la page d’accueil", @@ -494,28 +494,28 @@ "LabelSettingsParseSubtitles": "Analyser les sous-titres", "LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du livre audio.
    Les sous-titres doivent être séparés par des « - »
    c’est-à-dire : « Titre du livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »", "LabelSettingsPreferMatchedMetadata": "Préférer les métadonnées par correspondance", - "LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de l’article lors d’une recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.", + "LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées mises en correspondance remplaceront les détails de l’élément lors de l’utilisation de la correspondance rapide. Par défaut, la correspondance rapide ne remplira que les détails manquants.", "LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance pour les livres ayant déjà un ASIN", "LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance pour les livres ayant déjà un ISBN", "LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri", "LabelSettingsSortingIgnorePrefixesHelp": "c’est-à-dire : pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »", "LabelSettingsSquareBookCovers": "Utiliser des couvertures carrées", "LabelSettingsSquareBookCoversHelp": "Préférer les couvertures carrées par rapport aux couvertures standards de ratio 1.6:1.", - "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", + "LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les éléments", + "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ément. Seul un fichier nommé « cover » sera conservé.", + "LabelSettingsStoreMetadataWithItem": "Enregistrer les métadonnées avec l’élément", + "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les fichiers de métadonnées sont stockés dans /metadata/items. En activant ce paramètre, les fichiers de métadonnées seront stockés dans les dossiers des éléments de votre bibliothèque.", "LabelSettingsTimeFormat": "Format d’heure", "LabelShowAll": "Tout afficher", - "LabelShowSeconds": "Afficher le seondes", + "LabelShowSeconds": "Afficher les seondes", "LabelSize": "Taille", - "LabelSleepTimer": "Minuterie", + "LabelSleepTimer": "Minuterie de mise en veille", "LabelSlug": "Balise", "LabelStart": "Démarrer", "LabelStarted": "Démarré", "LabelStartedAt": "Démarré à", "LabelStartTime": "Heure de démarrage", - "LabelStatsAudioTracks": "Pistes Audios", + "LabelStatsAudioTracks": "Pistes audio", "LabelStatsAuthors": "Auteurs", "LabelStatsBestDay": "Meilleur jour", "LabelStatsDailyAverage": "Moyenne journalière", @@ -523,8 +523,8 @@ "LabelStatsDaysListened": "Jours d’écoute", "LabelStatsHours": "Heures", "LabelStatsInARow": "d’affilée(s)", - "LabelStatsItemsFinished": "Articles terminés", - "LabelStatsItemsInLibrary": "Articles dans la bibliothèque", + "LabelStatsItemsFinished": "Élément(s) terminé(s)", + "LabelStatsItemsInLibrary": "Éléments dans la bibliothèque", "LabelStatsMinutes": "minutes", "LabelStatsMinutesListening": "Minutes d’écoute", "LabelStatsOverallDays": "Nombre total de jours", @@ -595,11 +595,11 @@ "LabelYourProgress": "Votre progression", "MessageAddToPlayerQueue": "Ajouter en file d’attente", "MessageAppriseDescription": "Nécessite une instance d’API Apprise pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.
    L’URL de l’API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur http://192.168.1.1:8337 alors vous devez mettre http://192.168.1.1:8337/notify.", - "MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les sauvegardes n’incluent pas les fichiers de votre bibliothèque.", - "MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer l’option suivante pour autoriser la recherche par correspondance à écraser les données existantes.", + "MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression des utilisateurs, les détails des éléments de la bibliothèque, les paramètres du serveur et les images stockées dans /metadata/items & /metadata/authors. Les sauvegardes n’incluent pas les fichiers stockés dans les dossiers de votre bibliothèque.", + "MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et métadonnées manquantes pour les éléments sélectionnés. Activez les options ci-dessous pour permettre la Recherche par correspondance d’écraser les couvertures et/ou métadonnées existantes.", "MessageBookshelfNoCollections": "Vous n’avez pas encore de collections", "MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0} : {1} »", - "MessageBookshelfNoResultsForQuery": "No results for query", + "MessageBookshelfNoResultsForQuery": "Aucun résultat pour la requête", "MessageBookshelfNoRSSFeeds": "Aucun flux RSS n’est ouvert", "MessageBookshelfNoSeries": "Vous n’avez aucune série", "MessageChapterEndIsAfter": "La fin du chapitre se situe après la fin de votre livre audio.", @@ -621,8 +621,8 @@ "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 ?", "MessageConfirmPurgeCache": "La purge du cache supprimera l’intégralité du répertoire à /metadata/cache.

    Êtes-vous sûr de vouloir supprimer le répertoire de cache ?", - "MessageConfirmPurgeItemsCache": "Purge items cache will delete the entire directory at /metadata/cache/items.
    Are you sure?", - "MessageConfirmQuickEmbed": "Attention ! L’intégration rapide ne sauvegardera pas vos fichiers audio. Assurez-vous d’avoir effectuer une sauvegarde de vos fichiers audio.

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

    Souhaitez-vous continuer ?", "MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?", "MessageConfirmRemoveAuthor": "Êtes-vous sûr de vouloir supprimer l’auteur « {0} » ?", "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?", @@ -631,11 +631,11 @@ "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Êtes-vous sûr de vouloir supprimer le narrateur « {0} » ?", "MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?", - "MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » en « {1} » pour tous les articles ?", - "MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.", + "MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » en « {1} » pour tous les éléments ?", + "MessageConfirmRenameGenreMergeNote": "Information : ce genre existe déjà et sera fusionné.", "MessageConfirmRenameGenreWarning": "Attention ! Un genre similaire avec une casse différente existe déjà « {0} ».", - "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.", + "MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les éléments ?", + "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": "Êtes-vous sûr de vouloir re-analyser {0} éléments ?", "MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer le livre numérique {0} « {1} » à l’appareil « {2} »?", @@ -649,16 +649,16 @@ "MessageForceReScanDescription": "analysera de nouveau tous les fichiers. Les étiquettes ID3 des fichiers audio, les fichiers OPF et les fichiers texte seront analysés comme s’ils étaient nouveaux.", "MessageImportantNotice": "Information importante !", "MessageInsertChapterBelow": "Insérer le chapitre ci-dessous", - "MessageItemsSelected": "{0} articles sélectionnés", - "MessageItemsUpdated": "{0} articles mis à jour", + "MessageItemsSelected": "{0} éléments sélectionnés", + "MessageItemsUpdated": "{0} éléments mis à jour", "MessageJoinUsOn": "Rejoignez-nous sur", "MessageListeningSessionsInTheLastYear": "{0} sessions d’écoute l’an dernier", "MessageLoading": "Chargement…", "MessageLoadingFolders": "Chargement des dossiers…", "MessageLogsDescription": "Les journaux sont stockés dans /metadata/logs sous forme de fichiers JSON. Les journaux d’incidents sont stockés dans /metadata/logs/crash_logs.txt.", - "MessageM4BFailed": "M4B échec", - "MessageM4BFinished": "M4B terminé", - "MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster l’horodatage.", + "MessageM4BFailed": "M4B a échoué !", + "MessageM4BFinished": "M4B terminé !", + "MessageMapChapterTitles": "Faire correspondre les titres de chapitres avec ceux de vos livres audio existants sans ajuster les horodatages.", "MessageMarkAllEpisodesFinished": "Marquer tous les épisodes terminés", "MessageMarkAllEpisodesNotFinished": "Marquer tous les épisodes non terminés", "MessageMarkAsFinished": "Marquer comme terminé", @@ -673,14 +673,14 @@ "MessageNoCoversFound": "Aucune couverture trouvée", "MessageNoDescription": "Aucune description", "MessageNoDownloadsInProgress": "Aucun téléchargement en cours", - "MessageNoDownloadsQueued": "Aucun téléchargement en file d’attente", + "MessageNoDownloadsQueued": "Aucun téléchargement en attente", "MessageNoEpisodeMatchesFound": "Aucune correspondance d’épisode trouvée", "MessageNoEpisodes": "Aucun épisode", - "MessageNoFoldersAvailable": "Aucun dossier disponible", + "MessageNoFoldersAvailable": "Aucun dossiers disponible", "MessageNoGenres": "Aucun genre", "MessageNoIssues": "Aucune parution", - "MessageNoItems": "Aucun article", - "MessageNoItemsFound": "Aucun article trouvé", + "MessageNoItems": "Aucun élément", + "MessageNoItemsFound": "Aucun élément trouvé", "MessageNoListeningSessions": "Aucune session d’écoute en cours", "MessageNoLogs": "Aucun journaux", "MessageNoMediaProgress": "Aucun média en cours", @@ -689,7 +689,7 @@ "MessageNoResults": "Aucun résultat", "MessageNoSearchResultsFor": "Aucun résultat pour la recherche « {0} »", "MessageNoSeries": "Aucune série", - "MessageNoTags": "Aucune d’étiquettes", + "MessageNoTags": "Aucune étiquette", "MessageNoTasksRunning": "Aucune tâche en cours", "MessageNotYetImplemented": "Non implémenté", "MessageNoUpdateNecessary": "Aucune mise à jour nécessaire", @@ -763,8 +763,8 @@ "ToastCachePurgeSuccess": "Cache purgé avec succès", "ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs", "ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre", - "ToastCollectionItemsRemoveFailed": "Échec de la suppression de(s) article(s) de la collection", - "ToastCollectionItemsRemoveSuccess": "Article(s) supprimé(s) de la collection", + "ToastCollectionItemsRemoveFailed": "Échec de la suppression d’un ou plusieurs éléments de la collection", + "ToastCollectionItemsRemoveSuccess": "Élément(s) supprimé(s) de la collection", "ToastCollectionRemoveFailed": "Échec de la suppression de la collection", "ToastCollectionRemoveSuccess": "Collection supprimée", "ToastCollectionUpdateFailed": "Échec de la mise à jour de la collection", @@ -772,11 +772,11 @@ "ToastDeleteFileFailed": "Échec de la suppression du fichier", "ToastDeleteFileSuccess": "Fichier supprimé", "ToastFailedToLoadData": "Échec du chargement des données", - "ToastItemCoverUpdateFailed": "Échec de la mise à jour de la couverture de l’article", - "ToastItemCoverUpdateSuccess": "Couverture de l’article mise à jour", - "ToastItemDetailsUpdateFailed": "Échec de la mise à jour des détails de l’article", - "ToastItemDetailsUpdateSuccess": "Détails de l’article mis à jour", - "ToastItemDetailsUpdateUnneeded": "Pas de mise à jour nécessaire sur les détails de l’article", + "ToastItemCoverUpdateFailed": "Échec de la mise à jour de la couverture de l’élément", + "ToastItemCoverUpdateSuccess": "Couverture mise à jour", + "ToastItemDetailsUpdateFailed": "Échec de la mise à jour des détails de l’élément", + "ToastItemDetailsUpdateSuccess": "Détails de l’élément mis à jour", + "ToastItemDetailsUpdateUnneeded": "Aucune mise à jour n’est nécessaire pour les détails de l’élément", "ToastItemMarkedAsFinishedFailed": "Échec de l’annotation terminée", "ToastItemMarkedAsFinishedSuccess": "Article marqué comme terminé", "ToastItemMarkedAsNotFinishedFailed": "Échec de l’annotation non-terminée", @@ -795,10 +795,10 @@ "ToastPlaylistRemoveSuccess": "Liste de lecture supprimée", "ToastPlaylistUpdateFailed": "Échec de la mise à jour de la liste de lecture", "ToastPlaylistUpdateSuccess": "Liste de lecture mise à jour", - "ToastPodcastCreateFailed": "Échec de la création du Podcast", - "ToastPodcastCreateSuccess": "Podcast créé", - "ToastRemoveItemFromCollectionFailed": "Échec de la suppression de l’article de la collection", - "ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection", + "ToastPodcastCreateFailed": "Échec de la création du podcast", + "ToastPodcastCreateSuccess": "Podcast créé avec succès", + "ToastRemoveItemFromCollectionFailed": "Échec de la suppression d’un élément de la collection", + "ToastRemoveItemFromCollectionSuccess": "Élément supprimé de la collection", "ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS", "ToastRSSFeedCloseSuccess": "Flux RSS fermé", "ToastSendEbookToDeviceFailed": "Échec de l’envoi du livre numérique à l’appareil", From 9c33446449fa648a08aa05d2eaa8f32187f57d11 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 3 Jun 2024 17:21:18 -0500 Subject: [PATCH 14/16] Update:Support for ENV variables to disable SSRF request filter (DISABLE_SSRF_REQUEST_FILTER=1) #2549 --- server/Server.js | 1 + server/utils/fileUtils.js | 248 +++++++++++++++++++---------------- server/utils/podcastUtils.js | 67 +++++----- 3 files changed, 167 insertions(+), 149 deletions(-) diff --git a/server/Server.js b/server/Server.js index 2d393a8d7..404c19798 100644 --- a/server/Server.js +++ b/server/Server.js @@ -51,6 +51,7 @@ class Server { global.RouterBasePath = ROUTER_BASE_PATH global.XAccel = process.env.USE_X_ACCEL global.AllowCors = process.env.ALLOW_CORS === '1' + global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1' if (!fs.pathExistsSync(global.ConfigPath)) { fs.mkdirSync(global.ConfigPath) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index a4a97f63e..db5520628 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -7,13 +7,12 @@ const rra = require('../libs/recursiveReaddirAsync') const Logger = require('../Logger') const { AudioMimeType } = require('./constants') - /** -* Make sure folder separator is POSIX for Windows file paths. e.g. "C:\Users\Abs" becomes "C:/Users/Abs" -* -* @param {String} path - Ugly file path -* @return {String} Pretty posix file path -*/ + * Make sure folder separator is POSIX for Windows file paths. e.g. "C:\Users\Abs" becomes "C:/Users/Abs" + * + * @param {String} path - Ugly file path + * @return {String} Pretty posix file path + */ const filePathToPOSIX = (path) => { if (!global.isWin || !path) return path return path.replace(/\\/g, '/') @@ -22,9 +21,9 @@ module.exports.filePathToPOSIX = filePathToPOSIX /** * Check path is a child of or equal to another path - * - * @param {string} parentPath - * @param {string} childPath + * + * @param {string} parentPath + * @param {string} childPath * @returns {boolean} */ function isSameOrSubPath(parentPath, childPath) { @@ -33,8 +32,8 @@ function isSameOrSubPath(parentPath, 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 + relativePath === '' || // Same path (e.g. parentPath = '/a/b/', childPath = '/a/b') + (!relativePath.startsWith('..') && !Path.isAbsolute(relativePath)) // Sub path ) } module.exports.isSameOrSubPath = isSameOrSubPath @@ -67,8 +66,8 @@ module.exports.getFileTimestampsWithIno = getFileTimestampsWithIno /** * Get file size - * - * @param {string} path + * + * @param {string} path * @returns {Promise} */ module.exports.getFileSize = async (path) => { @@ -77,8 +76,8 @@ module.exports.getFileSize = async (path) => { /** * Get file mtimeMs - * - * @param {string} path + * + * @param {string} path * @returns {Promise} epoch timestamp */ module.exports.getFileMTimeMs = async (path) => { @@ -91,8 +90,8 @@ module.exports.getFileMTimeMs = async (path) => { } /** - * - * @param {string} filepath + * + * @param {string} filepath * @returns {boolean} */ async function checkPathIsFile(filepath) { @@ -106,16 +105,19 @@ async function checkPathIsFile(filepath) { module.exports.checkPathIsFile = checkPathIsFile function getIno(path) { - return fs.stat(path, { bigint: true }).then((data => String(data.ino))).catch((err) => { - Logger.error('[Utils] Failed to get ino for path', path, err) - return null - }) + return fs + .stat(path, { bigint: true }) + .then((data) => String(data.ino)) + .catch((err) => { + Logger.error('[Utils] Failed to get ino for path', path, err) + return null + }) } module.exports.getIno = getIno /** * Read contents of file - * @param {string} path + * @param {string} path * @returns {string} */ async function readTextFile(path) { @@ -144,8 +146,8 @@ module.exports.bytesPretty = bytesPretty /** * Get array of files inside dir - * @param {string} path - * @param {string} [relPathToReplace] + * @param {string} path + * @param {string} [relPathToReplace] * @returns {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} */ async function recurseFiles(path, relPathToReplace = null) { @@ -177,55 +179,58 @@ async function recurseFiles(path, relPathToReplace = null) { const directoriesToIgnore = [] - list = list.filter((item) => { - if (item.error) { - Logger.error(`[fileUtils] Recurse files file "${item.fullname}" has error`, item.error) - return false - } + list = list + .filter((item) => { + if (item.error) { + Logger.error(`[fileUtils] Recurse files file "${item.fullname}" has error`, item.error) + return false + } - const relpath = item.fullname.replace(relPathToReplace, '') - let reldirname = Path.dirname(relpath) - if (reldirname === '.') reldirname = '' - const dirname = Path.dirname(item.fullname) + const relpath = item.fullname.replace(relPathToReplace, '') + let reldirname = Path.dirname(relpath) + if (reldirname === '.') reldirname = '' + const dirname = Path.dirname(item.fullname) - // Directory has a file named ".ignore" flag directory and ignore - if (item.name === '.ignore' && reldirname && reldirname !== '.' && !directoriesToIgnore.includes(dirname)) { - Logger.debug(`[fileUtils] .ignore found - ignoring directory "${reldirname}"`) - directoriesToIgnore.push(dirname) - return false - } + // Directory has a file named ".ignore" flag directory and ignore + if (item.name === '.ignore' && reldirname && reldirname !== '.' && !directoriesToIgnore.includes(dirname)) { + Logger.debug(`[fileUtils] .ignore found - ignoring directory "${reldirname}"`) + directoriesToIgnore.push(dirname) + return false + } - if (item.extension === '.part') { - Logger.debug(`[fileUtils] Ignoring .part file "${relpath}"`) - return false - } + if (item.extension === '.part') { + Logger.debug(`[fileUtils] Ignoring .part file "${relpath}"`) + return false + } - // Ignore any file if a directory or the filename starts with "." - if (relpath.split('/').find(p => p.startsWith('.'))) { - Logger.debug(`[fileUtils] Ignoring path has . "${relpath}"`) - return false - } + // Ignore any file if a directory or the filename starts with "." + if (relpath.split('/').find((p) => p.startsWith('.'))) { + Logger.debug(`[fileUtils] Ignoring path has . "${relpath}"`) + return false + } - return true - }).filter(item => { - // Filter out items in ignore directories - if (directoriesToIgnore.some(dir => item.fullname.startsWith(dir))) { - Logger.debug(`[fileUtils] Ignoring path in dir with .ignore "${item.fullname}"`) - return false - } - return true - }).map((item) => { - var isInRoot = (item.path + '/' === relPathToReplace) - return { - name: item.name, - path: item.fullname.replace(relPathToReplace, ''), - dirpath: item.path, - reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''), - fullpath: item.fullname, - extension: item.extension, - deep: item.deep - } - }) + return true + }) + .filter((item) => { + // Filter out items in ignore directories + if (directoriesToIgnore.some((dir) => item.fullname.startsWith(dir))) { + Logger.debug(`[fileUtils] Ignoring path in dir with .ignore "${item.fullname}"`) + return false + } + return true + }) + .map((item) => { + var isInRoot = item.path + '/' === relPathToReplace + return { + name: item.name, + path: item.fullname.replace(relPathToReplace, ''), + dirpath: item.path, + reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''), + fullpath: item.fullname, + extension: item.extension, + deep: item.deep + } + }) // Sort from least deep to most list.sort((a, b) => a.deep - b.deep) @@ -237,8 +242,8 @@ module.exports.recurseFiles = recurseFiles /** * Download file from web to local file system * Uses SSRF filter to prevent internal URLs - * - * @param {string} url + * + * @param {string} url * @param {string} filepath path to download the file to * @param {Function} [contentTypeFilter] validate content type before writing * @returns {Promise} @@ -251,33 +256,35 @@ module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => { method: 'GET', responseType: 'stream', timeout: 30000, - 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) - - writer.on('finish', resolve) - writer.on('error', reject) - }).catch((err) => { - Logger.error(`[fileUtils] Failed to download file "${filepath}"`, err) - reject(err) + httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl), + httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl) }) + .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) + + writer.on('finish', resolve) + writer.on('error', reject) + }) + .catch((err) => { + Logger.error(`[fileUtils] Failed to download file "${filepath}"`, err) + reject(err) + }) }) } /** * 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 + * + * @param {string} url + * @param {string} filepath * @returns {Promise} */ module.exports.downloadImageFile = (url, filepath) => { @@ -350,14 +357,17 @@ module.exports.getAudioMimeTypeFromExtname = (extname) => { module.exports.removeFile = (path) => { if (!path) return false - return fs.remove(path).then(() => true).catch((error) => { - Logger.error(`[fileUtils] Failed remove file "${path}"`, error) - return false - }) + return fs + .remove(path) + .then(() => true) + .catch((error) => { + Logger.error(`[fileUtils] Failed remove file "${path}"`, error) + return false + }) } module.exports.encodeUriPath = (path) => { - const uri = new URL('/', "file://") + const uri = new URL('/', 'file://') // we assign the path here to assure that URL control characters like # are // actually interpreted as part of the URL path uri.pathname = path @@ -367,8 +377,8 @@ module.exports.encodeUriPath = (path) => { /** * Check if directory is writable. * This method is necessary because fs.access(directory, fs.constants.W_OK) does not work on Windows - * - * @param {string} directory + * + * @param {string} directory * @returns {Promise} */ module.exports.isWritable = async (directory) => { @@ -385,7 +395,7 @@ module.exports.isWritable = async (directory) => { /** * Get Windows drives as array e.g. ["C:/", "F:/"] - * + * * @returns {Promise} */ module.exports.getWindowsDrives = async () => { @@ -398,7 +408,11 @@ module.exports.getWindowsDrives = async () => { reject(error) return } - let drives = stdout?.split(/\r?\n/).map(line => line.trim()).filter(line => line).slice(1) + let drives = stdout + ?.split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line) + .slice(1) const validDrives = [] for (const drive of drives) { let drivepath = drive + '/' @@ -415,33 +429,35 @@ module.exports.getWindowsDrives = async () => { /** * Get array of directory paths in a directory - * - * @param {string} dirPath + * + * @param {string} dirPath * @param {number} level * @returns {Promise<{ path:string, dirname:string, level:number }[]>} */ module.exports.getDirectoriesInPath = async (dirPath, level) => { try { const paths = await fs.readdir(dirPath) - let dirs = await Promise.all(paths.map(async dirname => { - const fullPath = Path.join(dirPath, dirname) + let dirs = await Promise.all( + paths.map(async (dirname) => { + const fullPath = Path.join(dirPath, dirname) - const lstat = await fs.lstat(fullPath).catch((error) => { - Logger.debug(`Failed to lstat "${fullPath}"`, error) - return null + const lstat = await fs.lstat(fullPath).catch((error) => { + Logger.debug(`Failed to lstat "${fullPath}"`, error) + return null + }) + if (!lstat?.isDirectory()) return null + + return { + path: this.filePathToPOSIX(fullPath), + dirname, + level + } }) - if (!lstat?.isDirectory()) return null - - return { - path: this.filePathToPOSIX(fullPath), - dirname, - level - } - })) - dirs = dirs.filter(d => d) + ) + dirs = dirs.filter((d) => d) return dirs } catch (error) { Logger.error('Failed to readdir', dirPath, error) return [] } -} \ No newline at end of file +} diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 769798eb0..954a6d57f 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -220,8 +220,8 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal /** * Get podcast RSS feed as JSON * Uses SSRF filter to prevent internal URLs - * - * @param {string} feedUrl + * + * @param {string} feedUrl * @param {boolean} [excludeEpisodeMetadata=false] * @returns {Promise} */ @@ -234,37 +234,38 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { timeout: 12000, responseType: 'arraybuffer', headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8' }, - httpAgent: ssrfFilter(feedUrl), - httpsAgent: ssrfFilter(feedUrl) - }).then(async (data) => { - - // Adding support for ios-8859-1 encoded RSS feeds. - // See: https://github.com/advplyr/audiobookshelf/issues/1489 - const contentType = data.headers?.['content-type'] || '' // e.g. text/xml; charset=iso-8859-1 - if (contentType.toLowerCase().includes('iso-8859-1')) { - data.data = data.data.toString('latin1') - } else { - data.data = data.data.toString() - } - - if (!data?.data) { - Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`) - return null - } - Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}" success - parsing xml`) - const payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata) - if (!payload) { - return null - } - - // RSS feed may be a private RSS feed - payload.podcast.metadata.feedUrl = feedUrl - - return payload.podcast - }).catch((error) => { - Logger.error('[podcastUtils] getPodcastFeed Error', error) - return null + httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl), + httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl) }) + .then(async (data) => { + // Adding support for ios-8859-1 encoded RSS feeds. + // See: https://github.com/advplyr/audiobookshelf/issues/1489 + const contentType = data.headers?.['content-type'] || '' // e.g. text/xml; charset=iso-8859-1 + if (contentType.toLowerCase().includes('iso-8859-1')) { + data.data = data.data.toString('latin1') + } else { + data.data = data.data.toString() + } + + if (!data?.data) { + Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`) + return null + } + Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}" success - parsing xml`) + const payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata) + if (!payload) { + return null + } + + // RSS feed may be a private RSS feed + payload.podcast.metadata.feedUrl = feedUrl + + return payload.podcast + }) + .catch((error) => { + Logger.error('[podcastUtils] getPodcastFeed Error', error) + return null + }) } // Return array of episodes ordered by closest match (Levenshtein distance of 6 or less) @@ -283,7 +284,7 @@ module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => { } const matches = [] - feed.episodes.forEach(ep => { + feed.episodes.forEach((ep) => { if (!ep.title) return const epTitle = ep.title.toLowerCase().trim() From 06202811b460d5ede99dfdf92ccdcdba6e80d94b Mon Sep 17 00:00:00 2001 From: Daniel Brain Date: Wed, 5 Jun 2024 20:31:07 +1000 Subject: [PATCH 15/16] Fix ssrfFilter url --- server/utils/fileUtils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index db5520628..e62f12a53 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -256,8 +256,8 @@ module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => { method: 'GET', responseType: 'stream', timeout: 30000, - httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl), - httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl) + httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(url), + httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(url) }) .then((response) => { // Validate content type From ef05e37a042d9d8c7330d162c6b009dd30282c27 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 5 Jun 2024 17:02:03 -0500 Subject: [PATCH 16/16] Fix:Casting for podcast episodes #3044 --- client/players/AudioTrack.js | 2 +- client/players/castUtils.js | 64 ++++++++++++++++++++++-------------- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/client/players/AudioTrack.js b/client/players/AudioTrack.js index f364dad89..78ddfd76b 100644 --- a/client/players/AudioTrack.js +++ b/client/players/AudioTrack.js @@ -29,4 +29,4 @@ export default class AudioTrack { return this.contentUrl + `?token=${this.userToken}` } -} \ No newline at end of file +} diff --git a/client/players/castUtils.js b/client/players/castUtils.js index 666fcb2c6..3d8e3ee86 100644 --- a/client/players/castUtils.js +++ b/client/players/castUtils.js @@ -1,13 +1,22 @@ - function getMediaInfoFromTrack(libraryItem, castImage, track) { - // https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.AudiobookChapterMediaMetadata - var metadata = new chrome.cast.media.AudiobookChapterMediaMetadata() - metadata.bookTitle = libraryItem.media.metadata.title - metadata.chapterNumber = track.index - metadata.chapterTitle = track.title - metadata.images = [castImage] - metadata.title = track.title - metadata.subtitle = libraryItem.media.metadata.title + let metadata = null + if (libraryItem.mediaType === 'podcast') { + metadata = new chrome.cast.media.MusicTrackMediaMetadata() + metadata.albumArtist = libraryItem.media.metadata.author + metadata.artist = libraryItem.media.metadata.author + metadata.title = track.title + metadata.albumName = libraryItem.media.metadata.title + metadata.images = [castImage] + } else { + // https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.AudiobookChapterMediaMetadata + metadata = new chrome.cast.media.AudiobookChapterMediaMetadata() + metadata.bookTitle = libraryItem.media.metadata.title + metadata.chapterNumber = track.index + metadata.chapterTitle = track.title + metadata.images = [castImage] + metadata.title = track.title + metadata.subtitle = libraryItem.media.metadata.title + } var trackurl = track.fullContentUrl var mimeType = track.mimeType @@ -20,17 +29,25 @@ function getMediaInfoFromTrack(libraryItem, castImage, track) { function buildCastMediaInfo(libraryItem, coverUrl, tracks) { const castImage = new chrome.cast.Image(coverUrl) - return tracks.map(t => getMediaInfoFromTrack(libraryItem, castImage, t)) + return tracks.map((t) => getMediaInfoFromTrack(libraryItem, castImage, t)) } function buildCastQueueRequest(libraryItem, coverUrl, tracks, startTime) { var mediaInfoItems = buildCastMediaInfo(libraryItem, coverUrl, tracks) - var containerMetadata = new chrome.cast.media.AudiobookContainerMetadata() - containerMetadata.authors = libraryItem.media.metadata.authors.map(a => a.name) - containerMetadata.narrators = libraryItem.media.metadata.narrators || [] - containerMetadata.publisher = libraryItem.media.metadata.publisher || undefined - containerMetadata.title = libraryItem.media.metadata.title + let containerMetadata = null + let queueType = chrome.cast.media.QueueType.AUDIOBOOK + if (libraryItem.mediaType === 'podcast') { + queueType = chrome.cast.media.QueueType.PODCAST_SERIES + containerMetadata = new chrome.cast.media.ContainerMetadata(chrome.cast.media.ContainerType.GENERIC_CONTAINER) + containerMetadata.title = libraryItem.media.metadata.title + } else { + containerMetadata = new chrome.cast.media.AudiobookContainerMetadata() + containerMetadata.authors = libraryItem.media.metadata.authors?.map((a) => a.name) + containerMetadata.narrators = libraryItem.media.metadata.narrators || [] + containerMetadata.publisher = libraryItem.media.metadata.publisher || undefined + containerMetadata.title = libraryItem.media.metadata.title + } var mediaQueueItems = mediaInfoItems.map((mi) => { var queueItem = new chrome.cast.media.QueueItem(mi) @@ -38,23 +55,25 @@ function buildCastQueueRequest(libraryItem, coverUrl, tracks, startTime) { }) // Find track to start playback and calculate track start offset - var track = tracks.find(at => at.startOffset <= startTime && at.startOffset + at.duration > startTime) + var track = tracks.find((at) => at.startOffset <= startTime && at.startOffset + at.duration > startTime) var trackStartIndex = track ? track.index - 1 : 0 var trackStartTime = Math.floor(track ? startTime - track.startOffset : 0) var queueData = new chrome.cast.media.QueueData(libraryItem.id, libraryItem.media.metadata.title, '', false, mediaQueueItems, trackStartIndex, trackStartTime) queueData.containerMetadata = containerMetadata - queueData.queueType = chrome.cast.media.QueueType.AUDIOBOOK + queueData.queueType = queueType return queueData } function castLoadMedia(castSession, request) { return new Promise((resolve) => { - castSession.loadMedia(request) - .then(() => resolve(true), (reason) => { + castSession.loadMedia(request).then( + () => resolve(true), + (reason) => { console.error('Load media failed', reason) resolve(false) - }) + } + ) }) } @@ -69,7 +88,4 @@ function buildCastLoadRequest(libraryItem, coverUrl, tracks, startTime, autoplay return request } -export { - buildCastLoadRequest, - castLoadMedia -} \ No newline at end of file +export { buildCastLoadRequest, castLoadMedia }