From a1e321b153c9ba8821e35b8975639f4fe998d4e0 Mon Sep 17 00:00:00 2001 From: Machou Date: Wed, 3 Jan 2024 20:16:21 +0100 Subject: [PATCH 01/73] 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 86a64602..3db4b43a 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 8027c4a06f01f3a13d43cf041af012a4f64d62f5 Mon Sep 17 00:00:00 2001 From: Barnabas Ratki Date: Wed, 3 Jan 2024 01:36:56 +0100 Subject: [PATCH 02/73] Added support for custom metadata providers WiP but already open to feedback --- client/components/app/ConfigSideNav.vue | 5 + .../modals/AddCustomMetadataProviderModal.vue | 104 ++++++++++++ .../tables/CustomMetadataProviderTable.vue | 150 ++++++++++++++++++ client/layouts/default.vue | 8 + .../config/custom-metadata-providers.vue | 45 ++++++ client/store/scanners.js | 31 +++- client/strings/en-us.json | 3 + server/Database.js | 45 ++++++ server/controllers/LibraryController.js | 10 ++ server/controllers/MiscController.js | 90 +++++++++++ server/finders/BookFinder.js | 17 +- server/models/CustomMetadataProvider.js | 58 +++++++ server/providers/CustomProviderAdapter.js | 76 +++++++++ server/routers/ApiRouter.js | 4 + 14 files changed, 642 insertions(+), 4 deletions(-) create mode 100644 client/components/modals/AddCustomMetadataProviderModal.vue create mode 100644 client/components/tables/CustomMetadataProviderTable.vue create mode 100644 client/pages/config/custom-metadata-providers.vue create mode 100644 server/models/CustomMetadataProvider.js create mode 100644 server/providers/CustomProviderAdapter.js diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index c2db0725..e253d1ae 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -109,6 +109,11 @@ export default { id: 'config-authentication', title: this.$strings.HeaderAuthentication, path: '/config/authentication' + }, + { + id: 'config-custom-metadata-providers', + title: this.$strings.HeaderCustomMetadataProviders, + path: '/config/custom-metadata-providers' } ] diff --git a/client/components/modals/AddCustomMetadataProviderModal.vue b/client/components/modals/AddCustomMetadataProviderModal.vue new file mode 100644 index 00000000..1b9f930c --- /dev/null +++ b/client/components/modals/AddCustomMetadataProviderModal.vue @@ -0,0 +1,104 @@ + + + diff --git a/client/components/tables/CustomMetadataProviderTable.vue b/client/components/tables/CustomMetadataProviderTable.vue new file mode 100644 index 00000000..8104cede --- /dev/null +++ b/client/components/tables/CustomMetadataProviderTable.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/client/layouts/default.vue b/client/layouts/default.vue index c3cc3484..1d33c44c 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -328,6 +328,9 @@ export default { this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices) }, + customMetadataProvidersChanged() { + this.$store.dispatch('scanners/reFetchCustom') + }, initializeSocket() { this.socket = this.$nuxtSocket({ name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', @@ -406,6 +409,10 @@ export default { this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete) this.socket.on('admin_message', this.adminMessageEvt) + + // Custom metadata provider Listeners + this.socket.on('custom_metadata_provider_added', this.customMetadataProvidersChanged) + this.socket.on('custom_metadata_provider_removed', this.customMetadataProvidersChanged) }, showUpdateToast(versionData) { var ignoreVersion = localStorage.getItem('ignoreVersion') @@ -541,6 +548,7 @@ export default { window.addEventListener('keydown', this.keyDown) this.$store.dispatch('libraries/load') + this.$store.dispatch('scanners/reFetchCustom') this.initLocalStorage() diff --git a/client/pages/config/custom-metadata-providers.vue b/client/pages/config/custom-metadata-providers.vue new file mode 100644 index 00000000..10fdb21b --- /dev/null +++ b/client/pages/config/custom-metadata-providers.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/client/store/scanners.js b/client/store/scanners.js index ccdc1791..32878a6a 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -73,6 +73,33 @@ export const state = () => ({ export const getters = {} -export const actions = {} +export const actions = { + reFetchCustom({ dispatch, commit }) { + return this.$axios + .$get(`/api/custom-metadata-providers`) + .then((data) => { + const providers = data.providers -export const mutations = {} \ No newline at end of file + commit('setCustomProviders', providers) + return data + }) + .catch((error) => { + console.error('Failed', error) + return false + }) + }, +} + +export const mutations = { + setCustomProviders(state, providers) { + // clear previous values, and add new values to the end + state.providers = state.providers.filter((p) => !p.value.startsWith("custom-")); + state.providers = [ + ...state.providers, + ...providers.map((p) => {return { + text: p.name, + value: p.slug, + }}) + ] + }, +} \ No newline at end of file diff --git a/client/strings/en-us.json b/client/strings/en-us.json index f69175fd..9dfde095 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -96,6 +96,7 @@ "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudioTracks": "Audio Tracks", "HeaderAuthentication": "Authentication", + "HeaderCustomMetadataProviders": "Custom metadata providers", "HeaderBackups": "Backups", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", @@ -540,6 +541,8 @@ "LabelYourBookmarks": "Your Bookmarks", "LabelYourPlaylists": "Your Playlists", "LabelYourProgress": "Your Progress", + "LabelUrl": "URL", + "LabelApiKey": "API Key", "MessageAddToPlayerQueue": "Add to player queue", "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.", diff --git a/server/Database.js b/server/Database.js index fd606bac..bbea7352 100644 --- a/server/Database.js +++ b/server/Database.js @@ -132,6 +132,11 @@ class Database { return this.models.playbackSession } + /** @type {typeof import('./models/CustomMetadataProvider')} */ + get customMetadataProviderModel() { + return this.models.customMetadataProvider + } + /** * Check if db file exists * @returns {boolean} @@ -245,6 +250,7 @@ class Database { require('./models/Feed').init(this.sequelize) require('./models/FeedEpisode').init(this.sequelize) require('./models/Setting').init(this.sequelize) + require('./models/CustomMetadataProvider').init(this.sequelize) return this.sequelize.sync({ force, alter: false }) } @@ -694,6 +700,45 @@ class Database { }) } + /** + * Returns true if a custom provider with the given slug exists + * @param {string} providerSlug + * @return {boolean} + */ + async doesCustomProviderExistBySlug(providerSlug) { + const id = providerSlug.split("custom-")[1] + + if (!id) { + return false + } + + return !!await this.customMetadataProviderModel.findByPk(id) + } + + /** + * Removes a custom metadata provider + * @param {string} id + */ + async removeCustomMetadataProviderById(id) { + // destroy metadta provider + await this.customMetadataProviderModel.destroy({ + where: { + id, + } + }) + + const slug = `custom-${id}`; + + // fallback libraries using it to google + await this.libraryModel.update({ + provider: "google", + }, { + where: { + provider: slug, + } + }); + } + /** * Clean invalid records in database * Series should have atleast one Book diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 70baff85..304ca4f0 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -51,6 +51,11 @@ class LibraryController { } } + // Validate that the custom provider exists if given any + if (newLibraryPayload.provider && newLibraryPayload.provider.startsWith("custom-")) { + await Database.doesCustomProviderExistBySlug(newLibraryPayload.provider) + } + const library = new Library() let currentLargestDisplayOrder = await Database.libraryModel.getMaxDisplayOrder() @@ -175,6 +180,11 @@ class LibraryController { } } + // Validate that the custom provider exists if given any + if (req.body.provider && req.body.provider.startsWith("custom-")) { + await Database.doesCustomProviderExistBySlug(req.body.provider) + } + const hasUpdates = library.update(req.body) // TODO: Should check if this is an update to folder paths or name only if (hasUpdates) { diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index c2272ee6..31f4587b 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -717,5 +717,95 @@ class MiscController { const stats = await adminStats.getStatsForYear(year) res.json(stats) } + + /** + * GET: /api/custom-metadata-providers + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getCustomMetadataProviders(req, res) { + const providers = await Database.customMetadataProviderModel.findAll() + + res.json({ + providers: providers.map((p) => p.toUserJson()), + }) + } + + /** + * GET: /api/custom-metadata-providers/admin + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getAdminCustomMetadataProviders(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin custom metadata providers`) + return res.sendStatus(403) + } + + const providers = await Database.customMetadataProviderModel.findAll() + + res.json({ + providers, + }) + } + + /** + * PATCH: /api/custom-metadata-providers/admin + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async addCustomMetadataProviders(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin custom metadata providers`) + return res.sendStatus(403) + } + + const { name, url, apiKey } = req.body; + + if (!name || !url || !apiKey) { + return res.status(500).send(`Invalid patch data`) + } + + const provider = await Database.customMetadataProviderModel.create({ + name, + url, + apiKey, + }) + + SocketAuthority.adminEmitter('custom_metadata_provider_added', provider) + + res.json({ + provider, + }) + } + + /** + * DELETE: /api/custom-metadata-providers/admin/:id + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async deleteCustomMetadataProviders(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin custom metadata providers`) + return res.sendStatus(403) + } + + const { id } = req.params; + + if (!id) { + return res.status(500).send(`Invalid delete data`) + } + + const provider = await Database.customMetadataProviderModel.findByPk(id); + await Database.removeCustomMetadataProviderById(id); + + SocketAuthority.adminEmitter('custom_metadata_provider_removed', provider) + + res.json({}) + } } module.exports = new MiscController() diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 466c8701..6c35a5fb 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -5,6 +5,7 @@ const iTunes = require('../providers/iTunes') const Audnexus = require('../providers/Audnexus') const FantLab = require('../providers/FantLab') const AudiobookCovers = require('../providers/AudiobookCovers') +const CustomProviderAdapter = require('../providers/CustomProviderAdapter') const Logger = require('../Logger') const { levenshteinDistance, escapeRegExp } = require('../utils/index') @@ -17,6 +18,7 @@ class BookFinder { this.audnexus = new Audnexus() this.fantLab = new FantLab() this.audiobookCovers = new AudiobookCovers() + this.customProviderAdapter = new CustomProviderAdapter() this.providers = ['google', 'itunes', 'openlibrary', 'fantlab', 'audiobookcovers', 'audible', 'audible.ca', 'audible.uk', 'audible.au', 'audible.fr', 'audible.de', 'audible.jp', 'audible.it', 'audible.in', 'audible.es'] @@ -147,6 +149,13 @@ class BookFinder { return books } + async getCustomProviderResults(title, author, providerSlug) { + const books = await this.customProviderAdapter.search(title, author, providerSlug) + if (this.verbose) Logger.debug(`Custom provider '${providerSlug}' Search Results: ${books.length || 0}`) + + return books + } + static TitleCandidates = class { constructor(cleanAuthor) { @@ -315,6 +324,11 @@ class BookFinder { const maxFuzzySearches = !isNaN(options.maxFuzzySearches) ? Number(options.maxFuzzySearches) : 5 let numFuzzySearches = 0 + // Custom providers are assumed to be correct + if (provider.startsWith("custom-")) { + return await this.getCustomProviderResults(title, author, provider) + } + if (!title) return books @@ -397,8 +411,7 @@ class BookFinder { books = await this.getFantLabResults(title, author) } else if (provider === 'audiobookcovers') { books = await this.getAudiobookCoversResults(title) - } - else { + } else { books = await this.getGoogleBooksResults(title, author) } return books diff --git a/server/models/CustomMetadataProvider.js b/server/models/CustomMetadataProvider.js new file mode 100644 index 00000000..4f2488d2 --- /dev/null +++ b/server/models/CustomMetadataProvider.js @@ -0,0 +1,58 @@ +const { DataTypes, Model, Sequelize } = require('sequelize') + +class CustomMetadataProvider extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.name + /** @type {string} */ + this.url + /** @type {string} */ + this.apiKey + } + + getSlug() { + return `custom-${this.id}` + } + + toUserJson() { + return { + name: this.name, + id: this.id, + slug: this.getSlug() + } + } + + static findByPk(id) { + this.findOne({ + where: { + id, + } + }) + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + url: DataTypes.STRING, + apiKey: DataTypes.STRING + }, { + sequelize, + modelName: 'customMetadataProvider' + }) + } +} + +module.exports = CustomMetadataProvider \ No newline at end of file diff --git a/server/providers/CustomProviderAdapter.js b/server/providers/CustomProviderAdapter.js new file mode 100644 index 00000000..1bf5a5ee --- /dev/null +++ b/server/providers/CustomProviderAdapter.js @@ -0,0 +1,76 @@ +const Database = require('../Database') +const axios = require("axios"); +const Logger = require("../Logger"); + +class CustomProviderAdapter { + constructor() { + } + + async search(title, author, providerSlug) { + const providerId = providerSlug.split("custom-")[1] + + console.log(providerId) + const provider = await Database.customMetadataProviderModel.findOne({ + where: { + id: providerId, + } + }); + + if (!provider) { + throw new Error("Custom provider not found for the given id"); + } + + const matches = await axios.get(`${provider.url}/search?query=${encodeURIComponent(title)}${!!author ? `&author=${encodeURIComponent(author)}` : ""}`, { + headers: { + "Authorization": provider.apiKey, + }, + }).then((res) => { + if (!res || !res.data || !Array.isArray(res.data.matches)) return null + return res.data.matches + }).catch(error => { + Logger.error('[CustomMetadataProvider] Search error', error) + return [] + }) + + if (matches === null) { + throw new Error("Custom provider returned malformed response"); + } + + // re-map keys to throw out + return matches.map(({ + title, + subtitle, + author, + narrator, + publisher, + published_year, + description, + cover, + isbn, + asin, + genres, + tags, + language, + duration, + }) => { + return { + title, + subtitle, + author, + narrator, + publisher, + publishedYear: published_year, + description, + cover, + isbn, + asin, + genres, + tags: tags.join(","), + language, + duration, + } + }) + } +} + +module.exports = CustomProviderAdapter \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 3edce256..f78d4539 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -318,6 +318,10 @@ class ApiRouter { this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this)) this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this)) this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this)) + this.router.get('/custom-metadata-providers', MiscController.getCustomMetadataProviders.bind(this)) + this.router.get('/custom-metadata-providers/admin', MiscController.getAdminCustomMetadataProviders.bind(this)) + this.router.patch('/custom-metadata-providers/admin', MiscController.addCustomMetadataProviders.bind(this)) + this.router.delete('/custom-metadata-providers/admin/:id', MiscController.deleteCustomMetadataProviders.bind(this)) } async getDirectories(dir, relpath, excludedDirs, level = 0) { From 08a41e37b4d412ef04cb45e839acda07b1e77cd9 Mon Sep 17 00:00:00 2001 From: Barnabas Ratki Date: Wed, 3 Jan 2024 20:27:42 +0100 Subject: [PATCH 03/73] Add specification --- custom-metadata-provider-specification.yaml | 124 ++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 custom-metadata-provider-specification.yaml diff --git a/custom-metadata-provider-specification.yaml b/custom-metadata-provider-specification.yaml new file mode 100644 index 00000000..3201fbb8 --- /dev/null +++ b/custom-metadata-provider-specification.yaml @@ -0,0 +1,124 @@ +openapi: 3.0.0 +servers: + - url: https://example.com + description: Local server +info: + license: + name: MIT + url: https://opensource.org/licenses/MIT + + + title: Custom Metadata Provider + version: 0.1.0 +security: + - api_key: [] + +paths: + /search: + get: + description: Search for books + operationId: search + summary: Search for books + security: + - api_key: [] + parameters: + - name: query + in: query + required: true + schema: + type: string + - name: author + in: query + required: false + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + matches: + type: array + items: + $ref: "#/components/schemas/BookMetadata" + "400": + description: Bad Request + content: + application/json: + schema: + type: object + properties: + error: + type: string + "401": + description: Unauthorized + content: + application/json: + schema: + type: object + properties: + error: + type: string + "500": + description: Internal Server Error + content: + application/json: + schema: + type: object + properties: + error: + type: string +components: + schemas: + BookMetadata: + type: object + properties: + title: + type: string + subtitle: + type: string + author: + type: string + narrator: + type: string + publisher: + type: string + published_year: + type: string + description: + type: string + cover: + type: string + description: URL to the cover image + isbn: + type: string + format: isbn + asin: + type: string + format: asin + genres: + type: array + items: + type: string + tags: + type: array + items: + type: string + language: + type: string + duration: + type: number + format: int64 + description: Duration in seconds + required: + - title + securitySchemes: + api_key: + type: apiKey + name: AUTHORIZATION + in: header + + From 5ea423072be02beb9489e62c51d8aeb023acbeb1 Mon Sep 17 00:00:00 2001 From: Barnabas Ratki Date: Wed, 3 Jan 2024 20:40:36 +0100 Subject: [PATCH 04/73] Small fixes --- server/Database.js | 2 +- server/controllers/LibraryController.js | 4 ++-- server/models/CustomMetadataProvider.js | 2 +- server/providers/CustomProviderAdapter.js | 8 +------- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/server/Database.js b/server/Database.js index bbea7352..302170ac 100644 --- a/server/Database.js +++ b/server/Database.js @@ -705,7 +705,7 @@ class Database { * @param {string} providerSlug * @return {boolean} */ - async doesCustomProviderExistBySlug(providerSlug) { + async doesCustomProviderExistWithSlug(providerSlug) { const id = providerSlug.split("custom-")[1] if (!id) { diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 304ca4f0..b1ab572f 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -53,7 +53,7 @@ class LibraryController { // Validate that the custom provider exists if given any if (newLibraryPayload.provider && newLibraryPayload.provider.startsWith("custom-")) { - await Database.doesCustomProviderExistBySlug(newLibraryPayload.provider) + await Database.doesCustomProviderExistWithSlug(newLibraryPayload.provider) } const library = new Library() @@ -182,7 +182,7 @@ class LibraryController { // Validate that the custom provider exists if given any if (req.body.provider && req.body.provider.startsWith("custom-")) { - await Database.doesCustomProviderExistBySlug(req.body.provider) + await Database.doesCustomProviderExistWithSlug(req.body.provider) } const hasUpdates = library.update(req.body) diff --git a/server/models/CustomMetadataProvider.js b/server/models/CustomMetadataProvider.js index 4f2488d2..9bc175c4 100644 --- a/server/models/CustomMetadataProvider.js +++ b/server/models/CustomMetadataProvider.js @@ -27,7 +27,7 @@ class CustomMetadataProvider extends Model { } static findByPk(id) { - this.findOne({ + return this.findOne({ where: { id, } diff --git a/server/providers/CustomProviderAdapter.js b/server/providers/CustomProviderAdapter.js index 1bf5a5ee..d5f64291 100644 --- a/server/providers/CustomProviderAdapter.js +++ b/server/providers/CustomProviderAdapter.js @@ -8,13 +8,7 @@ class CustomProviderAdapter { async search(title, author, providerSlug) { const providerId = providerSlug.split("custom-")[1] - - console.log(providerId) - const provider = await Database.customMetadataProviderModel.findOne({ - where: { - id: providerId, - } - }); + const provider = await Database.customMetadataProviderModel.findByPk(providerId); if (!provider) { throw new Error("Custom provider not found for the given id"); From 12c6a1baa02b2514b48565a4e030281856b2906d Mon Sep 17 00:00:00 2001 From: Barnabas Ratki Date: Wed, 3 Jan 2024 20:42:35 +0100 Subject: [PATCH 05/73] Fix log messages --- server/controllers/MiscController.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 31f4587b..76140dcc 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -759,7 +759,7 @@ class MiscController { */ async addCustomMetadataProviders(req, res) { if (!req.user.isAdminOrUp) { - Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin custom metadata providers`) + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to add admin custom metadata providers`) return res.sendStatus(403) } @@ -790,7 +790,7 @@ class MiscController { */ async deleteCustomMetadataProviders(req, res) { if (!req.user.isAdminOrUp) { - Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin custom metadata providers`) + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to delete admin custom metadata providers`) return res.sendStatus(403) } From baa65b8155aa21468650f5859c4dc42ece478a6b Mon Sep 17 00:00:00 2001 From: Benjamin Porter Date: Wed, 3 Jan 2024 13:55:43 -0700 Subject: [PATCH 06/73] 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 25472000..943fc567 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 ffa7cc0d22dbe42457745083b8cdafaa0c93640c Mon Sep 17 00:00:00 2001 From: Machou Date: Fri, 5 Jan 2024 07:19:07 +0100 Subject: [PATCH 07/73] 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 3db4b43a..e570614d 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 08/73] 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 1d422810..ca044b71 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 63cb8805..2c6cc191 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 12ce7b1e..acc92ea5 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 09/73] 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 b1fb03ac..f2c6f342 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 10/73] 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 4a1b4753..b5d98a25 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 11/73] 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 8cc3f7fb..8fb7c87c 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 12/73] 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 f649fb76..a3b84f00 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 13/73] 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 c4d1345d..04b3ce59 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 c1eabfc6..77245537 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 14/73] 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 e570614d..d894412c 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 15/73] 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 8ec73dd0..43938f9c 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 3cf97f33..9b4aa32d 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 1f9d2823..e554dfb4 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 6c93dddf..b40e9323 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 b56c4db6..07dcbb11 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 00000000..6e97c1da --- /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 00000000..7238b0bf --- /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 b51ceea5..3087497a 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 16/73] 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 3087497a..a5419601 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 f1d5ce89..ca033cca 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 17/73] 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 fd606bac..0ddef620 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 b4953189..54fa5802 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 df202fb9..c6875ad7 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 67e9abfb..508cf4c6 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 18/73] 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 4e01c92b..769798eb 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) => { From cf85d66b2f59412fb01f2601685669138656278a Mon Sep 17 00:00:00 2001 From: Torstein Eide <1884894+Eideen@users.noreply.github.com> Date: Fri, 12 Jan 2024 14:26:32 +0100 Subject: [PATCH 19/73] Add example for HAproxy --- readme.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/readme.md b/readme.md index a3b84f00..4c8bd5d7 100644 --- a/readme.md +++ b/readme.md @@ -241,6 +241,93 @@ subdomain.domain.com { reverse_proxy : } ``` +### HAproxy + +Bellow is a geneic HAproxy config, using `audiobookshelf.YOUR_DOMAIN.COM`. + +To use `http2`, `ssl` is needed. + +````make +global + # ... (your global settings go here) + +defaults + mode http + # ... (your default settings go here) + +frontend my_frontend + # Bind to port 443, enable SSL, and specify the certificate list file + bind :443 name :443 ssl crt-list /path/to/cert.crt_list alpn h2,http/1.1 + mode http + + # Define an ACL for subdomains starting with "audiobookshelf" + acl is_audiobookshelf hdr_beg(host) -i audiobookshelf + + # Use the ACL to route traffic to audiobookshelf_backend if the condition is met, + # otherwise, use the default_backend + use_backend audiobookshelf_backend if is_audiobookshelf + default_backend default_backend + +backend audiobookshelf_backend + mode http + # ... (backend settings for audiobookshelf go here) + + # Define the server for the audiobookshelf backend + server audiobookshelf_server 127.0.0.99:13378 + +backend default_backend + mode http + # ... (default backend settings go here) + + # Define the server for the default backend + server default_server 127.0.0.123:8081 + +```` + +#### PFsense and HAproxy + +For PFsense the inputs are more graficals + +##### Frontend, Default backend, access control lists and actions + +###### Access Control lists + +| Name | Expression | CS | Not | Value | +|:--------------:|:-----------------:|:--:|:---:|:---------------:| +| audiobookshelf | Host starts with: | | | audiobookshelf. | + + + +###### Actions + +The `condition acl names` needs to match the name above `audiobookshelf`. + +| Action | Parameters | Condition acl names | Backend | +|:--------------:|:-----------------:|:---------------:|:---------------:| +| audiobookshelf | Host starts with: | audiobookshelf. | audiobookshelf| + +##### Backend + + +The `Name` needs to match the `Backend` above `audiobookshelf`. + +| Name | audiobookshelf | +|--------------|-----------------| + +**Server list:** + +| Name | Expression | CS | Not | Value | +|:--------------:|:-----------------:|:--:|:---:|:---------------:| +| audiobookshelf | Host starts with: | | | audiobookshelf. | + +##### Health checking + +Health checking is enabled by default. `Http check method` of `OPTIONS` is not supported on Audiobookshelf. +If Health check fails, data will not be forwared. +Need to one of following: + +* Change `Health check method` to `none`. To disable. +* Change `Http check method` to `HEAD` or `GET`. To make Health checking function. # Run from source From 6ca684603c6c1a1b0737b62e1663913e7a74a555 Mon Sep 17 00:00:00 2001 From: Torstein Eide <1884894+Eideen@users.noreply.github.com> Date: Fri, 12 Jan 2024 14:35:30 +0100 Subject: [PATCH 20/73] Fix typos --- readme.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/readme.md b/readme.md index 4c8bd5d7..86fa46f0 100644 --- a/readme.md +++ b/readme.md @@ -284,13 +284,13 @@ backend default_backend ```` -#### PFsense and HAproxy +### PFsense and HAproxy -For PFsense the inputs are more graficals +For PFsense the inputs are graphical, and `Health checking` is enabled. -##### Frontend, Default backend, access control lists and actions +#### Frontend, Default backend, access control lists and actions -###### Access Control lists +##### Access Control lists | Name | Expression | CS | Not | Value | |:--------------:|:-----------------:|:--:|:---:|:---------------:| @@ -298,29 +298,29 @@ For PFsense the inputs are more graficals -###### Actions +##### Actions The `condition acl names` needs to match the name above `audiobookshelf`. -| Action | Parameters | Condition acl names | Backend | -|:--------------:|:-----------------:|:---------------:|:---------------:| -| audiobookshelf | Host starts with: | audiobookshelf. | audiobookshelf| +| Action | Parameters | Condition acl names | +|:--------------:|:-----------------:|:---------------:| +| `Use Backend` |audiobookshelf | audiobookshelf | -##### Backend +#### Backend -The `Name` needs to match the `Backend` above `audiobookshelf`. +The `Name` needs to match the `Parameters` above `audiobookshelf`. | Name | audiobookshelf | |--------------|-----------------| -**Server list:** +##### Server list: | Name | Expression | CS | Not | Value | |:--------------:|:-----------------:|:--:|:---:|:---------------:| | audiobookshelf | Host starts with: | | | audiobookshelf. | -##### Health checking +##### Health checking: Health checking is enabled by default. `Http check method` of `OPTIONS` is not supported on Audiobookshelf. If Health check fails, data will not be forwared. From 3b531144cfdbce2379b90bb4f2ee36ca949db79c Mon Sep 17 00:00:00 2001 From: FlyinPancake Date: Fri, 12 Jan 2024 21:45:03 +0100 Subject: [PATCH 21/73] implemented suggestions, extended CMPs with series --- .../pages/config/custom-metadata-providers.vue | 6 ++---- client/strings/en-us.json | 6 +++--- custom-metadata-provider-specification.yaml | 13 ++++++++++++- server/controllers/MiscController.js | 10 +++++----- server/models/CustomMetadataProvider.js | 10 ++-------- server/providers/CustomProviderAdapter.js | 16 +++++++++------- 6 files changed, 33 insertions(+), 28 deletions(-) diff --git a/client/pages/config/custom-metadata-providers.vue b/client/pages/config/custom-metadata-providers.vue index 10fdb21b..9f394eae 100644 --- a/client/pages/config/custom-metadata-providers.vue +++ b/client/pages/config/custom-metadata-providers.vue @@ -9,7 +9,7 @@
- {{ $strings.ButtonAdd }} + {{ $strings.ButtonAdd }} @@ -40,6 +40,4 @@ export default { } - + diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 9dfde095..e937ed72 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -96,7 +96,6 @@ "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudioTracks": "Audio Tracks", "HeaderAuthentication": "Authentication", - "HeaderCustomMetadataProviders": "Custom metadata providers", "HeaderBackups": "Backups", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", @@ -105,6 +104,7 @@ "HeaderCollectionItems": "Collection Items", "HeaderCover": "Cover", "HeaderCurrentDownloads": "Current Downloads", + "HeaderCustomMetadataProviders": "Custom metadata providers", "HeaderDetails": "Details", "HeaderDownloadQueue": "Download Queue", "HeaderEbookFiles": "Ebook Files", @@ -194,6 +194,7 @@ "LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", + "LabelApiKey": "API Key", "LabelAppend": "Append", "LabelAuthor": "Author", "LabelAuthorFirstLast": "Author (First Last)", @@ -526,6 +527,7 @@ "LabelUploaderDragAndDrop": "Drag & drop files or folders", "LabelUploaderDropFiles": "Drop files", "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", + "LabelUrl": "URL", "LabelUseChapterTrack": "Use chapter track", "LabelUseFullTrack": "Use full track", "LabelUser": "User", @@ -541,8 +543,6 @@ "LabelYourBookmarks": "Your Bookmarks", "LabelYourPlaylists": "Your Playlists", "LabelYourProgress": "Your Progress", - "LabelUrl": "URL", - "LabelApiKey": "API Key", "MessageAddToPlayerQueue": "Add to player queue", "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.", diff --git a/custom-metadata-provider-specification.yaml b/custom-metadata-provider-specification.yaml index 3201fbb8..90df875b 100644 --- a/custom-metadata-provider-specification.yaml +++ b/custom-metadata-provider-specification.yaml @@ -86,7 +86,7 @@ components: type: string publisher: type: string - published_year: + publishedYear: type: string description: type: string @@ -107,6 +107,17 @@ components: type: array items: type: string + series: + type: array + items: + type: object + properties: + series: + type: string + required: true + sequence: + type: number + format: int64 language: type: string duration: diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 76140dcc..1d2fff04 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -763,7 +763,7 @@ class MiscController { return res.sendStatus(403) } - const { name, url, apiKey } = req.body; + const { name, url, apiKey } = req.body if (!name || !url || !apiKey) { return res.status(500).send(`Invalid patch data`) @@ -794,18 +794,18 @@ class MiscController { return res.sendStatus(403) } - const { id } = req.params; + const { id } = req.params if (!id) { return res.status(500).send(`Invalid delete data`) } - const provider = await Database.customMetadataProviderModel.findByPk(id); - await Database.removeCustomMetadataProviderById(id); + const provider = await Database.customMetadataProviderModel.findByPk(id) + await Database.removeCustomMetadataProviderById(id) SocketAuthority.adminEmitter('custom_metadata_provider_removed', provider) - res.json({}) + res.sendStatus(200) } } module.exports = new MiscController() diff --git a/server/models/CustomMetadataProvider.js b/server/models/CustomMetadataProvider.js index 9bc175c4..d6047bb8 100644 --- a/server/models/CustomMetadataProvider.js +++ b/server/models/CustomMetadataProvider.js @@ -26,13 +26,7 @@ class CustomMetadataProvider extends Model { } } - static findByPk(id) { - return this.findOne({ - where: { - id, - } - }) - } + /** * Initialize model @@ -47,7 +41,7 @@ class CustomMetadataProvider extends Model { }, name: DataTypes.STRING, url: DataTypes.STRING, - apiKey: DataTypes.STRING + apiKey: DataTypes.STRING, }, { sequelize, modelName: 'customMetadataProvider' diff --git a/server/providers/CustomProviderAdapter.js b/server/providers/CustomProviderAdapter.js index d5f64291..1919ecc9 100644 --- a/server/providers/CustomProviderAdapter.js +++ b/server/providers/CustomProviderAdapter.js @@ -1,6 +1,6 @@ const Database = require('../Database') -const axios = require("axios"); -const Logger = require("../Logger"); +const axios = require("axios") +const Logger = require("../Logger") class CustomProviderAdapter { constructor() { @@ -8,10 +8,10 @@ class CustomProviderAdapter { async search(title, author, providerSlug) { const providerId = providerSlug.split("custom-")[1] - const provider = await Database.customMetadataProviderModel.findByPk(providerId); + const provider = await Database.customMetadataProviderModel.findByPk(providerId) if (!provider) { - throw new Error("Custom provider not found for the given id"); + throw new Error("Custom provider not found for the given id") } const matches = await axios.get(`${provider.url}/search?query=${encodeURIComponent(title)}${!!author ? `&author=${encodeURIComponent(author)}` : ""}`, { @@ -27,7 +27,7 @@ class CustomProviderAdapter { }) if (matches === null) { - throw new Error("Custom provider returned malformed response"); + throw new Error("Custom provider returned malformed response") } // re-map keys to throw out @@ -37,13 +37,14 @@ class CustomProviderAdapter { author, narrator, publisher, - published_year, + publishedYear, description, cover, isbn, asin, genres, tags, + series, language, duration, }) => { @@ -53,13 +54,14 @@ class CustomProviderAdapter { author, narrator, publisher, - publishedYear: published_year, + publishedYear, description, cover, isbn, asin, genres, tags: tags.join(","), + series: series.length ? series : null, language, duration, } From 850397e4c1bfe2a4d13727a08169e25337cd9a3f Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 12 Jan 2024 17:58:07 -0600 Subject: [PATCH 22/73] Add:Playlist button to podcast episodes on latest page #2455 --- .../pages/library/_library/podcast/latest.vue | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/client/pages/library/_library/podcast/latest.vue b/client/pages/library/_library/podcast/latest.vue index e69e055f..8d95203f 100644 --- a/client/pages/library/_library/podcast/latest.vue +++ b/client/pages/library/_library/podcast/latest.vue @@ -54,9 +54,16 @@

{{ getButtonText(episode) }}

- + + + + + + + +
@@ -136,6 +143,15 @@ export default { } }, methods: { + clickAddToPlaylist(episode) { + // Makeshift libraryItem + const libraryItem = { + id: episode.libraryItemId, + media: episode.podcast + } + this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: libraryItem, episode }]) + this.$store.commit('globals/setShowPlaylistsModal', true) + }, async clickEpisode(episode) { if (this.openingItem) return this.openingItem = true From e76af3bfc2e972dbca7851c96191f1c16a10d229 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 13 Jan 2024 16:41:13 -0600 Subject: [PATCH 23/73] Fix comic page menu dropdown highlight correct page --- client/components/readers/ComicReader.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue index 67aa16c6..d55fc0d6 100644 --- a/client/components/readers/ComicReader.vue +++ b/client/components/readers/ComicReader.vue @@ -1,7 +1,7 @@