diff --git a/client/components/cards/BookMatchCard.vue b/client/components/cards/BookMatchCard.vue index 87aa0a711..09b963c50 100644 --- a/client/components/cards/BookMatchCard.vue +++ b/client/components/cards/BookMatchCard.vue @@ -13,9 +13,17 @@
{{ book.publishedYear }}
-{{ $getString('LabelByAuthor', [book.author]) }}
-{{ $strings.LabelNarrators }}: {{ book.narrator }}
-{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}
+ +{{ $getString('LabelByAuthor', [book.author]) }}
+{{ $strings.LabelNarrators }}: {{ book.narrator }}
+{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}
+
diff --git a/client/package-lock.json b/client/package-lock.json
index d321ce58e..37fb4903a 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
- "version": "2.26.1",
+ "version": "2.26.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
- "version": "2.26.0",
+ "version": "2.26.2",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
diff --git a/client/package.json b/client/package.json
index 1aa9b384a..828fedf96 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
- "version": "2.26.1",
+ "version": "2.26.2",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
diff --git a/client/strings/cs.json b/client/strings/cs.json
index 7da798579..547806d37 100644
--- a/client/strings/cs.json
+++ b/client/strings/cs.json
@@ -1,5 +1,6 @@
{
"ButtonAdd": "Přidat",
+ "ButtonAddApiKey": "Přidat API klíč",
"ButtonAddChapters": "Přidat kapitoly",
"ButtonAddDevice": "Přidat zařízení",
"ButtonAddLibrary": "Přidat knihovnu",
@@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Vybrat složku",
"ButtonChooseFiles": "Vybrat soubory",
"ButtonClearFilter": "Vymazat filtr",
+ "ButtonClose": "Zavřít",
"ButtonCloseFeed": "Zavřít kanál",
"ButtonCloseSession": "Zavřít otevřenou relaci",
"ButtonCollections": "Kolekce",
@@ -119,6 +121,7 @@
"HeaderAccount": "Účet",
"HeaderAddCustomMetadataProvider": "Přidat vlastního poskytovatele metadat",
"HeaderAdvanced": "Pokročilé",
+ "HeaderApiKeys": "API klíče",
"HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise",
"HeaderAudioTracks": "Zvukové stopy",
"HeaderAudiobookTools": "Nástroje pro správu souborů audioknih",
@@ -162,6 +165,7 @@
"HeaderMetadataOrderOfPrecedence": "Pořadí priorit metadat",
"HeaderMetadataToEmbed": "Metadata k vložení",
"HeaderNewAccount": "Nový účet",
+ "HeaderNewApiKey": "Nový API klíč",
"HeaderNewLibrary": "Nová knihovna",
"HeaderNotificationCreate": "Vytvořit notifikaci",
"HeaderNotificationUpdate": "Aktualizovat notifikaci",
@@ -206,6 +210,7 @@
"HeaderTableOfContents": "Obsah",
"HeaderTools": "Nástroje",
"HeaderUpdateAccount": "Aktualizovat účet",
+ "HeaderUpdateApiKey": "Aktualizovat API klíč",
"HeaderUpdateAuthor": "Aktualizovat autora",
"HeaderUpdateDetails": "Aktualizovat podrobnosti",
"HeaderUpdateLibrary": "Aktualizovat knihovnu",
@@ -235,6 +240,10 @@
"LabelAllUsersExcludingGuests": "Všichni uživatelé kromě hostů",
"LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů",
"LabelAlreadyInYourLibrary": "Již ve vaší knihovně",
+ "LabelApiKeyCreated": "API klíč \"{0}\" byl úspěšně vytvořen.",
+ "LabelApiKeyCreatedDescription": "Zkopírujte si API klíč nyní, později již nebude možné jej zobrazit.",
+ "LabelApiKeyUser": "Vydávat se za uživatele",
+ "LabelApiKeyUserDescription": "Tento API klíč bude mít stejná oprávnění jako uživatel za něhož vystupuje. V protokolech to bude vypadat jako kdyby požadavky vytvářel přímo daný uživatel.",
"LabelApiToken": "API Token",
"LabelAppend": "Připojit",
"LabelAudioBitrate": "Bitový tok zvuku (např. 128k)",
@@ -346,6 +355,10 @@
"LabelExample": "Příklad",
"LabelExpandSeries": "Rozbalit série",
"LabelExpandSubSeries": "Rozbalit podsérie",
+ "LabelExpired": "Expirovaný",
+ "LabelExpiresAt": "Expiruje v",
+ "LabelExpiresInSeconds": "Expiruje za (sekundy)",
+ "LabelExpiresNever": "Nikdy",
"LabelExplicit": "Explicitně",
"LabelExplicitChecked": "Explicitní (zaškrtnuto)",
"LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)",
@@ -455,6 +468,7 @@
"LabelNewestEpisodes": "Nejnovější epizody",
"LabelNextBackupDate": "Datum příští zálohy",
"LabelNextScheduledRun": "Další naplánované spuštění",
+ "LabelNoApiKeys": "Žádné API klíče",
"LabelNoCustomMetadataProviders": "Žádní vlastní poskytovatelé metadat",
"LabelNoEpisodesSelected": "Nebyly vybrány žádné epizody",
"LabelNotFinished": "Nedokončeno",
@@ -544,6 +558,7 @@
"LabelSelectAll": "Vybrat vše",
"LabelSelectAllEpisodes": "Vybrat všechny epizody",
"LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují",
+ "LabelSelectUser": "Vybrat uživatele",
"LabelSelectUsers": "Vybrat uživatele",
"LabelSendEbookToDevice": "Odeslat e-knihu do...",
"LabelSequence": "Sekvence",
@@ -708,7 +723,9 @@
"MessageAddToPlayerQueue": "Přidat do fronty přehrávače",
"MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci Apprise API nebo API, které bude zpracovávat stejné požadavky.
Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese http://192.168.1.1:8337 pak byste měli zadat http://192.168.1.1:8337/notify.",
"MessageAsinCheck": "Ujistěte se, že používáte ASIN ze správného regionu Audible a ne z Amazonu.",
+ "MessageAuthenticationLegacyTokenWarning": "Zastaralé API tokeny budou v budoucnu odstraněny. Použijte místo nich API klíče.",
"MessageAuthenticationOIDCChangesRestart": "Po uložení restartujte server, aby se změny OIDC použily.",
+ "MessageAuthenticationSecurityMessage": "Bezpečnost autentizace byla vylepšena. Všichni uživatelé se musí znovu přihlásit.",
"MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v /metadata/items a /metadata/authors. Zálohy ne zahrnují všechny soubory uložené ve složkách knihovny.",
"MessageBackupsLocationEditNote": "Poznámka: Změna umístění záloh nepřesune ani nezmění existující zálohy",
"MessageBackupsLocationNoEditNote": "Poznámka: Umístění záloh je nastavené z proměnných prostředí a nelze zde změnit.",
@@ -730,6 +747,7 @@
"MessageChaptersNotFound": "Kapitoly nenalezeny",
"MessageCheckingCron": "Kontrola cronu...",
"MessageConfirmCloseFeed": "Opravdu chcete zavřít tento kanál?",
+ "MessageConfirmDeleteApiKey": "Opravdu chcete vymazat API klíč \"{0}\"?",
"MessageConfirmDeleteBackup": "Opravdu chcete smazat zálohu pro {0}?",
"MessageConfirmDeleteDevice": "Opravdu chcete vymazat zařízení e-reader \"{0}\"?",
"MessageConfirmDeleteFile": "Tento krok smaže soubor ze souborového systému. Jsi si jisti?",
@@ -1001,6 +1019,8 @@
"ToastEpisodeDownloadQueueClearSuccess": "Fronta stahování epizod je prázdná",
"ToastEpisodeUpdateSuccess": "{0} epizod aktualizováno",
"ToastErrorCannotShare": "Na tomto zařízení nelze nativně sdílet",
+ "ToastFailedToCreate": "Nepodařilo se vytvořit",
+ "ToastFailedToDelete": "Nepodařilo se odstranit",
"ToastFailedToLoadData": "Nepodařilo se načíst data",
"ToastFailedToMatch": "Nepodařilo se spárovat",
"ToastFailedToShare": "Sdílení selhalo",
@@ -1032,6 +1052,7 @@
"ToastMustHaveAtLeastOnePath": "Musí mít minimálně jednu cestu",
"ToastNameEmailRequired": "Jméno a email jsou vyžadovány",
"ToastNameRequired": "Jméno je vyžadováno",
+ "ToastNewApiKeyUserError": "Je nutné vybrat uživatele",
"ToastNewEpisodesFound": "{0} nových epizod bylo nalezeno",
"ToastNewUserCreatedFailed": "Chyba při vytváření účtu: \"{0}\"",
"ToastNewUserCreatedSuccess": "Vytvořen nový účet",
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index d927e3fd0..c65e0996e 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -438,6 +438,7 @@
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelLowestPriority": "Lowest Priority",
+ "LabelMatchConfidence": "Confidence",
"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",
"LabelMaxEpisodesToDownload": "Max # of episodes to download. Use 0 for unlimited.",
diff --git a/client/strings/fr.json b/client/strings/fr.json
index 03a0cdeef..60ae3fe9b 100644
--- a/client/strings/fr.json
+++ b/client/strings/fr.json
@@ -1,5 +1,6 @@
{
"ButtonAdd": "Ajouter",
+ "ButtonAddApiKey": "Ajouter une clé API",
"ButtonAddChapters": "Ajouter des chapitres",
"ButtonAddDevice": "Ajouter un appareil",
"ButtonAddLibrary": "Ajouter une bibliothèque",
@@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Sélectionner un dossier",
"ButtonChooseFiles": "Sélectionner des fichiers",
"ButtonClearFilter": "Effacer le filtre",
+ "ButtonClose": "Fermer",
"ButtonCloseFeed": "Fermer le flux",
"ButtonCloseSession": "Fermer la session",
"ButtonCollections": "Collections",
@@ -119,6 +121,7 @@
"HeaderAccount": "Compte",
"HeaderAddCustomMetadataProvider": "Ajouter un fournisseur de métadonnées personnalisé",
"HeaderAdvanced": "Avancé",
+ "HeaderApiKeys": "Clés API",
"HeaderAppriseNotificationSettings": "Configuration des notifications Apprise",
"HeaderAudioTracks": "Pistes audio",
"HeaderAudiobookTools": "Outils de gestion de fichiers de livres audio",
@@ -162,6 +165,7 @@
"HeaderMetadataOrderOfPrecedence": "Ordre de priorité des métadonnées",
"HeaderMetadataToEmbed": "Métadonnées à intégrer",
"HeaderNewAccount": "Nouveau compte",
+ "HeaderNewApiKey": "Nouvelle clé API",
"HeaderNewLibrary": "Nouvelle bibliothèque",
"HeaderNotificationCreate": "Créer une notification",
"HeaderNotificationUpdate": "Mise à jour de la notification",
@@ -177,6 +181,7 @@
"HeaderPlaylist": "Liste de lecture",
"HeaderPlaylistItems": "Éléments de la liste de lecture",
"HeaderPodcastsToAdd": "Podcasts à ajouter",
+ "HeaderPresets": "Préréglages",
"HeaderPreviewCover": "Prévisualiser la couverture",
"HeaderRSSFeedGeneral": "Détails du flux RSS",
"HeaderRSSFeedIsOpen": "Le flux RSS est actif",
@@ -205,6 +210,7 @@
"HeaderTableOfContents": "Table des matières",
"HeaderTools": "Outils",
"HeaderUpdateAccount": "Mettre à jour le compte",
+ "HeaderUpdateApiKey": "Mettre à jour la clé API",
"HeaderUpdateAuthor": "Mettre à jour l’auteur",
"HeaderUpdateDetails": "Mettre à jour les détails",
"HeaderUpdateLibrary": "Mettre à jour la bibliothèque",
@@ -234,6 +240,10 @@
"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",
+ "LabelApiKeyCreated": "La clé API « {0} » a été créée avec succès.",
+ "LabelApiKeyCreatedDescription": "Assurez-vous de copier la clé API maintenant car vous ne pourrez plus la voir.",
+ "LabelApiKeyUser": "Agir au nom de l’utilisateur",
+ "LabelApiKeyUserDescription": "Cette clé API disposera des mêmes autorisations que l’utilisateur pour lequel elle agit. Elle apparaîtra dans les journaux comme si c’était l’utilisateur qui effectuait la requête.",
"LabelApiToken": "Token API",
"LabelAppend": "Ajouter",
"LabelAudioBitrate": "Débit audio (par exemple 128k)",
@@ -345,6 +355,10 @@
"LabelExample": "Exemple",
"LabelExpandSeries": "Développer la série",
"LabelExpandSubSeries": "Développer les sous-séries",
+ "LabelExpired": "Expiré",
+ "LabelExpiresAt": "Expire à",
+ "LabelExpiresInSeconds": "Expire dans (secondes)",
+ "LabelExpiresNever": "Jamais",
"LabelExplicit": "Restriction",
"LabelExplicitChecked": "Explicite (vérifié)",
"LabelExplicitUnchecked": "Non explicite (non vérifié)",
@@ -454,6 +468,7 @@
"LabelNewestEpisodes": "Épisodes récents",
"LabelNextBackupDate": "Date de la prochaine sauvegarde",
"LabelNextScheduledRun": "Prochain lancement prévu",
+ "LabelNoApiKeys": "Aucune clé API",
"LabelNoCustomMetadataProviders": "Aucun fournisseurs de métadonnées personnalisés",
"LabelNoEpisodesSelected": "Aucun épisode sélectionné",
"LabelNotFinished": "Non terminé",
@@ -543,6 +558,7 @@
"LabelSelectAll": "Tout sélectionner",
"LabelSelectAllEpisodes": "Sélectionner tous les épisodes",
"LabelSelectEpisodesShowing": "Sélectionner {0} épisode(s) en cours",
+ "LabelSelectUser": "Sélectionner l’utilisateur",
"LabelSelectUsers": "Sélectionner les utilisateurs",
"LabelSendEbookToDevice": "Envoyer le livre numérique à…",
"LabelSequence": "Séquence",
@@ -707,7 +723,9 @@
"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.",
"MessageAsinCheck": "Assurez-vous d’utiliser l’ASIN de la bonne région Audible, et non d’Amazon.",
+ "MessageAuthenticationLegacyTokenWarning": "Les jetons d’API hérités seront supprimés à l’avenir. Utilisez plutôt les clés API.",
"MessageAuthenticationOIDCChangesRestart": "Redémarrez votre serveur après avoir enregistré pour appliquer les modifications OIDC.",
+ "MessageAuthenticationSecurityMessage": "L’authentification a été améliorée pour plus de sécurité. Tous les utilisateurs doivent se reconnecter.",
"MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression des utilisateurs, les détails des éléments de la bibliothèque, les paramètres du serveur et les images stockées dans /metadata/items & /metadata/authors. Les sauvegardes n’incluent pas les fichiers stockés dans les dossiers de votre bibliothèque.",
"MessageBackupsLocationEditNote": "Remarque : Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes",
"MessageBackupsLocationNoEditNote": "Remarque : l’emplacement de sauvegarde est défini via une variable d’environnement et ne peut pas être modifié ici.",
@@ -729,6 +747,7 @@
"MessageChaptersNotFound": "Chapitres non trouvés",
"MessageCheckingCron": "Vérification du cron…",
"MessageConfirmCloseFeed": "Êtes-vous sûr·e de vouloir fermer ce flux ?",
+ "MessageConfirmDeleteApiKey": "Êtes-vous sûr de vouloir supprimer la clé API « {0} » ?",
"MessageConfirmDeleteBackup": "Êtes-vous sûr·e de vouloir supprimer la sauvegarde de « {0} » ?",
"MessageConfirmDeleteDevice": "Êtes-vous sûr·e de vouloir supprimer la liseuse « {0} » ?",
"MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?",
@@ -756,6 +775,7 @@
"MessageConfirmRemoveAuthor": "Êtes-vous sûr·e de vouloir supprimer l’auteur « {0} » ?",
"MessageConfirmRemoveCollection": "Êtes-vous sûr·e de vouloir supprimer la collection « {0} » ?",
"MessageConfirmRemoveEpisode": "Êtes-vous sûr·e de vouloir supprimer l’épisode « {0} » ?",
+ "MessageConfirmRemoveEpisodeNote": "Remarque : cela ne supprime pas le fichier audio, sauf si vous activez « Supprimer définitivement le fichier »",
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr·e de vouloir supprimer {0} épisodes ?",
"MessageConfirmRemoveListeningSessions": "Êtes-vous sûr·e de vouloir supprimer {0} sessions d’écoute ?",
"MessageConfirmRemoveMetadataFiles": "Êtes-vous sûr·e de vouloir supprimer tous les fichiers « metatadata.{0} » des dossiers d’éléments de votre bibliothèque ?",
@@ -917,6 +937,8 @@
"NotificationOnBackupCompletedDescription": "Déclenché lorsqu’une sauvegarde est terminée",
"NotificationOnBackupFailedDescription": "Déclenché lorsqu'une sauvegarde échoue",
"NotificationOnEpisodeDownloadedDescription": "Déclenché lorsqu’un épisode de podcast est téléchargé automatiquement",
+ "NotificationOnRSSFeedDisabledDescription": "Déclenché lorsque les téléchargements automatiques d’épisodes sont désactivés en raison d’un trop grand nombre de tentatives infructueuses",
+ "NotificationOnRSSFeedFailedDescription": "Déclenché lorsque la demande de flux RSS échoue pour un téléchargement automatique d’épisode",
"NotificationOnTestDescription": "Événement pour tester le système de notification",
"PlaceholderNewCollection": "Nom de la nouvelle collection",
"PlaceholderNewFolderPath": "Nouveau chemin de dossier",
@@ -997,6 +1019,8 @@
"ToastEpisodeDownloadQueueClearSuccess": "File d’attente de téléchargement des épisodes effacée",
"ToastEpisodeUpdateSuccess": "{0} épisodes mis à jour",
"ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil",
+ "ToastFailedToCreate": "Échec de la création",
+ "ToastFailedToDelete": "Échec de la suppression",
"ToastFailedToLoadData": "Échec du chargement des données",
"ToastFailedToMatch": "Échec de la correspondance",
"ToastFailedToShare": "Échec du partage",
@@ -1028,6 +1052,7 @@
"ToastMustHaveAtLeastOnePath": "Doit avoir au moins un chemin",
"ToastNameEmailRequired": "Le nom et le courriel sont requis",
"ToastNameRequired": "Le nom est requis",
+ "ToastNewApiKeyUserError": "Vous devez sélectionner un utilisateur",
"ToastNewEpisodesFound": "{0} nouveaux épisodes trouvés",
"ToastNewUserCreatedFailed": "La création du compte à échouée : « {0} »",
"ToastNewUserCreatedSuccess": "Nouveau compte créé",
diff --git a/client/strings/hr.json b/client/strings/hr.json
index 40d9cf5b0..5289f803c 100644
--- a/client/strings/hr.json
+++ b/client/strings/hr.json
@@ -723,6 +723,7 @@
"MessageAddToPlayerQueue": "Dodaj u redoslijed izvođenja",
"MessageAppriseDescription": "Da biste se koristili ovom značajkom, treba vam instanca Apprise API-ja ili API koji može rukovati istom vrstom zahtjeva.
The Adresa Apprise API-ja treba biti puna URL putanja za slanje obavijesti, npr. ako vam se API instanca poslužuje na adresi http://192.168.1.1:8337 trebate upisati http://192.168.1.1:8337/notify.",
"MessageAsinCheck": "Upišite ASIN iz odgovarajuće Audibleove regije, ne s Amazonov.",
+ "MessageAuthenticationLegacyTokenWarning": "Starije API tokene ćemo ukloniti. Umjesto njih, koristite se API ključevima .",
"MessageAuthenticationOIDCChangesRestart": "Ponovno pokrenite poslužitelj da biste primijenili OIDC promjene.",
"MessageAuthenticationSecurityMessage": "Provjera autentičnosti poboljšana je radi sigurnosti. Svi se korisnici moraju ponovno prijaviti.",
"MessageBackupsDescription": "Sigurnosne kopije sadrže korisnike, korisnikov napredak medija, pojedinosti knjižničke građe, postavke poslužitelja i slike koje se spremaju u /metadata/items & /metadata/authors. Sigurnosne kopije ne sadrže niti jednu datoteku iz mapa knjižnice.",
diff --git a/client/strings/it.json b/client/strings/it.json
index 77e4faeed..7f4fd7c7a 100644
--- a/client/strings/it.json
+++ b/client/strings/it.json
@@ -1,18 +1,19 @@
{
"ButtonAdd": "Aggiungi",
+ "ButtonAddApiKey": "Aggiungi chiave API",
"ButtonAddChapters": "Aggiungi Capitoli",
"ButtonAddDevice": "Aggiungi Dispositivo",
"ButtonAddLibrary": "Aggiungi Libreria",
"ButtonAddPodcasts": "Aggiungi Podcast",
- "ButtonAddUser": "Aggiungi User",
+ "ButtonAddUser": "Aggiungi Utente",
"ButtonAddYourFirstLibrary": "Aggiungi la tua prima libreria",
"ButtonApply": "Applica",
- "ButtonApplyChapters": "Applica",
+ "ButtonApplyChapters": "Applica Capitoli",
"ButtonAuthors": "Autori",
"ButtonBack": "Indietro",
"ButtonBatchEditPopulateFromExisting": "Popola da esistente",
"ButtonBatchEditPopulateMapDetails": "Inserisci i dettagli della mappa",
- "ButtonBrowseForFolder": "Per Cartella",
+ "ButtonBrowseForFolder": "Sfoglia per Cartella",
"ButtonCancel": "Annulla",
"ButtonCancelEncode": "Ferma la codifica",
"ButtonChangeRootPassword": "Cambia la Password di root",
@@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Seleziona la Cartella",
"ButtonChooseFiles": "Seleziona i File",
"ButtonClearFilter": "Elimina filtri",
+ "ButtonClose": "Chiudi",
"ButtonCloseFeed": "Chiudi flusso",
"ButtonCloseSession": "Chiudi la sessione aperta",
"ButtonCollections": "Raccolte",
diff --git a/client/strings/ru.json b/client/strings/ru.json
index 3971365c3..ce03bdc4a 100644
--- a/client/strings/ru.json
+++ b/client/strings/ru.json
@@ -357,7 +357,7 @@
"LabelExpandSubSeries": "Развернуть подсерию",
"LabelExpired": "Истекший",
"LabelExpiresAt": "Истекает в",
- "LabelExpiresInSeconds": "Истекает через (seconds)",
+ "LabelExpiresInSeconds": "Истекает через (секунд)",
"LabelExpiresNever": "Никогда",
"LabelExplicit": "18+",
"LabelExplicitChecked": "18+ (отмечено)",
diff --git a/client/strings/tr.json b/client/strings/tr.json
index 1f331a3e5..13b2bdf04 100644
--- a/client/strings/tr.json
+++ b/client/strings/tr.json
@@ -1,5 +1,6 @@
{
"ButtonAdd": "Ekle",
+ "ButtonAddApiKey": "API Anahtarı Ekle",
"ButtonAddChapters": "Bölüm Ekle",
"ButtonAddDevice": "Cihaz Ekle",
"ButtonAddLibrary": "Kütüphane Ekle",
@@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Klasör seç",
"ButtonChooseFiles": "Dosya seç",
"ButtonClearFilter": "Filtreyi Temizle",
+ "ButtonClose": "Kapat",
"ButtonCloseFeed": "Akışı Kapat",
"ButtonCloseSession": "Acık Oturumu Kapat",
"ButtonCollections": "Koleksiyonlar",
@@ -95,7 +97,17 @@
"ButtonSearch": "Ara",
"ButtonSelectFolderPath": "Klasör Yolunu Seç",
"ButtonSeries": "Seriler",
+ "ButtonShare": "Paylaş",
+ "ButtonStats": "İstatistikler",
"ButtonSubmit": "Gönder",
+ "ButtonTest": "Dene",
+ "ButtonUnlinkOpenId": "OpenID ilişiğini kaldır",
+ "ButtonUpload": "Yükle",
+ "ButtonUploadBackup": "Yedeği Yükle",
+ "ButtonUploadCover": "Kapağı Yükle",
+ "ButtonUploadOPMLFile": "OPML Dosyası Yükle",
+ "ButtonUserDelete": "{0} kullanıcısını sil.",
+ "ButtonUserEdit": "{0} kullanıcısını düzenle",
"ButtonViewAll": "Tümünü Görüntüle",
"ButtonYes": "Evet",
"ErrorUploadFetchMetadataAPI": "Üst veriyi almakta hata",
@@ -104,6 +116,7 @@
"HeaderAccount": "Hesap",
"HeaderAddCustomMetadataProvider": "Özel Üstveri Sağlayıcısı Ekle",
"HeaderAdvanced": "Gelişmiş",
+ "HeaderApiKeys": "API Anahtarları",
"HeaderAppriseNotificationSettings": "Bildirim Ayarlarının Haberini Ver",
"HeaderAudioTracks": "Ses Kanalları",
"HeaderAudiobookTools": "Sesli Kitap Dosya Yönetim Araçları",
@@ -111,13 +124,23 @@
"HeaderBackups": "Yedeklemeler",
"HeaderChangePassword": "Parolayı Değiştir",
"HeaderChapters": "Bölümler",
+ "HeaderChooseAFolder": "Klasör Seç",
"HeaderCollection": "Koleksiyon",
"HeaderCollectionItems": "Koleksiyon Öğeleri",
+ "HeaderCover": "Kapak",
+ "HeaderCurrentDownloads": "Geçerli İndirmeler",
+ "HeaderCustomMessageOnLogin": "Girişteki Kişiselleştirilmiş Mesaj",
+ "HeaderCustomMetadataProviders": "Kişiselleştirilmiş Metadata Sağlayıcıları",
"HeaderDetails": "Detaylar",
+ "HeaderDownloadQueue": "Kuyruktakileri İndir",
"HeaderEbookFiles": "Ebook Dosyaları",
+ "HeaderEmail": "Email",
+ "HeaderEmailSettings": "Email Ayarları",
"HeaderEpisodes": "Bölümler",
+ "HeaderEreaderDevices": "Ekitap Cihazları",
"HeaderEreaderSettings": "Ereader Ayarları",
"HeaderFiles": "Dosyalar",
+ "HeaderFindChapters": "Bölümleri Bul",
"HeaderIgnoredFiles": "Görmezden Gelinen Dosyalar",
"HeaderItemFiles": "Öğe Dosyaları",
"HeaderItemMetadataUtils": "Öğe Üstveri Araçları",
diff --git a/client/strings/uk.json b/client/strings/uk.json
index 10724bad3..c7f760892 100644
--- a/client/strings/uk.json
+++ b/client/strings/uk.json
@@ -723,6 +723,7 @@
"MessageAddToPlayerQueue": "Додати до черги відтворення",
"MessageAppriseDescription": "Щоб скористатися цією функцією, вам потрібно мати запущену Apprise API або API, що оброблятиме ті ж запити.
Аби надсилати сповіщення, URL-адреса API Apprise мусить бути повною, наприклад, якщо ваш API розміщено за адресою http://192.168.1.1:8337, то необхідно вказати адресу http://192.168.1.1:8337/notify.",
"MessageAsinCheck": "Переконайтесь, що ви використовуєте ASIN з правильної регіональної Audible зони, а не з Amazon.",
+ "MessageAuthenticationLegacyTokenWarning": "Застарілі токени API будуть видалені в майбутньому. Натомість використовуйте Ключі API.",
"MessageAuthenticationOIDCChangesRestart": "Перезавантажте сервер після збереження, щоб застосувати зміни OIDC.",
"MessageAuthenticationSecurityMessage": "Автентифікацію покращено для безпеки. Усім користувачам потрібно повторно увійти в систему.",
"MessageBackupsDescription": "Резервні копії містять користувачів, прогрес, подробиці елементів бібліотеки, налаштування сервера та зображення з /metadata/items та /metadata/authors. Резервні копії не містять жодних файлів з тек бібліотеки.",
@@ -836,7 +837,7 @@
"MessageNoItems": "Елементи відсутні",
"MessageNoItemsFound": "Елементів не знайдено",
"MessageNoListeningSessions": "Сеанси прослуховування відсутні",
- "MessageNoLogs": "Немає журналів",
+ "MessageNoLogs": "Немає журнали",
"MessageNoMediaProgress": "Прогрес відсутній",
"MessageNoNotifications": "Сповіщення відсутні",
"MessageNoPodcastFeed": "Некоректний подкаст: немає каналу",
diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json
index 84ae64a9f..5138e84e3 100644
--- a/client/strings/zh-cn.json
+++ b/client/strings/zh-cn.json
@@ -240,10 +240,10 @@
"LabelAllUsersExcludingGuests": "除访客外的所有用户",
"LabelAllUsersIncludingGuests": "包括访客的所有用户",
"LabelAlreadyInYourLibrary": "已存在你的库中",
- "LabelApiKeyCreated": "API 密钥 \"{0}\" 创建成功。",
- "LabelApiKeyCreatedDescription": "请确保现在就复制 API 密钥,之后将无法再次查看。",
+ "LabelApiKeyCreated": "API 密钥 \"{0}\" 创建成功.",
+ "LabelApiKeyCreatedDescription": "请确保现在就复制 API 密钥, 之后将无法再次查看.",
"LabelApiKeyUser": "代用户操作",
- "LabelApiKeyUserDescription": "此 API 密钥将具有与其代理的用户相同的权限。在日志中,其请求将被视为由该用户直接发出。",
+ "LabelApiKeyUserDescription": "此 API 密钥将具有与其代理的用户相同的权限. 在日志中, 其请求将被视为由该用户直接发出.",
"LabelApiToken": "API 令牌",
"LabelAppend": "附加",
"LabelAudioBitrate": "音频比特率 (例如: 128k)",
@@ -329,7 +329,7 @@
"LabelEmailSettingsRejectUnauthorized": "拒绝未经授权的证书",
"LabelEmailSettingsRejectUnauthorizedHelp": "禁用SSL证书验证可能会使你的连接面临安全风险, 例如中间人攻击. 只有当你了解其中的含义并信任所连接的邮件服务器时, 才能禁用此选项.",
"LabelEmailSettingsSecure": "安全",
- "LabelEmailSettingsSecureHelp": "开启此选项时,将始终通过TLS连接服务器。关闭此选项时,仅在服务器支持STARTTLS扩展时使用TLS。在大多数情况下,如果连接到端口465,请将此项设为开启。如果连接到端口587或25,请将此设置保持为关闭。(来自nodemailer.com/smtp/#authentication)",
+ "LabelEmailSettingsSecureHelp": "开启此选项时, 将始终通过TLS连接服务器. 关闭此选项时, 仅在服务器支持STARTTLS扩展时使用TLS. 在大多数情况下, 如果连接到端口465, 请将此项设为开启. 如果连接到端口587或25, 请将此设置保持为关闭. (来自nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "测试地址",
"LabelEmbeddedCover": "嵌入封面",
"LabelEnable": "启用",
@@ -357,10 +357,10 @@
"LabelExpandSubSeries": "展开子系列",
"LabelExpired": "已过期",
"LabelExpiresAt": "过期时间",
- "LabelExpiresInSeconds": "有效期(秒)",
+ "LabelExpiresInSeconds": "有效期 (秒)",
"LabelExpiresNever": "从不",
"LabelExplicit": "含成人内容",
- "LabelExplicitChecked": "成人内容(已核实)",
+ "LabelExplicitChecked": "成人内容 (已核实)",
"LabelExplicitUnchecked": "无成人内容 (未核实)",
"LabelExportOPML": "导出 OPML",
"LabelFeedURL": "源 URL",
@@ -723,14 +723,15 @@
"MessageAddToPlayerQueue": "添加到播放队列",
"MessageAppriseDescription": "要使用此功能,你需要运行一个 Apprise API 实例或一个可以处理这些相同请求的 API.
Apprise API Url 应该是发送通知的完整 URL 路径, 例如: 如果你的 API 实例运行在 http://192.168.1.1:8337, 那么你可以输入 http://192.168.1.1:8337/notify.",
"MessageAsinCheck": "确保你使用的 ASIN 来自正确的 Audible 地区, 而不是亚马逊.",
+ "MessageAuthenticationLegacyTokenWarning": "旧版 API 令牌将来会被移除. 请改用 API 密钥.",
"MessageAuthenticationOIDCChangesRestart": "保存后重新启动服务器以应用 OIDC 更改.",
- "MessageAuthenticationSecurityMessage": "身份验证安全性已增强,所有用户都需要重新登录。",
+ "MessageAuthenticationSecurityMessage": "身份验证安全性已增强, 所有用户都需要重新登录.",
"MessageBackupsDescription": "备份包括用户, 用户进度, 媒体库项目详细信息, 服务器设置和图像, 存储在 /metadata/items & /metadata/authors. 备份不包括存储在你的媒体库文件夹中的任何文件.",
"MessageBackupsLocationEditNote": "注意: 更新备份位置不会移动或修改现有备份",
"MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.",
"MessageBackupsLocationPathEmpty": "备份位置路径不能为空",
"MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并",
- "MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息,填入上方所有勾选的编辑框中",
+ "MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息, 填入上方所有勾选的编辑框中",
"MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.",
"MessageBookshelfNoCollections": "你尚未进行任何收藏",
"MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.",
@@ -746,7 +747,7 @@
"MessageChaptersNotFound": "未找到章节",
"MessageCheckingCron": "检查计划任务...",
"MessageConfirmCloseFeed": "你确定要关闭此订阅源吗?",
- "MessageConfirmDeleteApiKey": "你确定要删除 API 密钥 \"{0}\" 吗?",
+ "MessageConfirmDeleteApiKey": "你确定要删除 API 密钥 \"{0}\" 吗?",
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
"MessageConfirmDeleteDevice": "你确定要删除电子阅读器设备 \"{0}\" 吗?",
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
@@ -774,7 +775,7 @@
"MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?",
"MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?",
"MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?",
- "MessageConfirmRemoveEpisodeNote": "注意:此操作不会删除音频文件,除非勾选“完全删除文件”选项",
+ "MessageConfirmRemoveEpisodeNote": "注意: 此操作不会删除音频文件, 除非勾选 \"完全删除文件\" 选项",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
"MessageConfirmRemoveListeningSessions": "你确定要移除 {0} 收听会话吗?",
"MessageConfirmRemoveMetadataFiles": "你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?",
@@ -866,7 +867,7 @@
"MessageRemoveEpisodes": "移除 {0} 剧集",
"MessageRemoveFromPlayerQueue": "从播放队列中移除",
"MessageRemoveUserWarning": "是否确实要永久删除用户 \"{0}\"?",
- "MessageReportBugsAndContribute": "反馈问题、建议功能或参与贡献,请访问",
+ "MessageReportBugsAndContribute": "反馈问题, 建议功能或参与贡献, 请访问",
"MessageResetChaptersConfirm": "你确定要重置章节并撤消你所做的更改吗?",
"MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份",
"MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.
备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.
将自动刷新使用服务器的所有客户端.",
diff --git a/package-lock.json b/package-lock.json
index cdf40fe4e..10e3389e3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
- "version": "2.26.1",
+ "version": "2.26.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
- "version": "2.26.0",
+ "version": "2.26.2",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
diff --git a/package.json b/package.json
index 85264c1f1..5e7d4bc34 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "2.26.1",
+ "version": "2.26.2",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js
index 8fde7bc49..2d7b57f14 100644
--- a/server/finders/BookFinder.js
+++ b/server/finders/BookFinder.js
@@ -7,7 +7,7 @@ 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')
+const { levenshteinDistance, levenshteinSimilarity, escapeRegExp, isValidASIN } = require('../utils/index')
const htmlSanitizer = require('../utils/htmlSanitizer')
class BookFinder {
@@ -385,7 +385,11 @@ class BookFinder {
if (!title) return books
- books = await this.runSearch(title, author, provider, asin, maxTitleDistance, maxAuthorDistance)
+ const isTitleAsin = isValidASIN(title.toUpperCase())
+
+ let actualTitleQuery = title
+ let actualAuthorQuery = author
+ books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance)
if (!books.length && maxFuzzySearches > 0) {
// Normalize title and author
@@ -408,19 +412,26 @@ class BookFinder {
for (const titlePart of titleParts) titleCandidates.add(titlePart)
titleCandidates = titleCandidates.getCandidates()
for (const titleCandidate of titleCandidates) {
- if (titleCandidate == title && authorCandidate == author) continue // We already tried this
+ if (titleCandidate == actualTitleQuery && authorCandidate == actualAuthorQuery) continue // We already tried this
if (++numFuzzySearches > maxFuzzySearches) break loop_author
- books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance)
+ actualTitleQuery = titleCandidate
+ actualAuthorQuery = authorCandidate
+ books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance)
if (books.length) break loop_author
}
}
}
if (books.length) {
- const resultsHaveDuration = provider.startsWith('audible')
- if (resultsHaveDuration && libraryItem?.media?.duration) {
- const libraryItemDurationMinutes = libraryItem.media.duration / 60
- // If provider results have duration, sort by ascendinge duration difference from libraryItem
+ const isAudibleProvider = provider.startsWith('audible')
+ const libraryItemDurationMinutes = libraryItem?.media?.duration ? libraryItem.media.duration / 60 : null
+
+ books.forEach((book) => {
+ if (typeof book !== 'object' || !isAudibleProvider) return
+ book.matchConfidence = this.calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin)
+ })
+
+ if (isAudibleProvider && libraryItemDurationMinutes) {
books.sort((a, b) => {
const aDuration = a.duration || Number.POSITIVE_INFINITY
const bDuration = b.duration || Number.POSITIVE_INFINITY
@@ -433,6 +444,120 @@ class BookFinder {
return books
}
+ /**
+ * Calculate match confidence score for a book
+ * @param {Object} book - The book object to calculate confidence for
+ * @param {number|null} libraryItemDurationMinutes - Duration of library item in minutes
+ * @param {string} actualTitleQuery - Actual title query
+ * @param {string} actualAuthorQuery - Actual author query
+ * @param {boolean} isTitleAsin - Whether the title is an ASIN
+ * @returns {number|null} - Match confidence score or null if not applicable
+ */
+ calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin) {
+ // ASIN results are always a match
+ if (isTitleAsin) return 1.0
+
+ let durationScore
+ if (libraryItemDurationMinutes && typeof book.duration === 'number') {
+ const durationDiff = Math.abs(book.duration - libraryItemDurationMinutes)
+ // Duration scores:
+ // diff | score
+ // 0 | 1.0
+ // 1 | 1.0
+ // 2 | 0.9
+ // 3 | 0.8
+ // 4 | 0.7
+ // 5 | 0.6
+ // 6 | 0.48
+ // 7 | 0.36
+ // 8 | 0.24
+ // 9 | 0.12
+ // 10 | 0.0
+ if (durationDiff <= 1) {
+ // Covers durationDiff = 0 for score 1.0
+ durationScore = 1.0
+ } else if (durationDiff <= 5) {
+ // (1, 5] - Score from 1.0 down to 0.6
+ // Linearly interpolates between (1, 1.0) and (5, 0.6)
+ // Equation: y = 1.0 - 0.08 * x
+ durationScore = 1.1 - 0.1 * durationDiff
+ } else if (durationDiff <= 10) {
+ // (5, 10] - Score from 0.6 down to 0.0
+ // Linearly interpolates between (5, 0.6) and (10, 0.0)
+ // Equation: y = 1.2 - 0.12 * x
+ durationScore = 1.2 - 0.12 * durationDiff
+ } else {
+ // durationDiff > 10 - Score is 0.0
+ durationScore = 0.0
+ }
+ Logger.debug(`[BookFinder] Duration diff: ${durationDiff}, durationScore: ${durationScore}`)
+ } else {
+ // Default score if library item duration or book duration is not available
+ durationScore = 0.1
+ }
+
+ const calculateTitleScore = (titleQuery, book, keepSubtitle = false) => {
+ const cleanTitle = cleanTitleForCompares(book.title || '', keepSubtitle)
+ const cleanSubtitle = keepSubtitle && book.subtitle ? `: ${book.subtitle}` : ''
+ const normBookTitle = `${cleanTitle}${cleanSubtitle}`
+ const normTitleQuery = cleanTitleForCompares(titleQuery, keepSubtitle)
+ const titleSimilarity = levenshteinSimilarity(normTitleQuery, normBookTitle)
+ Logger.debug(`[BookFinder] keepSubtitle: ${keepSubtitle}, normBookTitle: ${normBookTitle}, normTitleQuery: ${normTitleQuery}, titleSimilarity: ${titleSimilarity}`)
+ return titleSimilarity
+ }
+ const titleQueryHasSubtitle = hasSubtitle(actualTitleQuery)
+ const titleScore = calculateTitleScore(actualTitleQuery, book, titleQueryHasSubtitle)
+
+ let authorScore
+ const normAuthorQuery = cleanAuthorForCompares(actualAuthorQuery)
+ const normBookAuthor = cleanAuthorForCompares(book.author || '')
+ if (!normAuthorQuery) {
+ // Original query had no author
+ authorScore = 1.0 // Neutral score
+ } else {
+ // Original query HAS an author (cleanedQueryAuthorForScore is not empty)
+ if (normBookAuthor) {
+ const bookAuthorParts = normBookAuthor.split(',').map((name) => name.trim().toLowerCase())
+ // Filter out empty parts that might result from ", ," or trailing/leading commas
+ const validBookAuthorParts = bookAuthorParts.filter((p) => p.length > 0)
+
+ if (validBookAuthorParts.length === 0) {
+ // Book author string was present but effectively empty (e.g. ",,")
+ // Since cleanedQueryAuthorForScore is non-empty here, this is a mismatch.
+ authorScore = 0.0
+ } else {
+ let maxPartScore = levenshteinSimilarity(normAuthorQuery, normBookAuthor)
+ Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, normBookAuthor: ${normBookAuthor}, similarity: ${maxPartScore}`)
+ if (validBookAuthorParts.length > 1 || normBookAuthor.includes(',')) {
+ validBookAuthorParts.forEach((part) => {
+ // part is guaranteed to be non-empty here
+ // cleanedQueryAuthorForScore is also guaranteed non-empty here.
+ // levenshteinDistance lowercases by default, but part is already lowercased.
+ const similarity = levenshteinSimilarity(normAuthorQuery, part)
+ Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, bookAuthorPart: ${part}, similarity: ${similarity}`)
+ const currentPartScore = similarity
+ maxPartScore = Math.max(maxPartScore, currentPartScore)
+ })
+ }
+ authorScore = maxPartScore
+ }
+ } else {
+ // Book has NO author (or not a string, or empty string)
+ // Query has an author (cleanedQueryAuthorForScore is non-empty), book does not.
+ authorScore = 0.0
+ }
+ }
+
+ const W_DURATION = 0.7
+ const W_TITLE = 0.2
+ const W_AUTHOR = 0.1
+
+ Logger.debug(`[BookFinder] Duration score: ${durationScore}, Title score: ${titleScore}, Author score: ${authorScore}`)
+ const confidence = W_DURATION * durationScore + W_TITLE * titleScore + W_AUTHOR * authorScore
+ Logger.debug(`[BookFinder] Confidence: ${confidence}`)
+ return Math.max(0, Math.min(1, confidence))
+ }
+
/**
* Search for books
*
@@ -464,6 +589,7 @@ class BookFinder {
} else {
books = await this.getGoogleBooksResults(title, author)
}
+
books.forEach((book) => {
if (book.description) {
book.description = htmlSanitizer.sanitize(book.description)
@@ -505,6 +631,9 @@ class BookFinder {
}
module.exports = new BookFinder()
+function hasSubtitle(title) {
+ return title.includes(':') || title.includes(' - ')
+}
function stripSubtitle(title) {
if (title.includes(':')) {
return title.split(':')[0].trim()
@@ -523,12 +652,12 @@ function replaceAccentedChars(str) {
}
}
-function cleanTitleForCompares(title) {
+function cleanTitleForCompares(title, keepSubtitle = false) {
if (!title) return ''
title = stripRedundantSpaces(title)
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
- let stripped = stripSubtitle(title)
+ let stripped = keepSubtitle ? title : stripSubtitle(title)
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
let cleaned = stripped.replace(/ *\([^)]*\) */g, '')
diff --git a/server/utils/index.js b/server/utils/index.js
index 9f7d961c0..369620276 100644
--- a/server/utils/index.js
+++ b/server/utils/index.js
@@ -34,6 +34,14 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
}
module.exports.levenshteinDistance = levenshteinDistance
+const levenshteinSimilarity = (str1, str2, caseSensitive = false) => {
+ const distance = levenshteinDistance(str1, str2, caseSensitive)
+ const maxLength = Math.max(str1.length, str2.length)
+ if (maxLength === 0) return 1
+ return 1 - distance / maxLength
+}
+module.exports.levenshteinSimilarity = levenshteinSimilarity
+
module.exports.isObject = (val) => {
return val !== null && typeof val === 'object'
}
diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js
index c986cc986..6578ca82a 100644
--- a/test/server/finders/BookFinder.test.js
+++ b/test/server/finders/BookFinder.test.js
@@ -5,6 +5,12 @@ const bookFinder = require('../../../server/finders/BookFinder')
const { LogLevel } = require('../../../server/utils/constants')
const Logger = require('../../../server/Logger')
Logger.setLogLevel(LogLevel.INFO)
+const { levenshteinDistance } = require('../../../server/utils/index')
+
+// levenshteinDistance is needed for manual calculation of expected scores in tests.
+// Assuming it's accessible for testing purposes or we mock/replicate its basic behavior if needed.
+// For now, we'll assume bookFinder.search uses it internally correctly.
+// const { levenshteinDistance } = require('../../../server/utils/index') // Not used directly in test logic, but for reasoning.
describe('TitleCandidates', () => {
describe('cleanAuthor non-empty', () => {
@@ -326,31 +332,262 @@ describe('search', () => {
const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }]
beforeEach(() => {
- runSearchStub.withArgs(t, a, provider).resolves(unsorted)
+ runSearchStub.withArgs(t, a, provider).resolves(structuredClone(unsorted))
+ })
+
+ afterEach(() => {
+ sinon.restore()
})
it('returns results sorted by library item duration diff', async () => {
- expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted)
+ const result = (await bookFinder.search(libraryItem, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
+ expect(result).to.deep.equal(sorted)
})
it('returns unsorted results if library item is null', async () => {
- expect(await bookFinder.search(null, provider, t, a)).to.deep.equal(unsorted)
+ const result = (await bookFinder.search(null, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
+ expect(result).to.deep.equal(unsorted)
})
it('returns unsorted results if library item duration is undefined', async () => {
- expect(await bookFinder.search({ media: {} }, provider, t, a)).to.deep.equal(unsorted)
+ const result = (await bookFinder.search({ media: {} }, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
+ expect(result).to.deep.equal(unsorted)
})
it('returns unsorted results if library item media is undefined', async () => {
- expect(await bookFinder.search({}, provider, t, a)).to.deep.equal(unsorted)
+ const result = (await bookFinder.search({}, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
+ expect(result).to.deep.equal(unsorted)
})
it('should return a result last if it has no duration', async () => {
const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]
const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}]
- runSearchStub.withArgs(t, a, provider).resolves(unsorted)
+ runSearchStub.withArgs(t, a, provider).resolves(structuredClone(unsorted))
+ const result = (await bookFinder.search(libraryItem, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
+ expect(result).to.deep.equal(sorted)
+ })
+ })
- expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted)
+ describe('matchConfidence score', () => {
+ const W_DURATION = 0.7
+ const W_TITLE = 0.2
+ const W_AUTHOR = 0.1
+ const DEFAULT_DURATION_SCORE_MISSING_INFO = 0.1
+
+ const libraryItemPerfectDuration = { media: { duration: 600 } } // 10 minutes
+
+ // Helper to calculate expected title/author score based on Levenshtein
+ // Assumes queryPart and bookPart are already "cleaned" for length calculation consistency with BookFinder.js
+ const calculateStringMatchScore = (cleanedQueryPart, cleanedBookPart) => {
+ if (!cleanedQueryPart) return cleanedBookPart ? 0 : 1 // query empty: 1 if book empty, else 0
+ if (!cleanedBookPart) return 0 // query non-empty, book empty: 0
+
+ // Use the imported levenshteinDistance. It defaults to case-insensitive, which is what we want.
+ const distance = levenshteinDistance(cleanedQueryPart, cleanedBookPart)
+ return Math.max(0, 1 - distance / Math.max(cleanedQueryPart.length, cleanedBookPart.length))
+ }
+
+ beforeEach(() => {
+ runSearchStub.resolves([])
+ })
+
+ afterEach(() => {
+ sinon.restore()
+ })
+
+ describe('for audible provider', () => {
+ const provider = 'audible'
+
+ it('should be 1.0 for perfect duration, title, and author match', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
+ // durationScore = 1.0 (diff 0 <= 1 min)
+ // titleScore = 1.0 (exact match)
+ // authorScore = 1.0 (exact match)
+ const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0
+ expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
+ })
+
+ it('should correctly score a large duration mismatch', async () => {
+ const bookResults = [{ duration: 21, title: 'The Great Novel', author: 'John Doe' }] // 21 min, diff = 11 min
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
+ // durationScore = 0.0
+ // titleScore = 1.0
+ // authorScore = 1.0
+ const expectedConfidence = W_DURATION * 0.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0
+ expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
+ })
+
+ it('should correctly score a medium duration mismatch', async () => {
+ const bookResults = [{ duration: 16, title: 'The Great Novel', author: 'John Doe' }] // 16 min, diff = 6 min
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
+ // durationScore = 1.2 - 6 * 0.12 = 0.48
+ // titleScore = 1.0
+ // authorScore = 1.0
+ const expectedConfidence = W_DURATION * 0.48 + W_TITLE * 1.0 + W_AUTHOR * 1.0
+ expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
+ })
+
+ it('should correctly score a minor duration mismatch', async () => {
+ const bookResults = [{ duration: 14, title: 'The Great Novel', author: 'John Doe' }] // 14 min, diff = 4 min
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
+ // durationScore = 1.1 - 4 * 0.1 = 0.7
+ // titleScore = 1.0
+ // authorScore = 1.0
+ const expectedConfidence = W_DURATION * 0.7 + W_TITLE * 1.0 + W_AUTHOR * 1.0
+ expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
+ })
+
+ it('should correctly score a tiny duration mismatch', async () => {
+ const bookResults = [{ duration: 11, title: 'The Great Novel', author: 'John Doe' }] // 11 min, diff = 1 min
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
+ // durationScore = 1.0
+ // titleScore = 1.0
+ // authorScore = 1.0
+ const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0
+ expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
+ })
+
+ it('should use default duration score if libraryItem duration is missing', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search({ media: {} }, provider, 'The Great Novel', 'John Doe')
+ // durationScore = DEFAULT_DURATION_SCORE_MISSING_INFO (0.2)
+ const expectedConfidence = W_DURATION * DEFAULT_DURATION_SCORE_MISSING_INFO + W_TITLE * 1.0 + W_AUTHOR * 1.0
+ expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
+ })
+
+ it('should use default duration score if book duration is missing', async () => {
+ const bookResults = [{ title: 'The Great Novel', author: 'John Doe' }] // No duration in book
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
+ // durationScore = DEFAULT_DURATION_SCORE_MISSING_INFO (0.2)
+ const expectedConfidence = W_DURATION * DEFAULT_DURATION_SCORE_MISSING_INFO + W_TITLE * 1.0 + W_AUTHOR * 1.0
+ expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
+ })
+
+ it('should correctly score a partial title match', async () => {
+ const bookResults = [{ duration: 10, title: 'Novel', author: 'John Doe' }]
+ runSearchStub.resolves(bookResults)
+ // Query: 'Novel Ex', Book: 'Novel'
+ // cleanTitleForCompares('Novel Ex') -> 'novel ex' (length 8)
+ // cleanTitleForCompares('Novel') -> 'novel' (length 5)
+ // levenshteinDistance('novel ex', 'novel') = 3
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'Novel Ex', 'John Doe')
+ const expectedTitleScore = calculateStringMatchScore('novel ex', 'novel') // 1 - (3/8) = 0.625
+ const expectedConfidence = W_DURATION * 1.0 + W_TITLE * expectedTitleScore + W_AUTHOR * 1.0
+ expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
+ })
+
+ it('should correctly score a partial author match (comma-separated)', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'Jane Smith, Jon Doee' }]
+ runSearchStub.resolves(bookResults)
+ // Query: 'Jon Doe', Book part: 'Jon Doee'
+ // cleanAuthorForCompares('Jon Doe') -> 'jon doe' (length 7)
+ // book author part (already lowercased) -> 'jon doee' (length 8)
+ // levenshteinDistance('jon doe', 'jon doee') = 1
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'Jon Doe')
+ // For the author part 'jon doee':
+ const expectedAuthorPartScore = calculateStringMatchScore('jon doe', 'jon doee') // 1 - (1/7)
+ // Assuming 'jane smith' gives a lower or 0 score, max score will be from 'jon doee'
+ const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * expectedAuthorPartScore
+ expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
+ })
+
+ it('should give authorScore 0 if query has author but book does not', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', author: null }]
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
+ // authorScore = 0.0
+ const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 0.0
+ expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
+ })
+
+ it('should give authorScore 1.0 if query has no author', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', '') // Empty author
+ expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
+ })
+
+ it('handles book author string that is only commas correctly (score 0)', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', author: ',, ,, ,' }]
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
+ // cleanedQueryAuthorForScore = "john doe"
+ // book.author leads to validBookAuthorParts being empty.
+ // authorScore = 0.0
+ const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 0.0
+ expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
+ })
+
+ it('should return 1.0 for ASIN results', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'B000F28ZJ4', null)
+ expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
+ })
+
+ it('should return 1.0 when author matches one of the book authors', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe, Jane Smith' }]
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
+ expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
+ })
+
+ it('should return 1.0 when author query and multiple book authors are the same', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe, Jane Smith' }]
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe, Jane Smith')
+ expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
+ })
+
+ it('should correctly score against a book with a subtitle when the query has a subtitle', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', subtitle: 'A Novel', author: 'John Doe' }]
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel: A Novel', 'John Doe')
+ expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
+ })
+
+ it('should correctly score against a book with a subtitle when the query does not have a subtitle', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', subtitle: 'A Novel', author: 'John Doe' }]
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
+ expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
+ })
+
+ describe('after fuzzy searches', () => {
+ it('should return 1.0 for a title candidate match', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
+ runSearchStub.resolves([])
+ runSearchStub.withArgs('the great novel', 'john doe').resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel - A Novel', 'John Doe')
+ expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
+ })
+
+ it('should return 1.0 for an author candidate match', async () => {
+ const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
+ runSearchStub.resolves([])
+ runSearchStub.withArgs('the great novel', 'john doe').resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe, Jane Smith')
+ expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
+ })
+ })
+ })
+
+ describe('for non-audible provider (e.g., google)', () => {
+ const provider = 'google'
+ it('should have not have matchConfidence', async () => {
+ const bookResults = [{ title: 'The Great Novel', author: 'John Doe' }]
+ runSearchStub.resolves(bookResults)
+ const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
+ expect(results[0]).to.not.have.property('matchConfidence')
+ })
})
})
})