From a1e321b153c9ba8821e35b8975639f4fe998d4e0 Mon Sep 17 00:00:00 2001 From: Machou Date: Wed, 3 Jan 2024 20:16:21 +0100 Subject: [PATCH 01/15] Update fr.json --- client/strings/fr.json | 210 ++++++++++++++++++++--------------------- 1 file changed, 105 insertions(+), 105 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index 86a6460255..3db4b43a88 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -1,10 +1,10 @@ { "ButtonAdd": "Ajouter", "ButtonAddChapters": "Ajouter le chapitre", - "ButtonAddDevice": "Add Device", - "ButtonAddLibrary": "Add Library", + "ButtonAddDevice": "Ajouter un appareil", + "ButtonAddLibrary": "Ajouter une bibliothèque", "ButtonAddPodcasts": "Ajouter des podcasts", - "ButtonAddUser": "Add User", + "ButtonAddUser": "Ajouter un utilisateur", "ButtonAddYourFirstLibrary": "Ajouter votre première bibliothèque", "ButtonApply": "Appliquer", "ButtonApplyChapters": "Appliquer les chapitres", @@ -62,7 +62,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série", "ButtonReScan": "Nouvelle analyse", "ButtonReset": "Réinitialiser", - "ButtonResetToDefault": "Reset to default", + "ButtonResetToDefault": "Réinitialiser aux valeurs par défaut", "ButtonRestore": "Rétablir", "ButtonSave": "Sauvegarder", "ButtonSaveAndClose": "Sauvegarder et Fermer", @@ -87,9 +87,9 @@ "ButtonUserEdit": "Modifier l’utilisateur {0}", "ButtonViewAll": "Afficher tout", "ButtonYes": "Oui", - "ErrorUploadFetchMetadataAPI": "Error fetching metadata", - "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", - "ErrorUploadLacksTitle": "Must have a title", + "ErrorUploadFetchMetadataAPI": "Erreur lors de la récupération des métadonnées", + "ErrorUploadFetchMetadataNoResults": "Impossible de récupérer les métadonnées - essayez de mettre à jour le titre et/ou l’auteur.", + "ErrorUploadLacksTitle": "Doit avoir un titre", "HeaderAccount": "Compte", "HeaderAdvanced": "Avancé", "HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise", @@ -130,15 +130,15 @@ "HeaderManageTags": "Gérer les étiquettes", "HeaderMapDetails": "Édition en masse", "HeaderMatch": "Chercher", - "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", - "HeaderMetadataToEmbed": "Métadonnée à intégrer", + "HeaderMetadataOrderOfPrecedence": "Ordre de priorité des métadonnées", + "HeaderMetadataToEmbed": "Métadonnées à intégrer", "HeaderNewAccount": "Nouveau compte", "HeaderNewLibrary": "Nouvelle bibliothèque", "HeaderNotifications": "Notifications", - "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", - "HeaderOpenRSSFeed": "Ouvrir Flux RSS", + "HeaderOpenIDConnectAuthentication": "Authentification via OpenID Connect", + "HeaderOpenRSSFeed": "Ouvrir un flux RSS", "HeaderOtherFiles": "Autres fichiers", - "HeaderPasswordAuthentication": "Password Authentication", + "HeaderPasswordAuthentication": "Authentification par mot de passe", "HeaderPermissions": "Permissions", "HeaderPlayerQueue": "Liste d’écoute", "HeaderPlaylist": "Liste de lecture", @@ -187,11 +187,11 @@ "LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection", "LabelAddToPlaylist": "Ajouter à la liste de lecture", "LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture", - "LabelAdminUsersOnly": "Admin users only", + "LabelAdminUsersOnly": "Administrateurs uniquement", "LabelAll": "Tout", "LabelAllUsers": "Tous les utilisateurs", - "LabelAllUsersExcludingGuests": "All users excluding guests", - "LabelAllUsersIncludingGuests": "All users including guests", + "LabelAllUsersExcludingGuests": "Tous les utilisateurs à l’exception des invités", + "LabelAllUsersIncludingGuests": "Tous les utilisateurs, y compris les invités", "LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque", "LabelAppend": "Ajouter", "LabelAuthor": "Auteur", @@ -199,29 +199,29 @@ "LabelAuthorLastFirst": "Auteur (Nom, Prénom)", "LabelAuthors": "Auteurs", "LabelAutoDownloadEpisodes": "Téléchargement automatique d’épisode", - "LabelAutoFetchMetadata": "Auto Fetch Metadata", - "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", - "LabelAutoLaunch": "Auto Launch", - "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path /login?autoLaunch=0)", - "LabelAutoRegister": "Auto Register", - "LabelAutoRegisterDescription": "Automatically create new users after logging in", - "LabelBackToUser": "Revenir à l’Utilisateur", - "LabelBackupLocation": "Backup Location", + "LabelAutoFetchMetadata": "Recherche automatique de métadonnées", + "LabelAutoFetchMetadataHelp": "Récupère les métadonnées du titre, de l’auteur et de la série pour simplifier le téléchargement. Il se peut que des métadonnées supplémentaires doivent être ajoutées après le téléchargement.", + "LabelAutoLaunch": "Lancement automatique", + "LabelAutoLaunchDescription": "Redirection automatique vers le fournisseur d'authentification lors de la navigation vers la page de connexion (chemin de remplacement manuel /login?autoLaunch=0)", + "LabelAutoRegister": "Enregistrement automatique", + "LabelAutoRegisterDescription": "Créer automatiquement de nouveaux utilisateurs après la connexion", + "LabelBackToUser": "Retour à l’utilisateur", + "LabelBackupLocation": "Emplacement de la sauvegarde", "LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques", - "LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes Enregistrées dans /metadata/backups", + "LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes enregistrées dans /metadata/backups", "LabelBackupsMaxBackupSize": "Taille maximale de la sauvegarde (en Go)", "LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.", - "LabelBackupsNumberToKeep": "Nombre de sauvegardes à maintenir", - "LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.", + "LabelBackupsNumberToKeep": "Nombre de sauvegardes à conserver", + "LabelBackupsNumberToKeepHelp": "Seule une sauvegarde sera supprimée à la fois. Si vous avez déjà plus de sauvegardes à effacer, vous devez les supprimer manuellement.", "LabelBitrate": "Bitrate", "LabelBooks": "Livres", - "LabelButtonText": "Button Text", + "LabelButtonText": "Texte du bouton", "LabelChangePassword": "Modifier le mot de passe", "LabelChannels": "Canaux", "LabelChapters": "Chapitres", - "LabelChaptersFound": "Chapitres trouvés", - "LabelChapterTitle": "Titres du chapitre", - "LabelClickForMoreInfo": "Click for more info", + "LabelChaptersFound": "chapitres trouvés", + "LabelChapterTitle": "Titre du chapitre", + "LabelClickForMoreInfo": "Cliquez ici pour plus d’informations", "LabelClosePlayer": "Fermer le lecteur", "LabelCodec": "Codec", "LabelCollapseSeries": "Réduire les séries", @@ -235,20 +235,20 @@ "LabelCover": "Couverture", "LabelCoverImageURL": "URL vers l’image de couverture", "LabelCreatedAt": "Créé le", - "LabelCronExpression": "Expression Cron", - "LabelCurrent": "Courrant", - "LabelCurrently": "En ce moment :", - "LabelCustomCronExpression": "Expression cron personnalisée:", - "LabelDatetime": "Datetime", - "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", + "LabelCronExpression": "Expression cron", + "LabelCurrent": "Actuel", + "LabelCurrently": "Actuellement :", + "LabelCustomCronExpression": "Expression cron personnalisée :", + "LabelDatetime": "Datetime", // need review with context + "LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)", "LabelDescription": "Description", "LabelDeselectAll": "Tout déselectionner", "LabelDevice": "Appareil", "LabelDeviceInfo": "Détail de l’appareil", - "LabelDeviceIsAvailableTo": "Device is available to...", + "LabelDeviceIsAvailableTo": "L’appareil est disponible pour…", "LabelDirectory": "Répertoire", - "LabelDiscFromFilename": "Disque depuis le fichier", - "LabelDiscFromMetadata": "Disque depuis les métadonnées", + "LabelDiscFromFilename": "Disque à partir du fichier", // need review with context + "LabelDiscFromMetadata": "Disque à partir des métadonnées", // need review with context "LabelDiscover": "Découvrir", "LabelDownload": "Téléchargement", "LabelDownloadNEpisodes": "Télécharger {0} épisode(s)", @@ -271,17 +271,17 @@ "LabelExample": "Exemple", "LabelExplicit": "Restriction", "LabelFeedURL": "URL du flux", - "LabelFetchingMetadata": "Fetching Metadata", + "LabelFetchingMetadata": "Récupération des métadonnées", "LabelFile": "Fichier", "LabelFileBirthtime": "Création du fichier", "LabelFileModified": "Modification du fichier", "LabelFilename": "Nom de fichier", - "LabelFilterByUser": "Filtrer par l’utilisateur", + "LabelFilterByUser": "Filtrer par utilisateur", "LabelFindEpisodes": "Trouver des épisodes", - "LabelFinished": "Fini(e)", + "LabelFinished": "Terminé", // need review with context "LabelFolder": "Dossier", "LabelFolders": "Dossiers", - "LabelFontFamily": "Famille de polices", + "LabelFontFamily": "Polices de caractères", "LabelFontScale": "Taille de la police de caractère", "LabelFormat": "Format", "LabelGenre": "Genre", @@ -289,16 +289,16 @@ "LabelHardDeleteFile": "Suppression du fichier", "LabelHasEbook": "Dispose d’un livre numérique", "LabelHasSupplementaryEbook": "Dispose d’un livre numérique supplémentaire", - "LabelHighestPriority": "Highest priority", + "LabelHighestPriority": "Priorité la plus élevée", "LabelHost": "Hôte", "LabelHour": "Heure", - "LabelIcon": "Icone", - "LabelImageURLFromTheWeb": "Image URL from the web", - "LabelIncludeInTracklist": "Inclure dans la liste des pistes", + "LabelIcon": "Icône", + "LabelImageURLFromTheWeb": "URL de l’image à partir du web", + "LabelIncludeInTracklist": "Inclure dans la liste de lecture", "LabelIncomplete": "Incomplet", "LabelInProgress": "En cours", "LabelInterval": "Intervalle", - "LabelIntervalCustomDailyWeekly": "Journalier / Hebdomadaire personnalisé", + "LabelIntervalCustomDailyWeekly": "Personnaliser quotidiennement / hebdomadairement", "LabelIntervalEvery12Hours": "Toutes les 12 heures", "LabelIntervalEvery15Minutes": "Toutes les 15 minutes", "LabelIntervalEvery2Hours": "Toutes les 2 heures", @@ -331,22 +331,22 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date", - "LabelLowestPriority": "Lowest Priority", - "LabelMatchExistingUsersBy": "Match existing users by", - "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", + "LabelLowestPriority": "Priorité la plus basse", + "LabelMatchExistingUsersBy": "Faire correspondre les utilisateurs existants par", + "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", - "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", + "LabelMetadataOrderOfPrecedenceDescription": "Les sources de métadonnées ayant une priorité plus élevée auront la priorité sur celles ayant une priorité moins élevée.", "LabelMetadataProvider": "Fournisseur de métadonnées", - "LabelMetaTag": "Etiquette de métadonnée", - "LabelMetaTags": "Etiquettes de métadonnée", + "LabelMetaTag": "Balise de métadonnée", + "LabelMetaTags": "Balises de métadonnée", "LabelMinute": "Minute", "LabelMissing": "Manquant", "LabelMissingParts": "Parties manquantes", - "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", - "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", + "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", - "LabelMoreInfo": "Plus d’info", + "LabelMoreInfo": "Plus d’informations", "LabelName": "Nom", "LabelNarrator": "Narrateur", "LabelNarrators": "Narrateurs", @@ -358,7 +358,7 @@ "LabelNextScheduledRun": "Prochain lancement prévu", "LabelNoEpisodesSelected": "Aucun épisode sélectionné", "LabelNotes": "Notes", - "LabelNotFinished": "Non terminé(e)", + "LabelNotFinished": "Non terminé", "LabelNotificationAppriseURL": "URL(s) d’Apprise", "LabelNotificationAvailableVariables": "Variables disponibles", "LabelNotificationBodyTemplate": "Modèle de Message", @@ -367,10 +367,10 @@ "LabelNotificationsMaxFailedAttemptsHelp": "La notification est abandonnée une fois ce seuil atteint", "LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente", "LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Les notifications seront ignorées si la file d’attente est à son maximum. Cela empêche un flot trop important.", - "LabelNotificationTitleTemplate": "Modèle de Titre", - "LabelNotStarted": "Non Démarré(e)", - "LabelNumberOfBooks": "Nombre de Livres", - "LabelNumberOfEpisodes": "Nombre d’Episodes", + "LabelNotificationTitleTemplate": "Modèle de titre", + "LabelNotStarted": "Pas commencé", + "LabelNumberOfBooks": "Nombre de livres", + "LabelNumberOfEpisodes": "Nombre d’épisodes", "LabelOpenRSSFeed": "Ouvrir le flux RSS", "LabelOverwrite": "Écraser", "LabelPassword": "Mot de passe", @@ -411,7 +411,7 @@ "LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé", "LabelRSSFeedOpen": "Flux RSS ouvert", "LabelRSSFeedPreventIndexing": "Empêcher l’indexation", - "LabelRSSFeedSlug": "Identificateur d’adresse du Flux RSS ", + "LabelRSSFeedSlug": "Balise URL du flux RSS", "LabelRSSFeedURL": "Adresse du flux RSS", "LabelSearchTerm": "Terme de recherche", "LabelSearchTitle": "Titre de recherche", @@ -419,8 +419,8 @@ "LabelSeason": "Saison", "LabelSelectAllEpisodes": "Sélectionner tous les épisodes", "LabelSelectEpisodesShowing": "Sélectionner {0} episode(s) en cours", - "LabelSelectUsers": "Select users", - "LabelSendEbookToDevice": "Envoyer le livre numérique à...", + "LabelSelectUsers": "Sélectionner les utilisateurs", + "LabelSendEbookToDevice": "Envoyer le livre numérique à…", "LabelSequence": "Séquence", "LabelSeries": "Séries", "LabelSeriesName": "Nom de la série", @@ -447,13 +447,13 @@ "LabelSettingsHomePageBookshelfView": "La page d’accueil utilise la vue étagère", "LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère", "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 « - »
i.e. « Titre du Livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »", + "LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du livre audio.
Les sous-titres doivent être séparés par « - »
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", "LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri", - "LabelSettingsSortingIgnorePrefixesHelp": "i.e. pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »", + "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", @@ -461,30 +461,30 @@ "LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles", "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items", "LabelSettingsTimeFormat": "Format d’heure", - "LabelShowAll": "Afficher Tout", + "LabelShowAll": "Tout afficher", "LabelSize": "Taille", "LabelSleepTimer": "Minuterie", - "LabelSlug": "Slug", + "LabelSlug": "Balise", "LabelStart": "Démarrer", "LabelStarted": "Démarré", "LabelStartedAt": "Démarré à", - "LabelStartTime": "Heure de Démarrage", + "LabelStartTime": "Heure de démarrage", "LabelStatsAudioTracks": "Pistes Audios", "LabelStatsAuthors": "Auteurs", - "LabelStatsBestDay": "Meilleur Jour", - "LabelStatsDailyAverage": "Moyenne Journalière", + "LabelStatsBestDay": "Meilleur jour", + "LabelStatsDailyAverage": "Moyenne journalière", "LabelStatsDays": "Jours", "LabelStatsDaysListened": "Jours d’écoute", "LabelStatsHours": "Heures", - "LabelStatsInARow": "d’affilé(s)", + "LabelStatsInARow": "d’affilée(s)", "LabelStatsItemsFinished": "Articles terminés", - "LabelStatsItemsInLibrary": "Articles dans la Bibliothèque", + "LabelStatsItemsInLibrary": "Articles dans la bibliothèque", "LabelStatsMinutes": "minutes", "LabelStatsMinutesListening": "Minutes d’écoute", - "LabelStatsOverallDays": "Jours au total", - "LabelStatsOverallHours": "Heures au total", + "LabelStatsOverallDays": "Nombre total de jours", + "LabelStatsOverallHours": "Nombre total d'heures", "LabelStatsWeekListening": "Écoute de la semaine", - "LabelSubtitle": "Sous-Titre", + "LabelSubtitle": "Sous-titre", "LabelSupportedFileTypes": "Types de fichiers supportés", "LabelTag": "Étiquette", "LabelTags": "Étiquettes", @@ -496,23 +496,23 @@ "LabelThemeLight": "Clair", "LabelTimeBase": "Base de temps", "LabelTimeListened": "Temps d’écoute", - "LabelTimeListenedToday": "Nombres d’écoutes Aujourd’hui", + "LabelTimeListenedToday": "Nombres d’écoutes aujourd’hui", "LabelTimeRemaining": "{0} restantes", "LabelTimeToShift": "Temps de décalage en secondes", "LabelTitle": "Titre", - "LabelToolsEmbedMetadata": "Métadonnées Intégrées", + "LabelToolsEmbedMetadata": "Métadonnées intégrées", "LabelToolsEmbedMetadataDescription": "Intègre les métadonnées au fichier audio avec la couverture et les chapitres.", - "LabelToolsMakeM4b": "Créer un fichier Livre Audio M4B", - "LabelToolsMakeM4bDescription": "Génère un fichier Livre Audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.", + "LabelToolsMakeM4b": "Créer un fichier livre audio M4B", + "LabelToolsMakeM4bDescription": "Génère un fichier livre audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.", "LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3", "LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, l’image de couverture et les chapitres.", - "LabelTotalDuration": "Durée Totale", + "LabelTotalDuration": "Durée totale", "LabelTotalTimeListened": "Temps d’écoute total", "LabelTrackFromFilename": "Piste depuis le fichier", "LabelTrackFromMetadata": "Piste depuis les métadonnées", "LabelTracks": "Pistes", "LabelTracksMultiTrack": "Piste multiple", - "LabelTracksNone": "No tracks", + "LabelTracksNone": "Aucune piste", "LabelTracksSingleTrack": "Piste simple", "LabelType": "Type", "LabelUnabridged": "Version intégrale", @@ -524,9 +524,9 @@ "LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée", "LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers", "LabelUploaderDropFiles": "Déposer des fichiers", - "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", + "LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, l’auteur et la série", "LabelUseChapterTrack": "Utiliser la piste du chapitre", - "LabelUseFullTrack": "Utiliser la piste Complète", + "LabelUseFullTrack": "Utiliser la piste complète", "LabelUser": "Utilisateur", "LabelUsername": "Nom d’utilisateur", "LabelValue": "Valeur", @@ -545,10 +545,10 @@ "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.", "MessageBookshelfNoCollections": "Vous n’avez pas encore de collections", - "MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0}: {1} »", + "MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0} : {1} »", "MessageBookshelfNoRSSFeeds": "Aucun flux RSS n’est ouvert", "MessageBookshelfNoSeries": "Vous n’avez aucune série", - "MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio", + "MessageChapterEndIsAfter": "La fin du chapitre se situe après la fin de votre livre audio.", "MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0", "MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre", "MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre", @@ -558,15 +558,15 @@ "MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la sauvegarde de « {0} » ?", "MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?", "MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?", - "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", - "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItem": "Cette opération supprimera l’élément de la base de données et de votre système de fichiers. Êtes-vous sûr ?", + "MessageConfirmDeleteLibraryItems": "Cette opération supprimera {0} éléments de la base de données et de votre système de fichiers. Êtes-vous sûr ?", "MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?", "MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une analyse forcée ?", "MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?", "MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr de vouloir marquer tous les épisodes comme non terminés ?", "MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme terminées ?", "MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme comme non terminés ?", - "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files.

Would you like to continue?", + "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}\"?", "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?", @@ -581,16 +581,16 @@ "MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les articles ?", "MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.", "MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».", - "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", + "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} »?", "MessageDownloadingEpisode": "Téléchargement de l’épisode", - "MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l’ordre correct", + "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", - "MessageFeedURLWillBe": "l’URL du flux sera {0}", + "MessageFeedURLWillBe": "L’URL du flux sera {0}", "MessageFetching": "Récupération…", - "MessageForceReScanDescription": "Analysera tous les fichiers de nouveau. Les étiquettes ID3 des fichiers audios, fichiers OPF, et les fichiers textes seront analysés comme s’ils étaient nouveaux.", - "MessageImportantNotice": "Information Importante !", + "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", @@ -646,13 +646,13 @@ "MessageRemoveChapter": "Supprimer le chapitre", "MessageRemoveEpisodes": "Suppression de {0} épisode(s)", "MessageRemoveFromPlayerQueue": "Supprimer de la liste d’écoute", - "MessageRemoveUserWarning": "Êtes-vous certain de vouloir supprimer définitivement l’utilisateur « {0} » ?", + "MessageRemoveUserWarning": "Êtes-vous sûr de vouloir supprimer définitivement l’utilisateur « {0} » ?", "MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur", - "MessageResetChaptersConfirm": "Êtes-vous certain de vouloir réinitialiser les chapitres et annuler les changements effectués ?", - "MessageRestoreBackupConfirm": "Êtes-vous certain de vouloir restaurer la sauvegarde créée le", + "MessageResetChaptersConfirm": "Êtes-vous sûr de vouloir réinitialiser les chapitres et annuler les changements effectués ?", + "MessageRestoreBackupConfirm": "Êtes-vous sûr de vouloir restaurer la sauvegarde créée le", "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.

Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.

Tous les clients utilisant votre serveur seront automatiquement mis à jour.", "MessageSearchResultsFor": "Résultats de recherche pour", - "MessageSelected": "{0} selected", + "MessageSelected": "{0} sélectionnés", "MessageServerCouldNotBeReached": "Serveur inaccessible", "MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre", "MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?", @@ -663,10 +663,10 @@ "MessageValidCronExpression": "Expression cron valide", "MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur", "MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !", - "MessageYourAudiobookDurationIsLonger": "La durée de votre Livre Audio est plus longue que la durée trouvée", - "MessageYourAudiobookDurationIsShorter": "La durée de votre Livre Audio est plus courte que la durée trouvée", + "MessageYourAudiobookDurationIsLonger": "La durée de votre livre audio est plus longue que la durée trouvée", + "MessageYourAudiobookDurationIsShorter": "La durée de votre livre audio est plus courte que la durée trouvée", "NoteChangeRootPassword": "seul l’utilisateur « root » peut utiliser un mot de passe vide", - "NoteChapterEditorTimes": "Information : l’horodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du Livre Audio.", + "NoteChapterEditorTimes": "Information : l’horodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du livre audio.", "NoteFolderPicker": "Information : Les dossiers déjà surveillés ne sont pas affichés", "NoteFolderPickerDebian": "Information : La sélection de dossier sur une installation debian n’est pas finalisée. Merci de renseigner le chemin complet vers votre bibliothèque manuellement.", "NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux en HTTPS.", @@ -677,8 +677,8 @@ "PlaceholderNewCollection": "Nom de la nouvelle collection", "PlaceholderNewFolderPath": "Nouveau chemin de dossier", "PlaceholderNewPlaylist": "Nouveau nom de liste de lecture", - "PlaceholderSearch": "Recherche...", - "PlaceholderSearchEpisode": "Recherche d’épisode...", + "PlaceholderSearch": "Recherche…", + "PlaceholderSearchEpisode": "Recherche d’épisode…", "ToastAccountUpdateFailed": "Échec de la mise à jour du compte", "ToastAccountUpdateSuccess": "Compte mis à jour", "ToastAuthorImageRemoveFailed": "Échec de la suppression de l’image", @@ -750,4 +750,4 @@ "ToastSocketFailedToConnect": "Échec de la connexion WebSocket", "ToastUserDeleteFailed": "Échec de la suppression de l’utilisateur", "ToastUserDeleteSuccess": "Utilisateur supprimé" -} \ No newline at end of file +} From baa65b8155aa21468650f5859c4dc42ece478a6b Mon Sep 17 00:00:00 2001 From: Benjamin Porter Date: Wed, 3 Jan 2024 13:55:43 -0700 Subject: [PATCH 02/15] Add tini as PID 1 handler in container image This PR adds `tini` to the container image and uses it as PID 1 when starting the container. This ensures that proper PID 1 signal-handling is implemented and passed to the underlying node.js process, thereby ensuring that the ABS process has a chance to receive and handle signals other than `SIGKILL`, such as the important `SIGINT`. This is somewhat related to #2445 . Without this, the signal handled by 2445 won't be received when running in a container. Some background: In linux, PID 1 has special duties involving signal handling that are different than other processes. Node doesn't properly handle these signals, which can lead to a number of problems ranging from annoying to disruptive. PID 1 also has reaping duties that can lead to resource exhaustion if not properly handled. For example, the container ignores `SIGINT` (Ctrl+C) as well as `docker stop`, which can be annoying in development as you have to kill or wait for the timeout to be reached. In a production environment (such as Kubernetes) this can lead to signal escalation and unnecessarily adds delays to deployments and restarts as K8s has to wait for the timeout to be reached before sending `SIGKILL`. At best this is annoying and unnecessarily adds delays. At worst this can lead to file/data corruption as the process doesn't get a chance to clean anything up when it is sent `SIGKILL`. Without a proper PID 1 to forward signals, only SIGKILL can be used to terminate the running process. --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 254720004c..943fc5674a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,8 @@ RUN apk update && \ ffmpeg \ make \ python3 \ - g++ + g++ \ + tini COPY --from=tone /usr/local/bin/tone /usr/local/bin/ COPY --from=build /client/dist /client/dist @@ -31,4 +32,5 @@ RUN apk del make python3 g++ EXPOSE 80 +ENTRYPOINT ["tini", "--"] CMD ["node", "index.js"] From 9f909b0d85c6c6961bb7da80a7f2bdce265f07d6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 3 Jan 2024 16:23:17 -0600 Subject: [PATCH 03/15] Update:Library folder browser to also work for debian and windows --- .../modals/libraries/EditLibrary.vue | 2 +- ...olderChooser.vue => LazyFolderChooser.vue} | 107 ++++++++++++------ server/controllers/FileSystemController.js | 60 ++++++++-- server/routers/ApiRouter.js | 29 ----- server/utils/fileUtils.js | 63 +++++++++++ 5 files changed, 183 insertions(+), 78 deletions(-) rename client/components/modals/libraries/{FolderChooser.vue => LazyFolderChooser.vue} (58%) diff --git a/client/components/modals/libraries/EditLibrary.vue b/client/components/modals/libraries/EditLibrary.vue index 598f3bcd42..d29d792987 100644 --- a/client/components/modals/libraries/EditLibrary.vue +++ b/client/components/modals/libraries/EditLibrary.vue @@ -31,7 +31,7 @@ {{ $strings.ButtonBrowseForFolder }} - + diff --git a/client/components/modals/libraries/FolderChooser.vue b/client/components/modals/libraries/LazyFolderChooser.vue similarity index 58% rename from client/components/modals/libraries/FolderChooser.vue rename to client/components/modals/libraries/LazyFolderChooser.vue index 6383d10283..0254f76050 100644 --- a/client/components/modals/libraries/FolderChooser.vue +++ b/client/components/modals/libraries/LazyFolderChooser.vue @@ -4,29 +4,32 @@ arrow_back

{{ $strings.HeaderChooseAFolder }}

-
-

{{ selectedPath || '\\' }}

+
+

{{ selectedPath || '/' }}

-
+
-
+
folder

..

-
+
folder

{{ dir.dirname }}

- arrow_right + arrow_right
-
+
folder

{{ dir.dirname }}

+
+ +
-
+

{{ $strings.MessageLoadingFolders }}

@@ -51,11 +54,12 @@ export default { }, data() { return { - loadingFolders: false, - allFolders: [], + initialLoad: false, + loadingDirs: false, + isPosix: true, + rootDirs: [], directories: [], selectedPath: '', - selectedFullPath: '', subdirs: [], level: 0, currentDir: null, @@ -98,59 +102,88 @@ export default { } }, methods: { - goBack() { - var splitPaths = this.selectedPath.split('\\').slice(1) - var prev = splitPaths.slice(0, -1).join('\\') + async goBack() { + let selPath = this.selectedPath.replace(/^\//, '') + var splitPaths = selPath.split('/') + + let previousPath = '' + let lookupPath = '' + + if (splitPaths.length > 2) { + lookupPath = splitPaths.slice(0, -2).join('/') + } + previousPath = splitPaths.slice(0, -1).join('/') - var currDirs = this.allFolders - for (let i = 0; i < splitPaths.length; i++) { - var _dir = currDirs.find((dir) => dir.dirname === splitPaths[i]) - if (_dir && _dir.path.slice(1) === prev) { - this.directories = currDirs - this.selectDir(_dir) - return - } else if (_dir) { - currDirs = _dir.dirs + if (!this.isPosix) { + // For windows drives add a trailing slash. e.g. C:/ + if (!this.isPosix && lookupPath.endsWith(':')) { + lookupPath += '/' } + if (!this.isPosix && previousPath.endsWith(':')) { + previousPath += '/' + } + } else { + // Add leading slash + if (previousPath) previousPath = '/' + previousPath + if (lookupPath) lookupPath = '/' + lookupPath } + + this.level-- + this.subdirs = this.directories + this.selectedPath = previousPath + this.directories = await this.fetchDirs(lookupPath, this.level) }, - selectDir(dir) { + async selectDir(dir) { if (dir.isUsed) return this.selectedPath = dir.path - this.selectedFullPath = dir.fullPath this.level = dir.level - this.subdirs = dir.dirs + this.subdirs = await this.fetchDirs(dir.path, dir.level + 1) }, - selectSubDir(dir) { + async selectSubDir(dir) { if (dir.isUsed) return this.selectedPath = dir.path - this.selectedFullPath = dir.fullPath this.level = dir.level this.directories = this.subdirs - this.subdirs = dir.dirs + this.subdirs = await this.fetchDirs(dir.path, dir.level + 1) }, selectFolder() { if (!this.selectedPath) { console.error('No Selected path') return } - if (this.paths.find((p) => p.startsWith(this.selectedFullPath))) { + if (this.paths.find((p) => p.startsWith(this.selectedPath))) { this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`) return } - this.$emit('select', this.selectedFullPath) + this.$emit('select', this.selectedPath) this.selectedPath = '' - this.selectedFullPath = '' + }, + fetchDirs(path, level) { + this.loadingDirs = true + return this.$axios + .$get(`/api/filesystem?path=${path}&level=${level}`) + .then((data) => { + console.log('Fetched directories', data.directories) + this.isPosix = !!data.posix + return data.directories + }) + .catch((error) => { + console.error('Failed to get filesystem paths', error) + this.$toast.error('Failed to get filesystem paths') + return [] + }) + .finally(() => { + this.loadingDirs = false + }) }, async init() { - this.loadingFolders = true - this.allFolders = await this.$store.dispatch('libraries/loadFolders') - this.loadingFolders = false + this.initialLoad = true + this.rootDirs = await this.fetchDirs('', 0) + this.initialLoad = false - this.directories = this.allFolders + this.directories = this.rootDirs this.subdirs = [] this.selectedPath = '' - this.selectedFullPath = '' } }, mounted() { diff --git a/server/controllers/FileSystemController.js b/server/controllers/FileSystemController.js index cee52cb290..88459e51af 100644 --- a/server/controllers/FileSystemController.js +++ b/server/controllers/FileSystemController.js @@ -1,31 +1,69 @@ const Path = require('path') const Logger = require('../Logger') -const Database = require('../Database') const fs = require('../libs/fsExtra') +const { toNumber } = require('../utils/index') +const fileUtils = require('../utils/fileUtils') class FileSystemController { constructor() { } + /** + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async getPaths(req, res) { if (!req.user.isAdminOrUp) { Logger.error(`[FileSystemController] Non-admin user attempting to get filesystem paths`, req.user) return res.sendStatus(403) } - const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => { - return Path.sep + dirname - }) + const relpath = req.query.path + const level = toNumber(req.query.level, 0) + + // Validate path. Must be absolute + if (relpath && (!Path.isAbsolute(relpath) || !await fs.pathExists(relpath))) { + Logger.error(`[FileSystemController] Invalid path in query string "${relpath}"`) + return res.status(400).send('Invalid "path" query string') + } + Logger.debug(`[FileSystemController] Getting file paths at ${relpath || 'root'} (${level})`) + + let directories = [] - // Do not include existing mapped library paths in response - const libraryFoldersPaths = await Database.libraryFolderModel.getAllLibraryFolderPaths() - libraryFoldersPaths.forEach((path) => { - let dir = path || '' - if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '') - excludedDirs.push(dir) + // Windows returns drives first + if (global.isWin) { + if (relpath) { + directories = await fileUtils.getDirectoriesInPath(relpath, level) + } else { + const drives = await fileUtils.getWindowsDrives().catch((error) => { + Logger.error(`[FileSystemController] Failed to get windows drives`, error) + return [] + }) + if (drives.length) { + directories = drives.map(d => { + return { + path: d, + dirname: d, + level: 0 + } + }) + } + } + } else { + directories = await fileUtils.getDirectoriesInPath(relpath || '/', level) + } + + // Exclude some dirs from this project to be cleaner in Docker + const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc', '.devcontainer', '.nyc_output', '.github', '.vscode'].map(dirname => { + return fileUtils.filePathToPOSIX(Path.join(global.appRoot, dirname)) + }) + directories = directories.filter(dir => { + return !excludedDirs.includes(dir.path) }) res.json({ - directories: await this.getDirectories(global.appRoot, '/', excludedDirs) + posix: !global.isWin, + directories }) } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 3edce256f4..2956cd520d 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -320,35 +320,6 @@ class ApiRouter { this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this)) } - async getDirectories(dir, relpath, excludedDirs, level = 0) { - try { - const paths = await fs.readdir(dir) - - let dirs = await Promise.all(paths.map(async dirname => { - const fullPath = Path.join(dir, dirname) - const path = Path.join(relpath, dirname) - - const isDir = (await fs.lstat(fullPath)).isDirectory() - if (isDir && !excludedDirs.includes(path) && dirname !== 'node_modules') { - return { - path, - dirname, - fullPath, - level, - dirs: level < 4 ? (await this.getDirectories(fullPath, path, excludedDirs, level + 1)) : [] - } - } else { - return false - } - })) - dirs = dirs.filter(d => d) - return dirs - } catch (error) { - Logger.error('Failed to readdir', dir, error) - return [] - } - } - // // Helper Methods // diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 89ad9e60ae..14e4d743cf 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -1,6 +1,7 @@ const axios = require('axios') const Path = require('path') const ssrfFilter = require('ssrf-req-filter') +const exec = require('child_process').exec const fs = require('../libs/fsExtra') const rra = require('../libs/recursiveReaddirAsync') const Logger = require('../Logger') @@ -378,3 +379,65 @@ module.exports.isWritable = async (directory) => { } } +/** + * Get Windows drives as array e.g. ["C:/", "F:/"] + * + * @returns {Promise} + */ +module.exports.getWindowsDrives = async () => { + if (!global.isWin) { + return [] + } + return new Promise((resolve, reject) => { + exec('wmic logicaldisk get name', async (error, stdout, stderr) => { + if (error) { + reject(error) + return + } + 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 + '/' + if (await fs.pathExists(drivepath)) { + validDrives.push(drivepath) + } else { + Logger.error(`Invalid drive ${drivepath}`) + } + } + resolve(validDrives) + }) + }) +} + +/** + * Get array of directory paths in a directory + * + * @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) + + 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 + } + })) + dirs = dirs.filter(d => d) + return dirs + } catch (error) { + Logger.error('Failed to readdir', dirPath, error) + return [] + } +} \ No newline at end of file From ffa7cc0d22dbe42457745083b8cdafaa0c93640c Mon Sep 17 00:00:00 2001 From: Machou Date: Fri, 5 Jan 2024 07:19:07 +0100 Subject: [PATCH 04/15] Update fr.json --- client/strings/fr.json | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index 3db4b43a88..e570614d04 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -101,7 +101,7 @@ "HeaderChapters": "Chapitres", "HeaderChooseAFolder": "Choisir un dossier", "HeaderCollection": "Collection", - "HeaderCollectionItems": "Entrées de la Collection", + "HeaderCollectionItems": "Entrées de la collection", "HeaderCover": "Couverture", "HeaderCurrentDownloads": "Téléchargements en cours", "HeaderDetails": "Détails", @@ -114,10 +114,10 @@ "HeaderEreaderSettings": "Options Ereader", "HeaderFiles": "Fichiers", "HeaderFindChapters": "Trouver les chapitres", - "HeaderIgnoredFiles": "Fichiers Ignorés", - "HeaderItemFiles": "Fichiers des Articles", + "HeaderIgnoredFiles": "Fichiers ignorés", + "HeaderItemFiles": "Fichiers des articles", "HeaderItemMetadataUtils": "Outils de gestion des métadonnées", - "HeaderLastListeningSession": "Dernière Session d’écoute", + "HeaderLastListeningSession": "Dernière session d’écoute", "HeaderLatestEpisodes": "Dernier épisodes", "HeaderLibraries": "Bibliothèque", "HeaderLibraryFiles": "Fichier de bibliothèque", @@ -239,7 +239,7 @@ "LabelCurrent": "Actuel", "LabelCurrently": "Actuellement :", "LabelCustomCronExpression": "Expression cron personnalisée :", - "LabelDatetime": "Datetime", // need review with context + "LabelDatetime": "Date", "LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)", "LabelDescription": "Description", "LabelDeselectAll": "Tout déselectionner", @@ -247,8 +247,8 @@ "LabelDeviceInfo": "Détail de l’appareil", "LabelDeviceIsAvailableTo": "L’appareil est disponible pour…", "LabelDirectory": "Répertoire", - "LabelDiscFromFilename": "Disque à partir du fichier", // need review with context - "LabelDiscFromMetadata": "Disque à partir des métadonnées", // need review with context + "LabelDiscFromFilename": "Depuis le fichier", + "LabelDiscFromMetadata": "Depuis les métadonnées", "LabelDiscover": "Découvrir", "LabelDownload": "Téléchargement", "LabelDownloadNEpisodes": "Télécharger {0} épisode(s)", @@ -278,7 +278,7 @@ "LabelFilename": "Nom de fichier", "LabelFilterByUser": "Filtrer par utilisateur", "LabelFindEpisodes": "Trouver des épisodes", - "LabelFinished": "Terminé", // need review with context + "LabelFinished": "Terminé le", "LabelFolder": "Dossier", "LabelFolders": "Dossiers", "LabelFontFamily": "Polices de caractères", @@ -406,7 +406,7 @@ "LabelRegion": "Région", "LabelReleaseDate": "Date de parution", "LabelRemoveCover": "Supprimer la couverture", - "LabelRowsPerPage": "Rows per page", + "LabelRowsPerPage": "Lignes par page", "LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé", "LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé", "LabelRSSFeedOpen": "Flux RSS ouvert", @@ -428,18 +428,18 @@ "LabelSetEbookAsPrimary": "Définir comme principale", "LabelSetEbookAsSupplementary": "Définir comme supplémentaire", "LabelSettingsAudiobooksOnly": "Livres audios seulement", - "LabelSettingsAudiobooksOnlyHelp": "L’activation de ce paramètre ignorera les fichiers “ ebook ”, à moins qu’ils ne se trouvent dans un dossier de livres audio, auquel cas ils seront définis comme des livres numériques supplémentaires.", + "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", "LabelSettingsChromecastSupport": "Support du Chromecast", "LabelSettingsDateFormat": "Format de date", "LabelSettingsDisableWatcher": "Désactiver la surveillance", "LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque", - "LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque des modifications de fichiers sont détectées. *Nécessite le redémarrage du serveur", + "LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque des modifications de fichiers sont détectées. * nécessite le redémarrage du serveur", "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", + "LabelSettingsEnableWatcherHelp": "Active la mise à jour automatique automatique lorsque des modifications de fichiers sont détectées. * nécessite le redémarrage du serveur", "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.", + "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", @@ -503,7 +503,7 @@ "LabelToolsEmbedMetadata": "Métadonnées intégrées", "LabelToolsEmbedMetadataDescription": "Intègre les métadonnées au fichier audio avec la couverture et les chapitres.", "LabelToolsMakeM4b": "Créer un fichier livre audio M4B", - "LabelToolsMakeM4bDescription": "Génère un fichier livre audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.", + "LabelToolsMakeM4bDescription": "Générer un fichier de livre audio .M4B avec des métadonnées intégrées, une image de couverture et des chapitres.", "LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3", "LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, l’image de couverture et les chapitres.", "LabelTotalDuration": "Durée totale", @@ -541,7 +541,7 @@ "LabelYourPlaylists": "Vos listes de lecture", "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.", + "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.", "MessageBookshelfNoCollections": "Vous n’avez pas encore de collections", @@ -650,7 +650,7 @@ "MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur", "MessageResetChaptersConfirm": "Êtes-vous sûr de vouloir réinitialiser les chapitres et annuler les changements effectués ?", "MessageRestoreBackupConfirm": "Êtes-vous sûr de vouloir restaurer la sauvegarde créée le", - "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.

Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.

Tous les clients utilisant votre serveur seront automatiquement mis à jour.", + "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.

Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.

Tous les clients utilisant votre serveur seront automatiquement mis à jour.", "MessageSearchResultsFor": "Résultats de recherche pour", "MessageSelected": "{0} sélectionnés", "MessageServerCouldNotBeReached": "Serveur inaccessible", From 578a59063f1839cfd7b14f023d3c90c254bfb08a Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 5 Jan 2024 09:24:18 -0600 Subject: [PATCH 05/15] Update discord invite link --- .github/ISSUE_TEMPLATE/bug.yaml | 2 +- .github/ISSUE_TEMPLATE/config.yml | 2 +- client/pages/config/index.vue | 4 ++-- readme.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml index 1d4228109f..ca044b7117 100644 --- a/.github/ISSUE_TEMPLATE/bug.yaml +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -11,7 +11,7 @@ body: value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)." - type: markdown attributes: - value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug." + value: "### Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug." - type: markdown attributes: value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant." diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 63cb880552..2c6cc1912c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: false contact_links: - name: Discord - url: https://discord.gg/pJsjuNCKRq + url: https://discord.gg/HQgCbd6E75 about: Ask questions, get help troubleshooting, and join the Abs community here. - name: Matrix url: https://matrix.to/#/#audiobookshelf:matrix.org diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 12ce7b1e63..acc92ea5e4 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -178,9 +178,9 @@

{{ $strings.MessageJoinUsOn }} - discord + discord

- + Date: Fri, 5 Jan 2024 14:38:29 -0600 Subject: [PATCH 06/15] Fix:Refresh podcast episode table when new episodes are downloaded --- client/components/tables/podcast/LazyEpisodesTable.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/components/tables/podcast/LazyEpisodesTable.vue b/client/components/tables/podcast/LazyEpisodesTable.vue index b1fb03ac72..f2c6f34282 100644 --- a/client/components/tables/podcast/LazyEpisodesTable.vue +++ b/client/components/tables/podcast/LazyEpisodesTable.vue @@ -87,7 +87,7 @@ export default { watch: { libraryItem: { handler() { - this.init() + this.refresh() } } }, @@ -515,6 +515,10 @@ export default { filterSortChanged() { this.init() }, + refresh() { + this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) + this.init() + }, init() { this.destroyEpisodeComponents() this.totalEpisodes = this.episodesList.length From eaf6bf29cc7063080aa91f9a200de4ff834fce88 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 5 Jan 2024 14:39:25 -0600 Subject: [PATCH 07/15] Fix:Improve performance for podcast rss feed episodes modal for large rss feeds --- .../components/modals/podcast/EpisodeFeed.vue | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/client/components/modals/podcast/EpisodeFeed.vue b/client/components/modals/podcast/EpisodeFeed.vue index 4a1b4753df..b5d98a25a6 100644 --- a/client/components/modals/podcast/EpisodeFeed.vue +++ b/client/components/modals/podcast/EpisodeFeed.vue @@ -68,7 +68,9 @@ export default { selectAll: false, search: null, searchTimeout: null, - searchText: null + searchText: null, + downloadedEpisodeGuidMap: {}, + downloadedEpisodeUrlMap: {} } }, watch: { @@ -122,11 +124,13 @@ export default { }, methods: { getIsEpisodeDownloaded(episode) { - return this.itemEpisodes.some((downloadedEpisode) => { - if (episode.guid && downloadedEpisode.guid === episode.guid) return true - if (!downloadedEpisode.enclosure?.url) return false - return this.getCleanEpisodeUrl(downloadedEpisode.enclosure.url) === episode.cleanUrl - }) + if (episode.guid && !!this.downloadedEpisodeGuidMap[episode.guid]) { + return true + } + if (this.downloadedEpisodeUrlMap[episode.cleanUrl]) { + return true + } + return false }, /** * UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed. @@ -219,6 +223,14 @@ export default { }) }, init() { + this.downloadedEpisodeGuidMap = {} + this.downloadedEpisodeUrlMap = {} + + this.itemEpisodes.forEach((episode) => { + if (episode.guid) this.downloadedEpisodeGuidMap[episode.guid] = episode.id + if (episode.enclosure?.url) this.downloadedEpisodeUrlMap[this.getCleanEpisodeUrl(episode.enclosure.url)] = episode.id + }) + this.episodesCleaned = this.episodes .filter((ep) => ep.enclosure?.url) .map((_ep) => { From a426da534c89a33ff28e6299d690a82ab2fc2081 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 5 Jan 2024 14:45:25 -0600 Subject: [PATCH 08/15] Fix:Export OPML not escaping characters #2487 --- server/utils/generators/opmlGenerator.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/server/utils/generators/opmlGenerator.js b/server/utils/generators/opmlGenerator.js index 8cc3f7fbc2..8fb7c87cb4 100644 --- a/server/utils/generators/opmlGenerator.js +++ b/server/utils/generators/opmlGenerator.js @@ -1,4 +1,5 @@ const xml = require('../../libs/xml') +const escapeForXML = require('../../libs/xml/escapeForXML') /** * Generate OPML file string for podcasts in a library @@ -12,18 +13,18 @@ module.exports.generate = (podcasts, indent = true) => { if (!podcast.feedURL) return const feedAttributes = { type: 'rss', - text: podcast.title, - title: podcast.title, - xmlUrl: podcast.feedURL + text: escapeForXML(podcast.title), + title: escapeForXML(podcast.title), + xmlUrl: escapeForXML(podcast.feedURL) } if (podcast.description) { - feedAttributes.description = podcast.description + feedAttributes.description = escapeForXML(podcast.description) } if (podcast.itunesPageUrl) { - feedAttributes.htmlUrl = podcast.itunesPageUrl + feedAttributes.htmlUrl = escapeForXML(podcast.itunesPageUrl) } if (podcast.language) { - feedAttributes.language = podcast.language + feedAttributes.language = escapeForXML(podcast.language) } bodyItems.push({ outline: { From 935e545caa718a39b7b66d19471e956c35d9d7c1 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 6 Jan 2024 14:13:39 -0600 Subject: [PATCH 09/15] Update readme for iOS beta full --- readme.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index f649fb76cc..a3b84f00a4 100644 --- a/readme.md +++ b/readme.md @@ -45,7 +45,9 @@ Join us on [Discord](https://discord.gg/HQgCbd6E75) or [Matrix](https://matrix.t Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app) ### iOS App (beta) -Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join the discussion](https://github.com/advplyr/audiobookshelf-app/discussions/60) +**Beta is currently full. Apple has a hard limit of 10k beta testers. Updates will be posted in Discord/Matrix.** + +Using Test Flight: https://testflight.apple.com/join/wiic7QIW ***(beta is full)*** ### Build your own tools & clients Check out the [API documentation](https://api.audiobookshelf.org/) From e88c1fa32979bbfa45f98a8135394674dd2bc2fd Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 6 Jan 2024 15:54:48 -0600 Subject: [PATCH 10/15] Update:Show tooltip for library item card titles that are truncated #2451 - Refactored tooltip so that they dont overflow the window --- client/components/cards/LazyBookCard.vue | 13 +++- client/components/ui/Tooltip.vue | 77 ++++++++++++++++++------ 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index c4d1345da8..04b3ce5992 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -8,10 +8,10 @@
-
- {{ displayTitle }} + +

{{ displayTitle }}

-
+

{{ displayLineTwo || ' ' }}

{{ displaySortLine }}

@@ -164,6 +164,7 @@ export default { imageReady: false, selected: false, isSelectionMode: false, + displayTitleTruncated: false, showCoverBg: false } }, @@ -642,6 +643,12 @@ export default { } this.libraryItem = libraryItem + + this.$nextTick(() => { + if (this.$refs.displayTitle) { + this.displayTitleTruncated = this.$refs.displayTitle.scrollWidth > this.$refs.displayTitle.clientWidth + } + }) }, clickCard(e) { if (this.processing) return diff --git a/client/components/ui/Tooltip.vue b/client/components/ui/Tooltip.vue index c1eabfc66f..772455375e 100644 --- a/client/components/ui/Tooltip.vue +++ b/client/components/ui/Tooltip.vue @@ -15,6 +15,13 @@ export default { type: String, default: 'right' }, + /** + * Delay showing the tooltip after X milliseconds of hovering + */ + delayOnShow: { + type: Number, + default: 0 + }, disabled: Boolean }, data() { @@ -22,7 +29,8 @@ export default { tooltip: null, tooltipId: null, isShowing: false, - hideTimeout: null + hideTimeout: null, + delayOnShowTimeout: null } }, watch: { @@ -59,29 +67,44 @@ export default { this.tooltip = tooltip }, setTooltipPosition(tooltip) { - var boxChow = this.$refs.box.getBoundingClientRect() + const boxRect = this.$refs.box.getBoundingClientRect() + + const shouldMount = !tooltip.isConnected - var shouldMount = !tooltip.isConnected // Calculate size of tooltip if (shouldMount) document.body.appendChild(tooltip) - var { width, height } = tooltip.getBoundingClientRect() + const tooltipRect = tooltip.getBoundingClientRect() if (shouldMount) tooltip.remove() - var top = 0 - var left = 0 + // Subtracting scrollbar size + const windowHeight = window.innerHeight - 8 + const windowWidth = window.innerWidth - 8 + + let top = 0 + let left = 0 if (this.direction === 'right') { - top = boxChow.top - height / 2 + boxChow.height / 2 - left = boxChow.left + boxChow.width + 4 + top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2) + left = Math.max(0, boxRect.left + boxRect.width + 4) } else if (this.direction === 'bottom') { - top = boxChow.top + boxChow.height + 4 - left = boxChow.left - width / 2 + boxChow.width / 2 + top = Math.max(0, boxRect.top + boxRect.height + 4) + left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2) } else if (this.direction === 'top') { - top = boxChow.top - height - 4 - left = boxChow.left - width / 2 + boxChow.width / 2 + top = Math.max(0, boxRect.top - tooltipRect.height - 4) + left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2) } else if (this.direction === 'left') { - top = boxChow.top - height / 2 + boxChow.height / 2 - left = boxChow.left - width - 4 + top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2) + left = Math.max(0, boxRect.left - tooltipRect.width - 4) + } + + // Shift left if tooltip would overflow the window on the right + if (left + tooltipRect.width > windowWidth) { + left -= left + tooltipRect.width - windowWidth + } + // Shift up if tooltip would overflow the window on the bottom + if (top + tooltipRect.height > windowHeight) { + top -= top + tooltipRect.height - windowHeight } + tooltip.style.top = top + 'px' tooltip.style.left = left + 'px' }, @@ -107,15 +130,33 @@ export default { this.isShowing = false }, cancelHide() { - if (this.hideTimeout) clearTimeout(this.hideTimeout) + clearTimeout(this.hideTimeout) }, mouseover() { - if (!this.isShowing) this.showTooltip() + if (this.isShowing || this.disabled) return + + if (this.delayOnShow) { + if (this.delayOnShowTimeout) { + // Delay already running + return + } + + this.delayOnShowTimeout = setTimeout(() => { + this.showTooltip() + this.delayOnShowTimeout = null + }, this.delayOnShow) + } else { + this.showTooltip() + } }, mouseleave() { - if (this.isShowing) { - this.hideTimeout = setTimeout(this.hideTooltip, 100) + if (!this.isShowing) { + clearTimeout(this.delayOnShowTimeout) + this.delayOnShowTimeout = null + return } + + this.hideTimeout = setTimeout(this.hideTooltip, 100) } }, beforeDestroy() { From 4608f91ec610f7d639b4e5a3c81e7bcc2befaf29 Mon Sep 17 00:00:00 2001 From: Machou Date: Sun, 7 Jan 2024 02:41:16 +0100 Subject: [PATCH 11/15] Update fr.json --- client/strings/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index e570614d04..d894412c42 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -154,7 +154,7 @@ "HeaderSchedule": "Programmation", "HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque", "HeaderSession": "Session", - "HeaderSetBackupSchedule": "Activer la Sauvegarde Automatique", + "HeaderSetBackupSchedule": "Activer la sauvegarde automatique", "HeaderSettings": "Paramètres", "HeaderSettingsDisplay": "Affichage", "HeaderSettingsExperimental": "Fonctionnalités expérimentales", From 69e23ef9f2b4f1d23549e7bcf2eafc8b9c447c2c Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 7 Jan 2024 17:51:07 -0600 Subject: [PATCH 12/15] Add:Epub metadata parser and cover extractor #1479 --- .../libraries/LibraryScannerSettings.vue | 2 +- server/managers/CoverManager.js | 41 +++++++ server/scanner/AbsMetadataFileScanner.js | 2 + server/scanner/BookScanner.js | 107 ++++++++++++----- server/scanner/PodcastScanner.js | 1 - server/utils/parsers/parseEbookMetadata.js | 42 +++++++ server/utils/parsers/parseEpubMetadata.js | 109 ++++++++++++++++++ server/utils/parsers/parseOpfMetadata.js | 15 +-- 8 files changed, 284 insertions(+), 35 deletions(-) create mode 100644 server/utils/parsers/parseEbookMetadata.js create mode 100644 server/utils/parsers/parseEpubMetadata.js diff --git a/client/components/modals/libraries/LibraryScannerSettings.vue b/client/components/modals/libraries/LibraryScannerSettings.vue index 8ec73dd03c..43938f9cf9 100644 --- a/client/components/modals/libraries/LibraryScannerSettings.vue +++ b/client/components/modals/libraries/LibraryScannerSettings.vue @@ -63,7 +63,7 @@ export default { }, audioMetatags: { id: 'audioMetatags', - name: 'Audio file meta tags', + name: 'Audio file meta tags OR ebook metadata', include: true }, nfoFile: { diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 3cf97f3394..9b4aa32d09 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -7,6 +7,8 @@ const imageType = require('../libs/imageType') const globals = require('../utils/globals') const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils') const { extractCoverArt } = require('../utils/ffmpegHelpers') +const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') + const CacheManager = require('../managers/CacheManager') class CoverManager { @@ -234,6 +236,7 @@ class CoverManager { /** * Extract cover art from audio file and save for library item + * * @param {import('../models/Book').AudioFileObject[]} audioFiles * @param {string} libraryItemId * @param {string} [libraryItemPath] null for isFile library items @@ -268,6 +271,44 @@ class CoverManager { return null } + /** + * Extract cover art from ebook and save for library item + * + * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData + * @param {string} libraryItemId + * @param {string} [libraryItemPath] null for isFile library items + * @returns {Promise} returns cover path + */ + async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) { + if (!ebookFileScanData?.ebookCoverPath) return null + + let coverDirPath = null + if (global.ServerSettings.storeCoverWithItem && libraryItemPath) { + coverDirPath = libraryItemPath + } else { + coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId) + } + await fs.ensureDir(coverDirPath) + + let extname = Path.extname(ebookFileScanData.ebookCoverPath) || '.jpg' + if (extname === '.jpeg') extname = '.jpg' + const coverFilename = `cover${extname}` + const coverFilePath = Path.join(coverDirPath, coverFilename) + + // TODO: Overwrite if exists? + const coverAlreadyExists = await fs.pathExists(coverFilePath) + if (coverAlreadyExists) { + Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for "${coverFilePath}" - overwriting`) + } + + const success = await parseEbookMetadata.extractCoverImage(ebookFileScanData, coverFilePath) + if (success) { + await CacheManager.purgeCoverCache(libraryItemId) + return coverFilePath + } + return null + } + /** * * @param {string} url diff --git a/server/scanner/AbsMetadataFileScanner.js b/server/scanner/AbsMetadataFileScanner.js index 1f9d282301..e554dfb443 100644 --- a/server/scanner/AbsMetadataFileScanner.js +++ b/server/scanner/AbsMetadataFileScanner.js @@ -36,6 +36,8 @@ class AbsMetadataFileScanner { for (const key in abMetadata) { // TODO: When to override with null or empty arrays? if (abMetadata[key] === undefined || abMetadata[key] === null) continue + if (key === 'authors' && !abMetadata.authors?.length) continue + if (key === 'genres' && !abMetadata.genres?.length) continue if (key === 'tags' && !abMetadata.tags?.length) continue if (key === 'chapters' && !abMetadata.chapters?.length) continue diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index 6c93dddfd6..b40e9323aa 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -3,8 +3,8 @@ const Path = require('path') const sequelize = require('sequelize') const { LogLevel } = require('../utils/constants') const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index') -const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') const parseNameString = require('../utils/parsers/parseNameString') +const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const globals = require('../utils/globals') const AudioFileScanner = require('./AudioFileScanner') const Database = require('../Database') @@ -170,7 +170,9 @@ class BookScanner { hasMediaChanges = true } - const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, libraryItemData, libraryScan, librarySettings, existingLibraryItem.id) + const ebookFileScanData = await parseEbookMetadata.parse(media.ebookFile) + + const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings, existingLibraryItem.id) let authorsUpdated = false const bookAuthorsRemoved = [] let seriesUpdated = false @@ -317,24 +319,34 @@ class BookScanner { }) } - // If no cover then extract cover from audio file if available OR search for cover if enabled in server settings + // If no cover then extract cover from audio file OR from ebook + const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path if (!media.coverPath) { - const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path - const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir) + let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir) if (extractedCoverPath) { libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`) media.coverPath = extractedCoverPath hasMediaChanges = true - } else if (Database.serverSettings.scannerFindCovers) { - const authorName = media.authors.map(au => au.name).filter(au => au).join(', ') - const coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan) - if (coverPath) { - media.coverPath = coverPath + } else if (ebookFileScanData?.ebookCoverPath) { + extractedCoverPath = await CoverManager.saveEbookCoverArt(ebookFileScanData, existingLibraryItem.id, libraryItemDir) + if (extractedCoverPath) { + libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from ebook file to path "${extractedCoverPath}"`) + media.coverPath = extractedCoverPath hasMediaChanges = true } } } + // If no cover then search for cover if enabled in server settings + if (!media.coverPath && Database.serverSettings.scannerFindCovers) { + const authorName = media.authors.map(au => au.name).filter(au => au).join(', ') + const coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan) + if (coverPath) { + media.coverPath = coverPath + hasMediaChanges = true + } + } + existingLibraryItem.media = media let libraryItemUpdated = false @@ -408,12 +420,14 @@ class BookScanner { return null } + let ebookFileScanData = null if (ebookLibraryFile) { ebookLibraryFile = ebookLibraryFile.toJSON() ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase() + ebookFileScanData = await parseEbookMetadata.parse(ebookLibraryFile) } - const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, libraryItemData, libraryScan, librarySettings) + const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings) bookMetadata.explicit = !!bookMetadata.explicit // Ensure boolean bookMetadata.abridged = !!bookMetadata.abridged // Ensure boolean @@ -481,19 +495,28 @@ class BookScanner { } } - // If cover was not found in folder then check embedded covers in audio files OR search for cover + // If cover was not found in folder then check embedded covers in audio files OR ebook file + const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path if (!bookObject.coverPath) { - const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path - // Extract and save embedded cover art - const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir) + let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir) if (extractedCoverPath) { + libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from audio file at "${extractedCoverPath}" for book "${bookObject.title}"`) bookObject.coverPath = extractedCoverPath - } else if (Database.serverSettings.scannerFindCovers) { - const authorName = bookMetadata.authors.join(', ') - bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan) + } else if (ebookFileScanData?.ebookCoverPath) { + extractedCoverPath = await CoverManager.saveEbookCoverArt(ebookFileScanData, libraryItemObj.id, libraryItemDir) + if (extractedCoverPath) { + libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from ebook file at "${extractedCoverPath}" for book "${bookObject.title}"`) + bookObject.coverPath = extractedCoverPath + } } } + // If cover not found then search for cover if enabled in settings + if (!bookObject.coverPath && Database.serverSettings.scannerFindCovers) { + const authorName = bookMetadata.authors.join(', ') + bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan) + } + libraryItemObj.book = bookObject const libraryItem = await Database.libraryItemModel.create(libraryItemObj, { include: { @@ -570,13 +593,14 @@ class BookScanner { /** * * @param {import('../models/Book').AudioFileObject[]} audioFiles + * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData * @param {import('./LibraryItemScanData')} libraryItemData * @param {LibraryScan} libraryScan * @param {import('../models/Library').LibrarySettingsObject} librarySettings * @param {string} [existingLibraryItemId] * @returns {Promise} */ - async getBookMetadataFromScanData(audioFiles, libraryItemData, libraryScan, librarySettings, existingLibraryItemId = null) { + async getBookMetadataFromScanData(audioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings, existingLibraryItemId = null) { // First set book metadata from folder/file names const bookMetadata = { title: libraryItemData.mediaMetadata.title, // required @@ -599,7 +623,7 @@ class BookScanner { coverPath: undefined } - const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) + const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId) const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`) for (const metadataSource of metadataPrecedence) { @@ -627,13 +651,15 @@ class BookScanner { * * @param {Object} bookMetadata * @param {import('../models/Book').AudioFileObject[]} audioFiles + * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData * @param {import('./LibraryItemScanData')} libraryItemData * @param {LibraryScan} libraryScan * @param {string} existingLibraryItemId */ - constructor(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) { + constructor(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId) { this.bookMetadata = bookMetadata this.audioFiles = audioFiles + this.ebookFileScanData = ebookFileScanData this.libraryItemData = libraryItemData this.libraryScan = libraryScan this.existingLibraryItemId = existingLibraryItemId @@ -647,13 +673,42 @@ class BookScanner { } /** - * Metadata from audio file meta tags + * Metadata from audio file meta tags OR metadata from ebook file */ audioMetatags() { - if (!this.audioFiles.length) return - // Modifies bookMetadata with metadata mapped from audio file meta tags - const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title - AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan) + if (this.audioFiles.length) { + // Modifies bookMetadata with metadata mapped from audio file meta tags + const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title + AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan) + } else if (this.ebookFileScanData) { + const ebookMetdataObject = this.ebookFileScanData.metadata + for (const key in ebookMetdataObject) { + if (key === 'tags') { + if (ebookMetdataObject.tags.length) { + this.bookMetadata.tags = ebookMetdataObject.tags + } + } else if (key === 'genres') { + if (ebookMetdataObject.genres.length) { + this.bookMetadata.genres = ebookMetdataObject.genres + } + } else if (key === 'authors') { + if (ebookMetdataObject.authors?.length) { + this.bookMetadata.authors = ebookMetdataObject.authors + } + } else if (key === 'narrators') { + if (ebookMetdataObject.narrators?.length) { + this.bookMetadata.narrators = ebookMetdataObject.narrators + } + } else if (key === 'series') { + if (ebookMetdataObject.series?.length) { + this.bookMetadata.series = ebookMetdataObject.series + } + } else if (ebookMetdataObject[key] && key !== 'sequence') { + this.bookMetadata[key] = ebookMetdataObject[key] + } + } + } + return null } /** diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index b56c4db604..07dcbb11f4 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -2,7 +2,6 @@ const uuidv4 = require("uuid").v4 const Path = require('path') const { LogLevel } = require('../utils/constants') const { getTitleIgnorePrefix } = require('../utils/index') -const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') const AudioFileScanner = require('./AudioFileScanner') const Database = require('../Database') const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') diff --git a/server/utils/parsers/parseEbookMetadata.js b/server/utils/parsers/parseEbookMetadata.js new file mode 100644 index 0000000000..6e97c1da70 --- /dev/null +++ b/server/utils/parsers/parseEbookMetadata.js @@ -0,0 +1,42 @@ +const parseEpubMetadata = require('./parseEpubMetadata') + +/** + * @typedef EBookFileScanData + * @property {string} path + * @property {string} ebookFormat + * @property {string} ebookCoverPath internal image path + * @property {import('../../scanner/BookScanner').BookMetadataObject} metadata + */ + +/** + * Parse metadata from ebook file + * + * @param {import('../../models/Book').EBookFileObject} ebookFile + * @returns {Promise} + */ +async function parse(ebookFile) { + if (!ebookFile) return null + + if (ebookFile.ebookFormat === 'epub') { + return parseEpubMetadata.parse(ebookFile.metadata.path) + } + return null +} +module.exports.parse = parse + +/** + * Extract cover from ebook file + * + * @param {EBookFileScanData} ebookFileScanData + * @param {string} outputCoverPath + * @returns {Promise} + */ +async function extractCoverImage(ebookFileScanData, outputCoverPath) { + if (!ebookFileScanData?.ebookCoverPath) return false + + if (ebookFileScanData.ebookFormat === 'epub') { + return parseEpubMetadata.extractCoverImage(ebookFileScanData.path, ebookFileScanData.ebookCoverPath, outputCoverPath) + } + return false +} +module.exports.extractCoverImage = extractCoverImage \ No newline at end of file diff --git a/server/utils/parsers/parseEpubMetadata.js b/server/utils/parsers/parseEpubMetadata.js new file mode 100644 index 0000000000..7238b0bf40 --- /dev/null +++ b/server/utils/parsers/parseEpubMetadata.js @@ -0,0 +1,109 @@ +const Path = require('path') +const Logger = require('../../Logger') +const StreamZip = require('../../libs/nodeStreamZip') +const parseOpfMetadata = require('./parseOpfMetadata') +const { xmlToJSON } = require('../index') + + +/** + * Extract file from epub and return string content + * + * @param {string} epubPath + * @param {string} filepath + * @returns {Promise} + */ +async function extractFileFromEpub(epubPath, filepath) { + const zip = new StreamZip.async({ file: epubPath }) + const data = await zip.entryData(filepath).catch((error) => { + Logger.error(`[parseEpubMetadata] Failed to extract ${filepath} from epub at "${epubPath}"`, error) + }) + const filedata = data?.toString('utf8') + await zip.close() + return filedata +} + +/** + * Extract an XML file from epub and return JSON + * + * @param {string} epubPath + * @param {string} xmlFilepath + * @returns {Promise} + */ +async function extractXmlToJson(epubPath, xmlFilepath) { + const filedata = await extractFileFromEpub(epubPath, xmlFilepath) + if (!filedata) return null + return xmlToJSON(filedata) +} + +/** + * Extract cover image from epub return true if success + * + * @param {string} epubPath + * @param {string} epubImageFilepath + * @param {string} outputCoverPath + * @returns {Promise} + */ +async function extractCoverImage(epubPath, epubImageFilepath, outputCoverPath) { + const zip = new StreamZip.async({ file: epubPath }) + + const success = await zip.extract(epubImageFilepath, outputCoverPath).then(() => true).catch((error) => { + Logger.error(`[parseEpubMetadata] Failed to extract image ${epubImageFilepath} from epub at "${epubPath}"`, error) + return false + }) + + await zip.close() + + return success +} +module.exports.extractCoverImage = extractCoverImage + +/** + * Parse metadata from epub + * + * @param {string} epubPath + * @returns {Promise} + */ +async function parse(epubPath) { + Logger.debug(`Parsing metadata from epub at "${epubPath}"`) + // Entrypoint of the epub that contains the filepath to the package document (opf file) + const containerJson = await extractXmlToJson(epubPath, 'META-INF/container.xml') + + // Get package document opf filepath from container.xml + const packageDocPath = containerJson.container?.rootfiles?.[0]?.rootfile?.[0]?.$?.['full-path'] + if (!packageDocPath) { + Logger.error(`Failed to get package doc path in Container.xml`, JSON.stringify(containerJson, null, 2)) + return null + } + + // Extract package document to JSON + const packageJson = await extractXmlToJson(epubPath, packageDocPath) + if (!packageJson) { + return null + } + + // Parse metadata from package document opf file + const opfMetadata = parseOpfMetadata.parseOpfMetadataJson(packageJson) + if (!opfMetadata) { + Logger.error(`Unable to parse metadata in package doc with json`, JSON.stringify(packageJson, null, 2)) + return null + } + + const payload = { + path: epubPath, + ebookFormat: 'epub', + metadata: opfMetadata + } + + // Attempt to find filepath to cover image + const manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find(item => item.$?.['media-type']?.startsWith('image/')) + let coverImagePath = manifestFirstImage?.$?.href + if (coverImagePath) { + const packageDirname = Path.dirname(packageDocPath) + payload.ebookCoverPath = Path.posix.join(packageDirname, coverImagePath) + } else { + Logger.warn(`Cover image not found in manifest for epub at "${epubPath}"`) + } + + return payload +} +module.exports.parse = parse \ No newline at end of file diff --git a/server/utils/parsers/parseOpfMetadata.js b/server/utils/parsers/parseOpfMetadata.js index b51ceea5fc..3087497a0d 100644 --- a/server/utils/parsers/parseOpfMetadata.js +++ b/server/utils/parsers/parseOpfMetadata.js @@ -136,11 +136,7 @@ function stripPrefix(str) { return str.split(':').pop() } -module.exports.parseOpfMetadataXML = async (xml) => { - const json = await xmlToJSON(xml) - - if (!json) return null - +module.exports.parseOpfMetadataJson = (json) => { // Handle or with prefix const packageKey = Object.keys(json).find(key => stripPrefix(key) === 'package') if (!packageKey) return null @@ -167,7 +163,7 @@ module.exports.parseOpfMetadataXML = async (xml) => { const creators = parseCreators(metadata) const authors = (fetchCreators(creators, 'aut') || []).map(au => au?.trim()).filter(au => au) const narrators = (fetchNarrators(creators, metadata) || []).map(nrt => nrt?.trim()).filter(nrt => nrt) - const data = { + return { title: fetchTitle(metadata), subtitle: fetchSubtitle(metadata), authors, @@ -182,5 +178,10 @@ module.exports.parseOpfMetadataXML = async (xml) => { series: fetchSeries(metadataMeta), tags: fetchTags(metadata) } - return data +} + +module.exports.parseOpfMetadataXML = async (xml) => { + const json = await xmlToJSON(xml) + if (!json) return null + return this.parseOpfMetadataJson(json) } \ No newline at end of file From da25eff5c12d163e1329b3470ea60f79127c8e38 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 8 Jan 2024 18:21:15 -0600 Subject: [PATCH 13/15] Fix:Parse series sequence from OPF in cases where series_index is not directly underneath series meta #2505 --- server/utils/parsers/parseOpfMetadata.js | 13 +++++++++++-- .../utils/parsers/parseOpfMetadata.test.js | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/server/utils/parsers/parseOpfMetadata.js b/server/utils/parsers/parseOpfMetadata.js index 3087497a0d..a54196018c 100644 --- a/server/utils/parsers/parseOpfMetadata.js +++ b/server/utils/parsers/parseOpfMetadata.js @@ -103,15 +103,24 @@ function fetchSeries(metadataMeta) { if (!metadataMeta) return [] const result = [] for (let i = 0; i < metadataMeta.length; i++) { - if (metadataMeta[i].$?.name === "calibre:series" && metadataMeta[i].$.content?.trim()) { + if (metadataMeta[i].$?.name === 'calibre:series' && metadataMeta[i].$.content?.trim()) { const name = metadataMeta[i].$.content.trim() let sequence = null - if (metadataMeta[i + 1]?.$?.name === "calibre:series_index" && metadataMeta[i + 1].$?.content?.trim()) { + if (metadataMeta[i + 1]?.$?.name === 'calibre:series_index' && metadataMeta[i + 1].$?.content?.trim()) { sequence = metadataMeta[i + 1].$.content.trim() } result.push({ name, sequence }) } } + + // If one series was found with no series_index then check if any series_index meta can be found + // this is to support when calibre:series_index is not directly underneath calibre:series + if (result.length === 1 && !result[0].sequence) { + const seriesIndexMeta = metadataMeta.find(m => m.$?.name === 'calibre:series_index' && m.$.content?.trim()) + if (seriesIndexMeta) { + result[0].sequence = seriesIndexMeta.$.content.trim() + } + } return result } diff --git a/test/server/utils/parsers/parseOpfMetadata.test.js b/test/server/utils/parsers/parseOpfMetadata.test.js index f1d5ce89e0..ca033cca9f 100644 --- a/test/server/utils/parsers/parseOpfMetadata.test.js +++ b/test/server/utils/parsers/parseOpfMetadata.test.js @@ -110,4 +110,21 @@ describe('parseOpfMetadata - test series', async () => { { "name": "Serie 1", "sequence": null } ]) }) + + it('test series and series index not directly underneath', async () => { + const opf = ` + + + + + + + + + ` + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([ + { "name": "Serie 1", "sequence": "1" } + ]) + }) }) From 4a76059608651ce219dc9e18d41cb11f879008b4 Mon Sep 17 00:00:00 2001 From: Benjamin Porter Date: Wed, 3 Jan 2024 16:19:28 -0700 Subject: [PATCH 14/15] Change `Logger.dev` calls to `Logger.debug` Logger.dev is kind of in a weird spot where it doesn't fit into the standard log level. It is called directly by some code and it only checks whether a property is set (which comes from an env var) before deciding to print out. This standardizes on `debug` by changing the dev calls to debug. Also removes the now unused code. --- server/Database.js | 4 ++-- server/Logger.js | 10 ---------- server/models/Library.js | 2 +- server/models/LibraryItem.js | 36 ++++++++++++++++++------------------ 4 files changed, 21 insertions(+), 31 deletions(-) diff --git a/server/Database.js b/server/Database.js index fd606bacb8..0ddef6206b 100644 --- a/server/Database.js +++ b/server/Database.js @@ -177,11 +177,11 @@ class Database { if (process.env.QUERY_LOGGING === "log") { // Setting QUERY_LOGGING=log will log all Sequelize queries before they run Logger.info(`[Database] Query logging enabled`) - logging = (query) => Logger.dev(`Running the following query:\n ${query}`) + logging = (query) => Logger.debug(`Running the following query:\n ${query}`) } else if (process.env.QUERY_LOGGING === "benchmark") { // Setting QUERY_LOGGING=benchmark will log all Sequelize queries and their execution times, after they run Logger.info(`[Database] Query benchmarking enabled"`) - logging = (query, time) => Logger.dev(`Ran the following query in ${time}ms:\n ${query}`) + logging = (query, time) => Logger.debug(`Ran the following query in ${time}ms:\n ${query}`) benchmark = true } diff --git a/server/Logger.js b/server/Logger.js index b495318933..54fa58022e 100644 --- a/server/Logger.js +++ b/server/Logger.js @@ -5,7 +5,6 @@ class Logger { constructor() { this.isDev = process.env.NODE_ENV !== 'production' this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE - this.hideDevLogs = process.env.HIDE_DEV_LOGS === undefined ? !this.isDev : process.env.HIDE_DEV_LOGS === '1' this.socketListeners = [] this.logManager = null @@ -88,15 +87,6 @@ class Logger { this.debug(`Set Log Level to ${this.levelString}`) } - /** - * Only to console and only for development - * @param {...any} args - */ - dev(...args) { - if (this.hideDevLogs) return - console.log(`[${this.timestamp}] DEV:`, ...args) - } - trace(...args) { if (this.logLevel > LogLevel.TRACE) return console.trace(`[${this.timestamp}] TRACE:`, ...args) diff --git a/server/models/Library.js b/server/models/Library.js index df202fb984..c6875ad7b1 100644 --- a/server/models/Library.js +++ b/server/models/Library.js @@ -233,7 +233,7 @@ class Library extends Model { for (let i = 0; i < libraries.length; i++) { const library = libraries[i] if (library.displayOrder !== i + 1) { - Logger.dev(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`) + Logger.debug(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`) await library.update({ displayOrder: i + 1 }).catch((error) => { Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error) }) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 67e9abfbfc..508cf4c6b4 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -264,7 +264,7 @@ class LibraryItem extends Model { for (const existingPodcastEpisode of existingPodcastEpisodes) { // Episode was removed if (!updatedPodcastEpisodes.some(ep => ep.id === existingPodcastEpisode.id)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`) await existingPodcastEpisode.destroy() hasUpdates = true } @@ -272,7 +272,7 @@ class LibraryItem extends Model { for (const updatedPodcastEpisode of updatedPodcastEpisodes) { const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id) if (!existingEpisodeMatch) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode) hasUpdates = true } else { @@ -283,7 +283,7 @@ class LibraryItem extends Model { if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from "${existingValue}" to "${updatedEpisodeCleaned[key]}"`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from "${existingValue}" to "${updatedEpisodeCleaned[key]}"`) episodeHasUpdates = true } } @@ -304,7 +304,7 @@ class LibraryItem extends Model { for (const existingAuthor of existingAuthors) { // Author was removed from Book if (!updatedAuthors.some(au => au.id === existingAuthor.id)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id) hasUpdates = true } @@ -312,7 +312,7 @@ class LibraryItem extends Model { for (const updatedAuthor of updatedAuthors) { // Author was added if (!existingAuthors.some(au => au.id === updatedAuthor.id)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`) + 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 } @@ -320,7 +320,7 @@ class LibraryItem extends Model { for (const existingSeries of existingSeriesAll) { // Series was removed if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id) hasUpdates = true } @@ -329,11 +329,11 @@ class LibraryItem extends Model { // Series was added/updated const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id) if (!existingSeriesMatch) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`) + 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 }) hasUpdates = true } else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`) await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence }) hasUpdates = true } @@ -346,7 +346,7 @@ class LibraryItem extends Model { if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (!areEquivalent(updatedMedia[key], existingValue, true)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`) hasMediaUpdates = true } } @@ -363,7 +363,7 @@ class LibraryItem extends Model { if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from ${existingValue} to ${updatedLibraryItem[key]}`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from ${existingValue} to ${updatedLibraryItem[key]}`) hasLibraryItemUpdates = true } } @@ -541,7 +541,7 @@ class LibraryItem extends Model { }) } } - Logger.dev(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) let start = Date.now() if (library.isBook) { @@ -558,7 +558,7 @@ class LibraryItem extends Model { total: continueSeriesPayload.count }) } - Logger.dev(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } else if (library.isPodcast) { // "Newest Episodes" shelf const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit) @@ -572,7 +572,7 @@ class LibraryItem extends Model { total: newestEpisodesPayload.count }) } - Logger.dev(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } start = Date.now() @@ -588,7 +588,7 @@ class LibraryItem extends Model { total: mostRecentPayload.count }) } - Logger.dev(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`) if (library.isBook) { start = Date.now() @@ -604,7 +604,7 @@ class LibraryItem extends Model { total: seriesMostRecentPayload.count }) } - Logger.dev(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) start = Date.now() // "Discover" shelf @@ -619,7 +619,7 @@ class LibraryItem extends Model { total: discoverLibraryItemsPayload.count }) } - Logger.dev(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } start = Date.now() @@ -650,7 +650,7 @@ class LibraryItem extends Model { }) } } - Logger.dev(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`) if (library.isBook) { start = Date.now() @@ -666,7 +666,7 @@ class LibraryItem extends Model { total: newestAuthorsPayload.count }) } - Logger.dev(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) From e8fa029df77231d38d6cd23f327985d6a60a4461 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 10 Jan 2024 08:12:26 -0600 Subject: [PATCH 15/15] Fix:Specific podcast rss feed cannot be fetched due to accept header #2446 --- server/utils/podcastUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 4e01c92b13..769798eb0d 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -233,7 +233,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { method: 'GET', timeout: 12000, responseType: 'arraybuffer', - headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml' }, + headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8' }, httpAgent: ssrfFilter(feedUrl), httpsAgent: ssrfFilter(feedUrl) }).then(async (data) => {