From 808fb5f2aa95f9d27d882e17a3715b06f8851897 Mon Sep 17 00:00:00 2001 From: Weblate Date: Mon, 15 Nov 2021 13:38:58 +0000 Subject: [PATCH 1/6] Translated using Weblate (Chinese (Simplified) (zh_CN)) [skip ci] Currently translated at 65.7% (488 of 742 strings) Translated using Weblate (Korean) [skip ci] Currently translated at 65.0% (483 of 742 strings) Translated using Weblate (Japanese) [skip ci] Currently translated at 65.3% (485 of 742 strings) Translated using Weblate (Italian) [skip ci] Currently translated at 66.3% (492 of 742 strings) Translated using Weblate (Icelandic) [skip ci] Currently translated at 65.3% (485 of 742 strings) Translated using Weblate (Hungarian) [skip ci] Currently translated at 100.0% (742 of 742 strings) Translated using Weblate (Hindi) [skip ci] Currently translated at 65.3% (485 of 742 strings) Translated using Weblate (Hebrew) [skip ci] Currently translated at 65.3% (485 of 742 strings) Translated using Weblate (French) [skip ci] Currently translated at 67.2% (499 of 742 strings) Translated using Weblate (Finnish) [skip ci] Currently translated at 65.3% (485 of 742 strings) Translated using Weblate (Greek) [skip ci] Currently translated at 65.3% (485 of 742 strings) Translated using Weblate (German) [skip ci] Currently translated at 73.1% (543 of 742 strings) Translated using Weblate (German) [skip ci] Currently translated at 73.1% (543 of 742 strings) Translated using Weblate (Danish) [skip ci] Currently translated at 65.3% (485 of 742 strings) Translated using Weblate (Czech) [skip ci] Currently translated at 65.3% (485 of 742 strings) Translated using Weblate (Bulgarian) [skip ci] Currently translated at 65.2% (484 of 742 strings) Translated using Weblate (Arabic) [skip ci] Currently translated at 65.4% (486 of 742 strings) Translated using Weblate (Spanish) [skip ci] Currently translated at 65.7% (488 of 742 strings) Co-authored-by: Anonymous Co-authored-by: Csaba Co-authored-by: mm519897405 Co-authored-by: reloxx Translate-URL: https://translate.servarr.com/projects/servarr/readarr/ar/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/bg/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/cs/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/da/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/de/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/el/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/es/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/fi/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/fr/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/he/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/hi/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/hu/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/is/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/it/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/ja/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/ko/ Translate-URL: https://translate.servarr.com/projects/servarr/readarr/zh_CN/ Translation: Servarr/Readarr --- src/NzbDrone.Core/Localization/Core/ar.json | 11 ++++- src/NzbDrone.Core/Localization/Core/bg.json | 9 +++- src/NzbDrone.Core/Localization/Core/cs.json | 10 ++++- src/NzbDrone.Core/Localization/Core/da.json | 10 ++++- src/NzbDrone.Core/Localization/Core/de.json | 44 ++++++++++++------- src/NzbDrone.Core/Localization/Core/el.json | 10 ++++- src/NzbDrone.Core/Localization/Core/es.json | 11 ++++- src/NzbDrone.Core/Localization/Core/fi.json | 10 ++++- src/NzbDrone.Core/Localization/Core/fr.json | 13 +++++- src/NzbDrone.Core/Localization/Core/he.json | 10 ++++- src/NzbDrone.Core/Localization/Core/hi.json | 10 ++++- src/NzbDrone.Core/Localization/Core/hu.json | 6 ++- src/NzbDrone.Core/Localization/Core/is.json | 10 ++++- src/NzbDrone.Core/Localization/Core/it.json | 15 ++++++- src/NzbDrone.Core/Localization/Core/ja.json | 10 ++++- src/NzbDrone.Core/Localization/Core/ko.json | 8 +++- .../Localization/Core/zh_CN.json | 8 ++-- 17 files changed, 169 insertions(+), 36 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/ar.json b/src/NzbDrone.Core/Localization/Core/ar.json index 727c42665..e8f1822b8 100644 --- a/src/NzbDrone.Core/Localization/Core/ar.json +++ b/src/NzbDrone.Core/Localization/Core/ar.json @@ -476,5 +476,14 @@ "NotMonitored": "غير مراقب", "ShowBookTitleHelpText": "إظهار عنوان الفيلم تحت الملصق", "ShowReleaseDate": "إظهار تاريخ الإصدار", - "ShowTitle": "إظهار العنوان" + "ShowTitle": "إظهار العنوان", + "RemoveFromBlocklist": "إزالة من القائمة السوداء", + "UnableToLoadBlocklist": "تعذر تحميل القائمة السوداء", + "Level": "مستوى", + "ReleaseBranchCheckOfficialBranchMessage": "الفرع {0} ليس فرع إصدار Radarr صالح ، لن تتلقى تحديثات", + "Time": "زمن", + "Component": "مكون", + "Blocklist": "القائمة السوداء", + "BlocklistHelpText": "يمنع Radarr من الاستيلاء على هذا الإصدار تلقائيًا مرة أخرى", + "BlocklistRelease": "إصدار القائمة السوداء" } diff --git a/src/NzbDrone.Core/Localization/Core/bg.json b/src/NzbDrone.Core/Localization/Core/bg.json index c96afd4c2..ac53e9261 100644 --- a/src/NzbDrone.Core/Localization/Core/bg.json +++ b/src/NzbDrone.Core/Localization/Core/bg.json @@ -476,5 +476,12 @@ "NotMonitored": "Не се следи", "ShowBookTitleHelpText": "Показване на заглавието на филма под плакат", "ShowReleaseDate": "Показване на датата на издаване", - "ShowTitle": "Показване на заглавието" + "ShowTitle": "Показване на заглавието", + "Level": "Ниво", + "RemoveFromBlocklist": "Премахване от черния списък", + "Time": "Време", + "UnableToLoadBlocklist": "Черният списък не може да се зареди", + "ReleaseBranchCheckOfficialBranchMessage": "Клон {0} не е валиден клон за издаване на Radarr, няма да получавате актуализации", + "Blocklist": "Черен списък", + "BlocklistRelease": "Освобождаване на черния списък" } diff --git a/src/NzbDrone.Core/Localization/Core/cs.json b/src/NzbDrone.Core/Localization/Core/cs.json index a6ee7f392..4d2b53bdd 100644 --- a/src/NzbDrone.Core/Localization/Core/cs.json +++ b/src/NzbDrone.Core/Localization/Core/cs.json @@ -476,5 +476,13 @@ "NotMonitored": "Nesledováno", "ShowBookTitleHelpText": "Zobrazit titul filmu pod plakátem", "ShowReleaseDate": "Zobrazit datum vydání", - "ShowTitle": "Ukázat nadpis" + "ShowTitle": "Ukázat nadpis", + "RemoveFromBlocklist": "Odebrat z černé listiny", + "UnableToLoadBlocklist": "Nelze načíst černou listinu", + "Component": "Součástka", + "Level": "Úroveň", + "ReleaseBranchCheckOfficialBranchMessage": "Pobočka {0} není platná větev vydání Radarr, nebudete dostávat aktualizace", + "Time": "Čas", + "Blocklist": "Černá listina", + "BlocklistRelease": "Vydání černé listiny" } diff --git a/src/NzbDrone.Core/Localization/Core/da.json b/src/NzbDrone.Core/Localization/Core/da.json index 5fbce7f4d..c951137c2 100644 --- a/src/NzbDrone.Core/Localization/Core/da.json +++ b/src/NzbDrone.Core/Localization/Core/da.json @@ -476,5 +476,13 @@ "BookAvailableButMissing": "Film tilgængelig, men mangler", "ShowBookTitleHelpText": "Vis filmtitel under plakat", "ShowReleaseDate": "Vis udgivelsesdato", - "ShowTitle": "Vis titel" + "ShowTitle": "Vis titel", + "Time": "Tid", + "UnableToLoadBlocklist": "Kunne ikke indlæse sortliste", + "RemoveFromBlocklist": "Fjern fra sortlisten", + "Component": "Komponent", + "Level": "Niveau", + "ReleaseBranchCheckOfficialBranchMessage": "Filial {0} er ikke en gyldig Radarr-frigivelsesfilial, du modtager ikke opdateringer", + "Blocklist": "Blacklist", + "BlocklistRelease": "Udgivelse af sortliste" } diff --git a/src/NzbDrone.Core/Localization/Core/de.json b/src/NzbDrone.Core/Localization/Core/de.json index 282805b18..d0fe7197f 100644 --- a/src/NzbDrone.Core/Localization/Core/de.json +++ b/src/NzbDrone.Core/Localization/Core/de.json @@ -16,30 +16,30 @@ "AlternateTitles": "Alternative Titel", "AlternateTitleslength1Title": "Titel", "AlternateTitleslength1Titles": "Titel", - "Analytics": "Statistiken", - "AnalyticsEnabledHelpText": "Sende anonyme Nutzungs- und Fehlerinformationen an die Server von Radarr. Dazu gehören Informationen über Browser, welche Seiten der Radarr-Weboberfläche aufgerufen wurden, Fehlerberichte sowie Betriebssystem- und Laufzeitversion. Wir werden diese Informationen verwenden, um Funktionen und Fehlerbehebungen zu priorisieren.", + "Analytics": "Analytik", + "AnalyticsEnabledHelpText": "Sende anonyme Nutzungs- und Fehlerinformationen an die Server von Readarr. Dazu gehören Informationen über Browser, welche Seiten der Readarr-Weboberfläche aufgerufen wurden, Fehlerberichte sowie Betriebssystem- und Laufzeitversion. Wir werden diese Informationen verwenden, um Funktionen und Fehlerbehebungen zu priorisieren.", "AnalyticsEnabledHelpTextWarning": "Erfordert einen Neustart", "AppDataDirectory": "AppData Ordner", "ApplyTags": "Tags setzen", - "ApplyTagsHelpTexts1": "Wie werden Tags zu ausgewählten Filmen zugeteilt", + "ApplyTagsHelpTexts1": "Wie werden Tags zu ausgewählten Autoren zugeteilt", "ApplyTagsHelpTexts2": "Hinzufügen: Füge neu Tags zu den existierenden Tags hinzu", "ApplyTagsHelpTexts3": "Entfernen: Eingegebene Tags entfernen", "ApplyTagsHelpTexts4": "Ersetzen: Nur eingegebene Tags übernehmen und vorhandene entfernen( keine Tags eingeben um alle zu entfernen )", "Authentication": "Authentifizierung", - "AuthenticationMethodHelpText": "Für den Zugriff auf Radarr sind Benutzername und Passwort erforderlich", + "AuthenticationMethodHelpText": "Für den Zugriff auf Readarr sind Benutzername und Passwort erforderlich", "AuthorClickToChangeBook": "Klicken um den Film zu bearbeiten", - "AutoRedownloadFailedHelpText": "Automatisch nach einen anderen Release suchen", + "AutoRedownloadFailedHelpText": "Automatisch nach einem anderen Release suchen", "AutoUnmonitorPreviouslyDownloadedBooksHelpText": "Auf der Festplatte gelöschte Filme auch automatisch in Radarr nicht mehr beobachten", "Automatic": "Automatisch", - "BackupFolderHelpText": "Relative Pfade befinden sich unter Radarrs AppData Ordner", + "BackupFolderHelpText": "Relative Pfade befinden sich unter Readarrs AppData Ordner", "BackupNow": "Jetzt sichern", "BackupRetentionHelpText": "Automatische Backups, die älter als die Aufbewahrungsfrist sind, werden automatisch gelöscht", "Backups": "Backups", - "BindAddress": "Bindungsadresse", + "BindAddress": "Adresse binden", "BindAddressHelpText": "Gültige IPv4 Adresse oder \"*\" für alle Netzwerke", "BookIsDownloading": "Film ist am herunterladen", "BookIsDownloadingInterp": "Film lädt herunter - {0}% {1}", - "Branch": "Branch", + "Branch": "Git-Branch", "BypassProxyForLocalAddresses": "Proxy für lokale Adressen umgehen", "Calendar": "Kalender", "CalendarWeekColumnHeaderHelpText": "Wird in der Wochenansicht über jeder Spalte angezeigt", @@ -47,11 +47,11 @@ "CancelMessageText": "Diese laufende Aufgabe wirklich abbrechen?", "CertificateValidation": "Zertifikat Validierung", "CertificateValidationHelpText": "Ändere wie streng die Validierung der HTTPS-Zertifizierung ist", - "ChangeFileDate": "Datei Erstelldatum anpassen", + "ChangeFileDate": "Erstelldatum der Datei anpassen", "ChangeHasNotBeenSavedYet": "Änderung wurde noch nicht gespeichert", "ChmodFolder": "chmod Ordner", "ChmodFolderHelpText": "Oktal, wird beim Importieren/Umbenennen von Medienordnern und Dateien angewendet (ohne Ausführungsbits)", - "ChmodFolderHelpTextWarning": "Dies funktioniert nur, wenn der Benutzer, der Radarr ausführt, der Eigentümer der Datei ist. Es ist besser, sicherzustellen, dass der Download-Client die Berechtigungen richtig setzt.", + "ChmodFolderHelpTextWarning": "Dies funktioniert nur, wenn der Benutzer, der Readarr ausführt, der Eigentümer der Datei ist. Es ist besser, sicherzustellen, dass der Download-Client die Berechtigungen richtig setzt.", "ChownGroupHelpText": "Gruppenname oder gid. Verwenden Sie gid für entfernte Dateisysteme.", "ChownGroupHelpTextWarning": "Dies funktioniert nur, wenn der Benutzer, der Radarr ausführt, der Eigentümer der Datei ist. Es ist besser, sicherzustellen, dass der Download-Client die gleiche Gruppe wie Radarr verwendet.", "Clear": "Leeren", @@ -65,14 +65,14 @@ "ConnectSettings": "Eintellungen für Verbindungen", "Connections": "Verbindungen", "CopyUsingHardlinksHelpText": "Hardlinks erstellen wenn Torrents die noch geseeded werden kopiert werden sollen", - "CopyUsingHardlinksHelpTextWarning": "Dateisperren Gelegentlich kann es vorkommen, dass Dateisperren das Umbenennen von Dateien verhindern, die gerade geseeded werden. Sie können das Seeding vorübergehend deaktivieren und die Umbenennungsfunktion von Radarr als Workaround verwenden.", + "CopyUsingHardlinksHelpTextWarning": "Dateisperren Gelegentlich kann es vorkommen, dass Dateisperren das Umbenennen von Dateien verhindern, die gerade geseeded werden. Sie können das Seeding vorübergehend deaktivieren und die Umbenennungsfunktion von Readarr als Workaround verwenden.", "CreateEmptyAuthorFoldersHelpText": "Leere Filmordner für fehlende Filme beim Scan erstellen", "CreateGroup": "Gruppe erstellen", "CutoffHelpText": "Sobald diese Qualität erreicht wird, werden keine neuen Releases erfasst", "CutoffUnmet": "› Schwelle nicht erreicht", "DBMigration": "DB Migration", "Dates": "Termine", - "DelayProfile": "Verzögerungs Profil", + "DelayProfile": "Verzögerungsprofil", "DelayProfiles": "Verzögerungsprofile", "DelayingDownloadUntilInterp": "Download verzögern bis {0} um {1}", "Delete": "Löschen", @@ -83,7 +83,7 @@ "DeleteDownloadClient": "Downloader löschen", "DeleteDownloadClientMessageText": "Downloader '{0}' wirklich löschen?", "DeleteEmptyFolders": "Leere Ordner löschen", - "DeleteEmptyFoldersHelpText": "Lösche leere Filmeordner während des Scans oder wenn Filmdateien gelöscht werden", + "DeleteEmptyFoldersHelpText": "Lösche leere Autorordner während des Scans oder wenn Buchdateien gelöscht werden", "DeleteImportListExclusion": "Importlisten Ausschluss löschen", "DeleteImportListExclusionMessageText": "Bist du sicher, dass du diesen Importlisten Ausschluss löschen willst?", "DeleteImportListMessageText": "Liste '{0}' wirklich löschen?", @@ -118,12 +118,12 @@ "Enable": "Aktivieren", "EnableAutomaticAdd": "Automatisch hinzufügen", "EnableAutomaticSearch": "Automatisch suchen", - "EnableColorImpairedMode": "Farbbeeinträchtigter Modus", - "EnableColorImpairedModeHelpText": "Alternativer Style, um farbbeeinträchtigten Benutzern eine bessere Unterscheidung farbcodierter Informationen zu ermöglichen", + "EnableColorImpairedMode": "Farbbeeinträchtigter Modus aktivieren", + "EnableColorImpairedModeHelpText": "Alternativer Stil, um farbbeeinträchtigten Benutzern eine bessere Unterscheidung farbcodierter Informationen zu ermöglichen", "EnableCompletedDownloadHandlingHelpText": "Importiere fertige Downloads vom Downloader automatisch", "EnableHelpText": "Metadaten Dateien erstellen für diesen Metadata Typ", "EnableInteractiveSearch": "Interaktive Suche", - "EnableRSS": "RSS Sync.", + "EnableRSS": "RSS aktivieren", "EnableSSL": "SSL", "EnableSslHelpText": " Erfordert einen Neustart als Administrator", "Ended": "Beendet", @@ -532,5 +532,15 @@ "ShowBookTitleHelpText": "Filmtitel unter dem Plakat anzeigen", "ShowReleaseDate": "Veröffentlichungsdatum anzeigen", "ShowTitle": "Titel anzeigen", - "TheAuthorFolderAndAllOfItsContentWillBeDeleted": "Der Filmordner und dessen Inhalt wird gelöscht." + "TheAuthorFolderAndAllOfItsContentWillBeDeleted": "Der Filmordner und dessen Inhalt wird gelöscht.", + "DeleteFilesHelpText": "Lösche die Buchdateien und Autorordner", + "Component": "Komponente", + "Level": "Stufe", + "Time": "Zeit", + "RemoveFromBlocklist": "Aus der Sperrliste entfernen", + "UnableToLoadBlocklist": "Sperrliste konnte nicht geladen werden", + "ReleaseBranchCheckOfficialBranchMessage": "Zweig {0} ist kein gültiger Radarr-Release-Zweig. Sie erhalten keine Updates", + "Blocklist": "Sperrliste", + "BlocklistHelpText": "Dieses Release nicht automatisch erneut erfassen", + "BlocklistRelease": "Release sperren" } diff --git a/src/NzbDrone.Core/Localization/Core/el.json b/src/NzbDrone.Core/Localization/Core/el.json index 7eea15007..43aa3a5b7 100644 --- a/src/NzbDrone.Core/Localization/Core/el.json +++ b/src/NzbDrone.Core/Localization/Core/el.json @@ -476,5 +476,13 @@ "NotMonitored": "Δεν παρακολουθείται", "ShowBookTitleHelpText": "Εμφάνιση τίτλου ταινίας κάτω από την αφίσα", "ShowReleaseDate": "Εμφάνιση ημερομηνίας κυκλοφορίας", - "ShowTitle": "Εμφάνιση τίτλου" + "ShowTitle": "Εμφάνιση τίτλου", + "Component": "Στοιχείο", + "RemoveFromBlocklist": "Κατάργηση από μαύρη λίστα", + "Time": "χρόνος", + "UnableToLoadBlocklist": "Δεν είναι δυνατή η φόρτωση της μαύρης λίστας", + "Level": "Επίπεδο", + "ReleaseBranchCheckOfficialBranchMessage": "Το υποκατάστημα {0} δεν είναι έγκυρο υποκατάστημα κυκλοφορίας Radarr, δεν θα λαμβάνετε ενημερώσεις", + "Blocklist": "Αποριφθέντα", + "BlocklistRelease": "Έκδοση μαύρης λίστας" } diff --git a/src/NzbDrone.Core/Localization/Core/es.json b/src/NzbDrone.Core/Localization/Core/es.json index 32f74315a..9f999d2d5 100644 --- a/src/NzbDrone.Core/Localization/Core/es.json +++ b/src/NzbDrone.Core/Localization/Core/es.json @@ -478,5 +478,14 @@ "ShowBookTitleHelpText": "Mostrar el título de la película debajo del poster", "ShowReleaseDate": "Mostrar fecha de lanzamiento", "ShowTitle": "Mostrar Título", - "TheAuthorFolderAndAllOfItsContentWillBeDeleted": "Se eliminará la carpeta de películas '{0}' y todo su contenido." + "TheAuthorFolderAndAllOfItsContentWillBeDeleted": "Se eliminará la carpeta de películas '{0}' y todo su contenido.", + "Component": "Componente", + "RemoveFromBlocklist": "Eliminar de lista de bloqueados", + "Time": "Fecha", + "UnableToLoadBlocklist": "No se han podido cargar las bloqueadas", + "Level": "Nivel", + "ReleaseBranchCheckOfficialBranchMessage": "Las versión {0} no es una versión válida de Radarr, no recibirás actualizaciones", + "Blocklist": "Bloqueadas", + "BlocklistHelpText": "Evita que Radarr vuelva a capturar esta película automáticamente", + "BlocklistRelease": "Bloquear este Estreno" } diff --git a/src/NzbDrone.Core/Localization/Core/fi.json b/src/NzbDrone.Core/Localization/Core/fi.json index 21518a326..58c96f89e 100644 --- a/src/NzbDrone.Core/Localization/Core/fi.json +++ b/src/NzbDrone.Core/Localization/Core/fi.json @@ -476,5 +476,13 @@ "NotMonitored": "Ei valvottu", "ShowBookTitleHelpText": "Näytä elokuvan nimi julisteen alla", "ShowReleaseDate": "Näytä julkaisupäivä", - "ShowTitle": "Näytä otsikko" + "ShowTitle": "Näytä otsikko", + "Component": "Komponentti", + "Level": "Taso", + "RemoveFromBlocklist": "Poista mustalta listalta", + "UnableToLoadBlocklist": "Mustaa listaa ei voi ladata", + "ReleaseBranchCheckOfficialBranchMessage": "Branch {0} ei ole kelvollinen Radarr-julkaisuhakemisto, et saa päivityksiä", + "Time": "Aika", + "Blocklist": "Musta lista", + "BlocklistRelease": "Mustan listan julkaisu" } diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index e51d34c62..d02e68886 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -487,5 +487,16 @@ "Trigger": "Déclencheur", "Type": "Type", "UI": "UI", - "ReplaceIllegalCharactersHelpText": "Remplacer les caractères illégaux. Si elle n'est pas cochée, Radarr les supprimera à la place" + "ReplaceIllegalCharactersHelpText": "Remplacer les caractères illégaux. Si elle n'est pas cochée, Radarr les supprimera à la place", + "Level": "Niveau", + "Publisher": "Éditeur", + "Label": "Label", + "RemoveFromBlocklist": "Supprimer de la liste noire", + "UnableToLoadBlocklist": "Impossible de charger la liste noire", + "Component": "Composant", + "ReleaseBranchCheckOfficialBranchMessage": "La branche {0} n'est pas une branche de version Radarr valide, vous ne recevrez pas de mises à jour", + "Time": "Heure", + "Blocklist": "Liste noire", + "BlocklistHelpText": "Empêche Radarr de récupérer automatiquement cette version", + "BlocklistRelease": "Mettre cette release sur la liste noire" } diff --git a/src/NzbDrone.Core/Localization/Core/he.json b/src/NzbDrone.Core/Localization/Core/he.json index ae69d641f..82fba6fc4 100644 --- a/src/NzbDrone.Core/Localization/Core/he.json +++ b/src/NzbDrone.Core/Localization/Core/he.json @@ -476,5 +476,13 @@ "Today": "היום", "Tomorrow": "מָחָר", "Trigger": "הדק", - "Type": "סוּג" + "Type": "סוּג", + "Time": "זְמַן", + "RemoveFromBlocklist": "הסר מהרשימה השחורה", + "UnableToLoadBlocklist": "לא ניתן לטעון את הרשימה השחורה", + "Component": "רְכִיב", + "Level": "רָמָה", + "ReleaseBranchCheckOfficialBranchMessage": "סניף {0} אינו סניף חוקי לשחרור Radarr, לא תקבל עדכונים", + "Blocklist": "רשימה שחורה", + "BlocklistRelease": "שחרור הרשימה השחורה" } diff --git a/src/NzbDrone.Core/Localization/Core/hi.json b/src/NzbDrone.Core/Localization/Core/hi.json index b92816826..1a982bb01 100644 --- a/src/NzbDrone.Core/Localization/Core/hi.json +++ b/src/NzbDrone.Core/Localization/Core/hi.json @@ -476,5 +476,13 @@ "Trigger": "उत्प्रेरक", "Type": "प्रकार", "UI": "यूआई", - "OutputPath": "उत्पादन के पथ" + "OutputPath": "उत्पादन के पथ", + "RemoveFromBlocklist": "ब्लैकलिस्ट से निकालें", + "UnableToLoadBlocklist": "ब्लैकलिस्ट लोड करने में असमर्थ", + "Component": "अंग", + "Level": "स्तर", + "ReleaseBranchCheckOfficialBranchMessage": "शाखा {0} वैध रेडीआर रिलीज शाखा नहीं है, आपको अपडेट नहीं मिलेगा", + "Time": "समय", + "Blocklist": "काला सूची में डालना", + "BlocklistRelease": "ब्लैकलिस्ट रिलीज़" } diff --git a/src/NzbDrone.Core/Localization/Core/hu.json b/src/NzbDrone.Core/Localization/Core/hu.json index 8907465b7..a8b3ee44c 100644 --- a/src/NzbDrone.Core/Localization/Core/hu.json +++ b/src/NzbDrone.Core/Localization/Core/hu.json @@ -736,5 +736,9 @@ "Blocklist": "Feketelista", "BlocklistHelpText": "Megakadályozza, hogy a Readarr automatikusan letöltse újra", "RemoveFromBlocklist": "Eltávolítás a feketelistáról", - "UnableToLoadBlocklist": "Nem sikerült betölteni a feketelistát" + "UnableToLoadBlocklist": "Nem sikerült betölteni a feketelistát", + "Component": "Komponens", + "Level": "Szint", + "ReleaseBranchCheckOfficialBranchMessage": "A(z) {0} nem érvényes Readarr frissítési ágazat, ezért nem kap frissítéseket", + "Time": "Idő" } diff --git a/src/NzbDrone.Core/Localization/Core/is.json b/src/NzbDrone.Core/Localization/Core/is.json index c9c26b3c8..6f875e55a 100644 --- a/src/NzbDrone.Core/Localization/Core/is.json +++ b/src/NzbDrone.Core/Localization/Core/is.json @@ -476,5 +476,13 @@ "Tomorrow": "Á morgun", "Trigger": "Kveikja", "Type": "Tegund", - "UI": "HÍ" + "UI": "HÍ", + "Level": "Stig", + "RemoveFromBlocklist": "Fjarlægja af svörtum lista", + "UnableToLoadBlocklist": "Ekki er hægt að hlaða svartan lista", + "Component": "Hluti", + "ReleaseBranchCheckOfficialBranchMessage": "Útibú {0} er ekki gild útibú frá Radarr, þú færð ekki uppfærslur", + "Time": "Tími", + "Blocklist": "Svartur listi", + "BlocklistRelease": "Útgáfa svartalista" } diff --git a/src/NzbDrone.Core/Localization/Core/it.json b/src/NzbDrone.Core/Localization/Core/it.json index 43e141a22..e4a079b0d 100644 --- a/src/NzbDrone.Core/Localization/Core/it.json +++ b/src/NzbDrone.Core/Localization/Core/it.json @@ -478,5 +478,18 @@ "Trigger": "Trigger", "Type": "Tipo", "UI": "Interfaccia", - "CloneIndexer": "Clona Indexer" + "CloneIndexer": "Clona Indexer", + "RemoveFromBlocklist": "Rimuovi della blacklist", + "Time": "Orario", + "Label": "Etichetta", + "UnableToLoadBlocklist": "Non riesco a caricare la BlackList", + "Component": "Componente", + "Level": "Livello", + "ReleaseBranchCheckOfficialBranchMessage": "Il Branch {0} non è un branch valido per le release di Radarr, non riceverai aggiornamenti", + "Absolute": "Assoluto", + "AddMissing": "Aggiungi ai mancanti", + "AddNewItem": "Aggiungi Nuovo Elemento", + "Blocklist": "Lista Nera", + "BlocklistHelpText": "Impedisci a Radarr di acquisire automaticamente questo versione", + "BlocklistRelease": "Release in blacklist" } diff --git a/src/NzbDrone.Core/Localization/Core/ja.json b/src/NzbDrone.Core/Localization/Core/ja.json index 20a6ea3d2..a3223569e 100644 --- a/src/NzbDrone.Core/Localization/Core/ja.json +++ b/src/NzbDrone.Core/Localization/Core/ja.json @@ -476,5 +476,13 @@ "NotMonitored": "監視されていません", "ShowBookTitleHelpText": "ポスターの下に映画のタイトルを表示する", "ShowReleaseDate": "リリース日を表示", - "ShowTitle": "タイトルを表示" + "ShowTitle": "タイトルを表示", + "UnableToLoadBlocklist": "ブラックリストを読み込めません", + "RemoveFromBlocklist": "ブラックリストから削除する", + "Component": "成分", + "Level": "レベル", + "ReleaseBranchCheckOfficialBranchMessage": "ブランチ{0}は有効なRadarrリリースブランチではありません。更新を受け取りません。", + "Time": "時間", + "Blocklist": "ブラックリスト", + "BlocklistRelease": "ブラックリストリリース" } diff --git a/src/NzbDrone.Core/Localization/Core/ko.json b/src/NzbDrone.Core/Localization/Core/ko.json index d67b702dc..b9f83a44c 100644 --- a/src/NzbDrone.Core/Localization/Core/ko.json +++ b/src/NzbDrone.Core/Localization/Core/ko.json @@ -476,5 +476,11 @@ "NotMonitored": "모니터링되지 않음", "ShowBookTitleHelpText": "포스터 아래에 영화 제목 표시", "ShowReleaseDate": "출시일 표시", - "ShowTitle": "쇼 제목" + "ShowTitle": "쇼 제목", + "Component": "구성 요소", + "RemoveFromBlocklist": "블랙리스트에서 제거", + "UnableToLoadBlocklist": "블랙리스트를로드 할 수 없습니다.", + "TheAuthorFolderAndAllOfItsContentWillBeDeleted": "동영상 폴더 '{0}' 및 모든 콘텐츠가 삭제됩니다.", + "Blocklist": "블랙리스트", + "BlocklistRelease": "블랙리스트 릴리스" } diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json index 4e4a7122e..7cf71a98b 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_CN.json +++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json @@ -10,7 +10,7 @@ "AdvancedSettingsHiddenClickToShow": "已隐藏,点击显示", "AdvancedSettingsShownClickToHide": "已显示,点击隐藏", "AgeWhenGrabbed": "发布时长", - "AlreadyInYourLibrary": "已经在库中", + "AlreadyInYourLibrary": "已经在你的库中", "AlternateTitles": "其他电影名称", "AlternateTitleslength1Title": "标题", "AlternateTitleslength1Titles": "标题", @@ -202,10 +202,10 @@ "Medium": "中", "Message": "信息", "MetadataSettings": "元数据设置", - "MinimumAge": "最小年龄Minimum Age", + "MinimumAge": "最小年龄", "MinimumAgeHelpText": "仅限Usenet:抓取NZB的最小年龄(分钟)。使用此功能可以使新版本有时间传播到您的Usenet提供程序。", "MinimumFreeSpace": "最小剩余空间", - "MinimumFreeSpaceWhenImportingHelpText": "如果可用磁盘空间少于此数则阻止导入", + "MinimumFreeSpaceWhenImportingHelpText": "如果导入的磁盘空间不足,则禁止导入", "MinimumLimits": "最小限制", "Missing": "缺少", "Mode": "模式", @@ -354,7 +354,7 @@ "SkipFreeSpaceCheckWhenImportingHelpText": "当Radarr无法检测您的影片根目录时使用", "SorryThatAuthorCannotBeFound": "对不起,未找到影片。", "SorryThatBookCannotBeFound": "对不起,未找到影片。", - "Source": "源代码", + "Source": "源路径", "SourcePath": "来源路径", "SslCertPasswordHelpText": "pfx文件密码", "SslCertPasswordHelpTextWarning": "重启生效", From dfb95588687b97b6a44bee8427939a577d17e0b9 Mon Sep 17 00:00:00 2001 From: ta264 Date: Thu, 18 Nov 2021 21:19:49 +0000 Subject: [PATCH 2/6] Fixed: Broken SSL certificate validation option (cherry picked from commit a6a761cb32a268c4447071dc692c428789063fce) --- .../Http/HttpClientFixture.cs | 32 +++++++++++++++++-- .../ICertificateValidationService.cs | 11 +++++++ .../Http/Dispatchers/ManagedHttpDispatcher.cs | 9 ++++++ src/NzbDrone.Core.Test/Framework/CoreTest.cs | 4 ++- .../X509CertificateValidationService.cs | 27 ++++++---------- 5 files changed, 62 insertions(+), 21 deletions(-) create mode 100644 src/NzbDrone.Common/Http/Dispatchers/ICertificateValidationService.cs diff --git a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs index c9ec92ec4..2240966a7 100644 --- a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs +++ b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Threading; using FluentAssertions; using Moq; @@ -15,6 +16,8 @@ using NzbDrone.Common.Http; using NzbDrone.Common.Http.Dispatchers; using NzbDrone.Common.Http.Proxy; using NzbDrone.Common.TPL; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Security; using NzbDrone.Test.Common; using NzbDrone.Test.Common.Categories; using HttpClient = NzbDrone.Common.Http.HttpClient; @@ -41,7 +44,7 @@ namespace NzbDrone.Common.Test.Http var mainHost = "httpbin.servarr.com"; // Use mirrors for tests that use two hosts - var candidates = new[] { "eu.httpbin.org", /* "httpbin.org", */ "www.httpbin.org" }; + var candidates = new[] { "httpbin1.servarr.com" }; // httpbin.org is broken right now, occassionally redirecting to https if it's unavailable. _httpBinHost = mainHost; @@ -49,7 +52,7 @@ namespace NzbDrone.Common.Test.Http TestLogger.Info($"{candidates.Length} TestSites available."); - _httpBinSleep = _httpBinHosts.Count() < 2 ? 100 : 10; + _httpBinSleep = 10; } private bool IsTestSiteAvailable(string site) @@ -84,10 +87,13 @@ namespace NzbDrone.Common.Test.Http Mocker.GetMock().Setup(c => c.Name).Returns("TestOS"); Mocker.GetMock().Setup(c => c.Version).Returns("9.0.0"); + Mocker.GetMock().SetupGet(x => x.CertificateValidation).Returns(CertificateValidationType.Enabled); + Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(new X509CertificateValidationService(Mocker.GetMock().Object, TestLogger)); Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant>(new IHttpRequestInterceptor[0]); Mocker.SetConstant(Mocker.Resolve()); @@ -127,6 +133,28 @@ namespace NzbDrone.Common.Test.Http response.Content.Should().NotBeNullOrWhiteSpace(); } + [TestCase(CertificateValidationType.Enabled)] + [TestCase(CertificateValidationType.DisabledForLocalAddresses)] + public void bad_ssl_should_fail_when_remote_validation_enabled(CertificateValidationType validationType) + { + Mocker.GetMock().SetupGet(x => x.CertificateValidation).Returns(validationType); + var request = new HttpRequest($"https://expired.badssl.com"); + + Assert.Throws(() => Subject.Execute(request)); + ExceptionVerification.ExpectedErrors(2); + } + + [Test] + public void bad_ssl_should_pass_if_remote_validation_disabled() + { + Mocker.GetMock().SetupGet(x => x.CertificateValidation).Returns(CertificateValidationType.Disabled); + + var request = new HttpRequest($"https://expired.badssl.com"); + + Subject.Execute(request); + ExceptionVerification.ExpectedErrors(0); + } + [Test] public void should_execute_typed_get() { diff --git a/src/NzbDrone.Common/Http/Dispatchers/ICertificateValidationService.cs b/src/NzbDrone.Common/Http/Dispatchers/ICertificateValidationService.cs new file mode 100644 index 000000000..187c1fd43 --- /dev/null +++ b/src/NzbDrone.Common/Http/Dispatchers/ICertificateValidationService.cs @@ -0,0 +1,11 @@ +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace NzbDrone.Common.Http.Dispatchers +{ + public interface ICertificateValidationService + { + bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors); + } +} diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 08b464b79..b7acefb3a 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -3,6 +3,7 @@ using System.IO; using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.Security; using System.Net.Sockets; using System.Text; using System.Threading; @@ -24,6 +25,7 @@ namespace NzbDrone.Common.Http.Dispatchers private readonly IHttpProxySettingsProvider _proxySettingsProvider; private readonly ICreateManagedWebProxy _createManagedWebProxy; + private readonly ICertificateValidationService _certificateValidationService; private readonly IUserAgentBuilder _userAgentBuilder; private readonly ICached _httpClientCache; private readonly ICached _credentialCache; @@ -31,12 +33,14 @@ namespace NzbDrone.Common.Http.Dispatchers public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, ICreateManagedWebProxy createManagedWebProxy, + ICertificateValidationService certificateValidationService, IUserAgentBuilder userAgentBuilder, ICacheManager cacheManager, Logger logger) { _proxySettingsProvider = proxySettingsProvider; _createManagedWebProxy = createManagedWebProxy; + _certificateValidationService = certificateValidationService; _userAgentBuilder = userAgentBuilder; _logger = logger; @@ -158,7 +162,12 @@ namespace NzbDrone.Common.Http.Dispatchers AllowAutoRedirect = false, Credentials = GetCredentialCache(), PreAuthenticate = true, + MaxConnectionsPerServer = 12, ConnectCallback = onConnect, + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = _certificateValidationService.ShouldByPassValidationError + } }; if (proxySettings != null) diff --git a/src/NzbDrone.Core.Test/Framework/CoreTest.cs b/src/NzbDrone.Core.Test/Framework/CoreTest.cs index ebb0611b5..4b4e990fc 100644 --- a/src/NzbDrone.Core.Test/Framework/CoreTest.cs +++ b/src/NzbDrone.Core.Test/Framework/CoreTest.cs @@ -11,6 +11,7 @@ using NzbDrone.Common.TPL; using NzbDrone.Core.Configuration; using NzbDrone.Core.Http; using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Security; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Framework @@ -25,7 +26,8 @@ namespace NzbDrone.Core.Test.Framework Mocker.SetConstant(new HttpProxySettingsProvider(Mocker.Resolve())); Mocker.SetConstant(new ManagedWebProxyFactory(Mocker.Resolve())); - Mocker.SetConstant(new ManagedHttpDispatcher(Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); + Mocker.SetConstant(new X509CertificateValidationService(Mocker.Resolve(), TestLogger)); + Mocker.SetConstant(new ManagedHttpDispatcher(Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); Mocker.SetConstant(new HttpClient(new IHttpRequestInterceptor[0], Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); Mocker.SetConstant(new ReadarrCloudRequestBuilder()); Mocker.SetConstant(Mocker.Resolve()); diff --git a/src/NzbDrone.Core/Security/X509CertificateValidationService.cs b/src/NzbDrone.Core/Security/X509CertificateValidationService.cs index ba58e1eff..0c03388b5 100644 --- a/src/NzbDrone.Core/Security/X509CertificateValidationService.cs +++ b/src/NzbDrone.Core/Security/X509CertificateValidationService.cs @@ -4,13 +4,12 @@ using System.Net.Security; using System.Security.Cryptography.X509Certificates; using NLog; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http.Dispatchers; using NzbDrone.Core.Configuration; -using NzbDrone.Core.Lifecycle; -using NzbDrone.Core.Messaging.Events; namespace NzbDrone.Core.Security { - public class X509CertificateValidationService : IHandle + public class X509CertificateValidationService : ICertificateValidationService { private readonly IConfigService _configService; private readonly Logger _logger; @@ -21,19 +20,16 @@ namespace NzbDrone.Core.Security _logger = logger; } - private bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) + public bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { - var request = sender as HttpWebRequest; - - if (request == null) + if (sender is not SslStream request) { return true; } - var cert2 = certificate as X509Certificate2; - if (cert2 != null && request != null && cert2.SignatureAlgorithm.FriendlyName == "md5RSA") + if (certificate is X509Certificate2 cert2 && cert2.SignatureAlgorithm.FriendlyName == "md5RSA") { - _logger.Error("https://{0} uses the obsolete md5 hash in it's https certificate, if that is your certificate, please (re)create certificate with better algorithm as soon as possible.", request.RequestUri.Authority); + _logger.Error("https://{0} uses the obsolete md5 hash in it's https certificate, if that is your certificate, please (re)create certificate with better algorithm as soon as possible.", request.TargetHostName); } if (sslPolicyErrors == SslPolicyErrors.None) @@ -41,12 +37,12 @@ namespace NzbDrone.Core.Security return true; } - if (request.RequestUri.Host == "localhost" || request.RequestUri.Host == "127.0.0.1") + if (request.TargetHostName == "localhost" || request.TargetHostName == "127.0.0.1") { return true; } - var ipAddresses = GetIPAddresses(request.RequestUri.Host); + var ipAddresses = GetIPAddresses(request.TargetHostName); var certificateValidation = _configService.CertificateValidation; if (certificateValidation == CertificateValidationType.Disabled) @@ -60,7 +56,7 @@ namespace NzbDrone.Core.Security return true; } - _logger.Error("Certificate validation for {0} failed. {1}", request.Address, sslPolicyErrors); + _logger.Error("Certificate validation for {0} failed. {1}", request.TargetHostName, sslPolicyErrors); return false; } @@ -74,10 +70,5 @@ namespace NzbDrone.Core.Security return Dns.GetHostEntry(host).AddressList; } - - public void Handle(ApplicationStartedEvent message) - { - ServicePointManager.ServerCertificateValidationCallback = ShouldByPassValidationError; - } } } From 04e575903feffa9e0450f3f4265d27722857fa71 Mon Sep 17 00:00:00 2001 From: ta264 Date: Thu, 18 Nov 2021 21:19:49 +0000 Subject: [PATCH 3/6] Fixed: Tray app restart (cherry picked from commit 5fce3bbedb48edc547d1e6a1db3af06b5542886d) --- src/NzbDrone.Console/Readarr.Console.csproj | 1 - src/NzbDrone.Console/app.manifest | 52 ------------------- src/NzbDrone.Host/AppLifetime.cs | 3 -- src/NzbDrone.Host/Bootstrap.cs | 13 ++++- src/NzbDrone/Readarr.csproj | 1 - src/NzbDrone/SysTray/SysTrayApp.cs | 57 +++++---------------- src/NzbDrone/WindowsApp.cs | 11 ++-- src/NzbDrone/app.manifest | 52 ------------------- 8 files changed, 30 insertions(+), 160 deletions(-) delete mode 100644 src/NzbDrone.Console/app.manifest delete mode 100644 src/NzbDrone/app.manifest diff --git a/src/NzbDrone.Console/Readarr.Console.csproj b/src/NzbDrone.Console/Readarr.Console.csproj index f738a8ef6..f88538db9 100644 --- a/src/NzbDrone.Console/Readarr.Console.csproj +++ b/src/NzbDrone.Console/Readarr.Console.csproj @@ -4,7 +4,6 @@ net6.0 ..\NzbDrone.Host\Readarr.ico - app.manifest Readarr diff --git a/src/NzbDrone.Console/app.manifest b/src/NzbDrone.Console/app.manifest deleted file mode 100644 index 8e6eb2fea..000000000 --- a/src/NzbDrone.Console/app.manifest +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - true - - - diff --git a/src/NzbDrone.Host/AppLifetime.cs b/src/NzbDrone.Host/AppLifetime.cs index d0d0955ce..0a181a7db 100644 --- a/src/NzbDrone.Host/AppLifetime.cs +++ b/src/NzbDrone.Host/AppLifetime.cs @@ -19,7 +19,6 @@ namespace NzbDrone.Host private readonly IBrowserService _browserService; private readonly IProcessProvider _processProvider; private readonly IEventAggregator _eventAggregator; - private readonly IUtilityModeRouter _utilityModeRouter; private readonly Logger _logger; public AppLifetime(IHostApplicationLifetime appLifetime, @@ -29,7 +28,6 @@ namespace NzbDrone.Host IBrowserService browserService, IProcessProvider processProvider, IEventAggregator eventAggregator, - IUtilityModeRouter utilityModeRouter, Logger logger) { _appLifetime = appLifetime; @@ -39,7 +37,6 @@ namespace NzbDrone.Host _browserService = browserService; _processProvider = processProvider; _eventAggregator = eventAggregator; - _utilityModeRouter = utilityModeRouter; _logger = logger; appLifetime.ApplicationStarted.Register(OnAppStarted); diff --git a/src/NzbDrone.Host/Bootstrap.cs b/src/NzbDrone.Host/Bootstrap.cs index 3e2e3e0b1..b73838457 100644 --- a/src/NzbDrone.Host/Bootstrap.cs +++ b/src/NzbDrone.Host/Bootstrap.cs @@ -180,7 +180,18 @@ namespace NzbDrone.Host return ApplicationModes.UninstallService; } - if (OsInfo.IsWindows && WindowsServiceHelpers.IsWindowsService()) + // IsWindowsService can throw sometimes, so wrap it + bool isWindowsService = false; + try + { + isWindowsService = WindowsServiceHelpers.IsWindowsService(); + } + catch + { + // don't care + } + + if (OsInfo.IsWindows && isWindowsService) { return ApplicationModes.Service; } diff --git a/src/NzbDrone/Readarr.csproj b/src/NzbDrone/Readarr.csproj index f01debd56..419a2af05 100644 --- a/src/NzbDrone/Readarr.csproj +++ b/src/NzbDrone/Readarr.csproj @@ -5,7 +5,6 @@ win-x64;win-x86 true ..\NzbDrone.Host\Readarr.ico - app.manifest true diff --git a/src/NzbDrone/SysTray/SysTrayApp.cs b/src/NzbDrone/SysTray/SysTrayApp.cs index 06fff943d..956cad32c 100644 --- a/src/NzbDrone/SysTray/SysTrayApp.cs +++ b/src/NzbDrone/SysTray/SysTrayApp.cs @@ -4,9 +4,8 @@ using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using Microsoft.Extensions.Hosting; -using NLog; using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Processes; +using NzbDrone.Core.Lifecycle; using NzbDrone.Host; namespace NzbDrone.SysTray @@ -14,28 +13,19 @@ namespace NzbDrone.SysTray public class SystemTrayApp : Form, IHostedService { private readonly IBrowserService _browserService; - private readonly IRuntimeInfo _runtimeInfo; - private readonly IProcessProvider _processProvider; + private readonly ILifecycleService _lifecycle; private readonly NotifyIcon _trayIcon = new NotifyIcon(); private readonly ContextMenuStrip _trayMenu = new ContextMenuStrip(); - public SystemTrayApp(IBrowserService browserService, IRuntimeInfo runtimeInfo, IProcessProvider processProvider) + public SystemTrayApp(IBrowserService browserService, ILifecycleService lifecycle) { _browserService = browserService; - _runtimeInfo = runtimeInfo; - _processProvider = processProvider; + _lifecycle = lifecycle; } public void Start() { - Application.ThreadException += OnThreadException; - Application.ApplicationExit += OnApplicationExit; - - Application.SetHighDpiMode(HighDpiMode.PerMonitor); - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - _trayMenu.Items.Add(new ToolStripMenuItem("Launch Browser", null, LaunchBrowser)); _trayMenu.Items.Add(new ToolStripSeparator()); _trayMenu.Items.Add(new ToolStripMenuItem("Exit", null, OnExit)); @@ -69,12 +59,6 @@ namespace NzbDrone.SysTray DisposeTrayIcon(); } - protected override void OnClosed(EventArgs e) - { - Console.WriteLine("Closing"); - base.OnClosed(e); - } - protected override void OnLoad(EventArgs e) { Visible = false; @@ -102,8 +86,7 @@ namespace NzbDrone.SysTray private void OnExit(object sender, EventArgs e) { - LogManager.Configuration = null; - Environment.Exit(0); + _lifecycle.Shutdown(); } private void LaunchBrowser(object sender, EventArgs e) @@ -117,33 +100,17 @@ namespace NzbDrone.SysTray } } - private void OnApplicationExit(object sender, EventArgs e) - { - if (_runtimeInfo.RestartPending) - { - _processProvider.SpawnNewProcess(_runtimeInfo.ExecutingApplication, "--restart --nobrowser"); - } - - DisposeTrayIcon(); - } - - private void OnThreadException(object sender, EventArgs e) - { - DisposeTrayIcon(); - } - private void DisposeTrayIcon() { - try - { - _trayIcon.Visible = false; - _trayIcon.Icon = null; - _trayIcon.Visible = false; - _trayIcon.Dispose(); - } - catch (Exception) + if (_trayIcon == null) { + return; } + + _trayIcon.Visible = false; + _trayIcon.Icon = null; + _trayIcon.Visible = false; + _trayIcon.Dispose(); } } } diff --git a/src/NzbDrone/WindowsApp.cs b/src/NzbDrone/WindowsApp.cs index 361e26d44..963a4d4fb 100644 --- a/src/NzbDrone/WindowsApp.cs +++ b/src/NzbDrone/WindowsApp.cs @@ -16,21 +16,22 @@ namespace NzbDrone public static void Main(string[] args) { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.SetHighDpiMode(HighDpiMode.SystemAware); + try { var startupArgs = new StartupContext(args); NzbDroneLogger.Register(startupArgs, false, true); - Bootstrap.Start(args, e => - { - e.ConfigureServices((_, s) => s.AddSingleton()); - }); + Bootstrap.Start(args, e => { e.ConfigureServices((_, s) => s.AddSingleton()); }); } catch (Exception e) { Logger.Fatal(e, "EPIC FAIL"); - MessageBox.Show($"{e.GetType().Name}: {e.Message}", buttons: MessageBoxButtons.OK, icon: MessageBoxIcon.Error, caption: "Epic Fail!"); + MessageBox.Show($"{e.GetType().Name}: {e}", buttons: MessageBoxButtons.OK, icon: MessageBoxIcon.Error, caption: "Epic Fail!"); } } } diff --git a/src/NzbDrone/app.manifest b/src/NzbDrone/app.manifest deleted file mode 100644 index 8e6eb2fea..000000000 --- a/src/NzbDrone/app.manifest +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - true - - - From 05d24821f728190437dc9cc8ecd00a271714309a Mon Sep 17 00:00:00 2001 From: ta264 Date: Thu, 18 Nov 2021 21:19:49 +0000 Subject: [PATCH 4/6] Fixed: Restarting windows service from UI (cherry picked from commit 3ae1ccc5e25eb16420c1f4ab627f42e3f478b22e) --- src/NzbDrone.Common.Test/ServiceFactoryFixture.cs | 12 ++++++++---- src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs | 11 ++++------- src/NzbDrone.Common/Readarr.Common.csproj | 1 + src/NzbDrone.Host.Test/ContainerFixture.cs | 8 ++++---- src/NzbDrone.Host/AppLifetime.cs | 2 +- src/NzbDrone.Host/Bootstrap.cs | 8 +++++--- 6 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/NzbDrone.Common.Test/ServiceFactoryFixture.cs b/src/NzbDrone.Common.Test/ServiceFactoryFixture.cs index ae6785780..1a3482582 100644 --- a/src/NzbDrone.Common.Test/ServiceFactoryFixture.cs +++ b/src/NzbDrone.Common.Test/ServiceFactoryFixture.cs @@ -3,6 +3,8 @@ using DryIoc; using DryIoc.Microsoft.DependencyInjection; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; using NUnit.Framework; using NzbDrone.Common.Composition.Extensions; using NzbDrone.Common.EnvironmentInfo; @@ -25,12 +27,14 @@ namespace NzbDrone.Common.Test .AddNzbDroneLogger() .AutoAddServices(Bootstrap.ASSEMBLIES) .AddDummyDatabase() - .AddStartupContext(new StartupContext("first", "second")) - .GetServiceProvider(); + .AddStartupContext(new StartupContext("first", "second")); - container.GetRequiredService().Register(); + container.RegisterInstance(new Mock().Object); - Mocker.SetConstant(container); + var serviceProvider = container.GetServiceProvider(); + serviceProvider.GetRequiredService().Register(); + + Mocker.SetConstant(serviceProvider); var handlers = Subject.BuildAll>() .Select(c => c.GetType().FullName); diff --git a/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs index 094ca7a57..874bc91ee 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs @@ -1,9 +1,9 @@ using System; using System.Diagnostics; using System.IO; -using System.Reflection; using System.Security.Principal; -using System.ServiceProcess; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.WindowsServices; using NLog; using NzbDrone.Common.Processes; @@ -14,14 +14,11 @@ namespace NzbDrone.Common.EnvironmentInfo private readonly Logger _logger; private readonly DateTime _startTime = DateTime.UtcNow; - public RuntimeInfo(IServiceProvider serviceProvider, Logger logger) + public RuntimeInfo(IHostLifetime hostLifetime, Logger logger) { _logger = logger; - IsWindowsService = !IsUserInteractive && - OsInfo.IsWindows && - serviceProvider.ServiceExist(ServiceProvider.SERVICE_NAME) && - serviceProvider.GetStatus(ServiceProvider.SERVICE_NAME) == ServiceControllerStatus.StartPending; + IsWindowsService = hostLifetime is WindowsServiceLifetime; //Guarded to avoid issues when running in a non-managed process var entry = Process.GetCurrentProcess().MainModule; diff --git a/src/NzbDrone.Common/Readarr.Common.csproj b/src/NzbDrone.Common/Readarr.Common.csproj index 8e5ad4f93..61e70b85f 100644 --- a/src/NzbDrone.Common/Readarr.Common.csproj +++ b/src/NzbDrone.Common/Readarr.Common.csproj @@ -7,6 +7,7 @@ + diff --git a/src/NzbDrone.Host.Test/ContainerFixture.cs b/src/NzbDrone.Host.Test/ContainerFixture.cs index da969a093..5a658b2c1 100644 --- a/src/NzbDrone.Host.Test/ContainerFixture.cs +++ b/src/NzbDrone.Host.Test/ContainerFixture.cs @@ -4,6 +4,7 @@ using DryIoc; using DryIoc.Microsoft.DependencyInjection; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Moq; using NUnit.Framework; using NzbDrone.Common; @@ -33,16 +34,15 @@ namespace NzbDrone.App.Test { var args = new StartupContext("first", "second"); - // set up a dummy broadcaster to allow tests to resolve - var mockBroadcaster = new Mock(); - var container = new Container(rules => rules.WithNzbDroneRules()) .AutoAddServices(Bootstrap.ASSEMBLIES) .AddNzbDroneLogger() .AddDummyDatabase() .AddStartupContext(args); - container.RegisterInstance(mockBroadcaster.Object); + // set up a dummy broadcaster and lifetime to allow tests to resolve + container.RegisterInstance(new Mock().Object); + container.RegisterInstance(new Mock().Object); _container = container.GetServiceProvider(); } diff --git a/src/NzbDrone.Host/AppLifetime.cs b/src/NzbDrone.Host/AppLifetime.cs index 0a181a7db..17f5bbcdc 100644 --- a/src/NzbDrone.Host/AppLifetime.cs +++ b/src/NzbDrone.Host/AppLifetime.cs @@ -68,7 +68,7 @@ namespace NzbDrone.Host private void OnAppStopped() { - if (_runtimeInfo.RestartPending) + if (_runtimeInfo.RestartPending && !_runtimeInfo.IsWindowsService) { var restartArgs = GetRestartArgs(); diff --git a/src/NzbDrone.Host/Bootstrap.cs b/src/NzbDrone.Host/Bootstrap.cs index b73838457..7df0e2327 100644 --- a/src/NzbDrone.Host/Bootstrap.cs +++ b/src/NzbDrone.Host/Bootstrap.cs @@ -180,15 +180,17 @@ namespace NzbDrone.Host return ApplicationModes.UninstallService; } + Logger.Debug("Getting windows service status"); + // IsWindowsService can throw sometimes, so wrap it - bool isWindowsService = false; + var isWindowsService = false; try { isWindowsService = WindowsServiceHelpers.IsWindowsService(); } - catch + catch (Exception e) { - // don't care + Logger.Error(e, "Failed to get service status"); } if (OsInfo.IsWindows && isWindowsService) From bf852cadbe9b2e51b86c2f3694e6d1fc9724eaaa Mon Sep 17 00:00:00 2001 From: ta264 Date: Thu, 18 Nov 2021 21:42:59 +0000 Subject: [PATCH 5/6] Fixed: Prevent frontend errors when many books added to Readarr --- .../src/Book/Edit/EditBookModalContent.js | 48 ++++++++++++----- .../Edit/EditBookModalContentConnector.js | 28 +++++++++- frontend/src/Store/Actions/editionActions.js | 54 +++++++++++++++++++ frontend/src/Store/Actions/index.js | 2 + .../Books/Repositories/EditionRepository.cs | 6 +++ .../Books/Services/EditionService.cs | 6 +-- src/Readarr.Api.V1/Books/BookController.cs | 2 +- .../Editions/EditionController.cs | 27 ++++++++++ 8 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 frontend/src/Store/Actions/editionActions.js create mode 100644 src/Readarr.Api.V1/Editions/EditionController.cs diff --git a/frontend/src/Book/Edit/EditBookModalContent.js b/frontend/src/Book/Edit/EditBookModalContent.js index 7d58128a6..d2634461f 100644 --- a/frontend/src/Book/Edit/EditBookModalContent.js +++ b/frontend/src/Book/Edit/EditBookModalContent.js @@ -6,11 +6,13 @@ import FormInputGroup from 'Components/Form/FormInputGroup'; import FormLabel from 'Components/Form/FormLabel'; import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes } from 'Helpers/Props'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; import translate from 'Utilities/String/translate'; class EditBookModalContent extends Component { @@ -36,6 +38,9 @@ class EditBookModalContent extends Component { authorName, statistics, item, + isFetching, + isPopulated, + error, isSaving, onInputChange, onModalClose, @@ -49,6 +54,7 @@ class EditBookModalContent extends Component { } = item; const hasFile = statistics ? statistics.bookFileCount : 0; + const errorMessage = getErrorMessage(error, 'Unable to load editions'); return ( @@ -88,20 +94,33 @@ class EditBookModalContent extends Component { /> - - - {translate('Edition')} - + { + isFetching && + + } - - + { + error && +
{errorMessage}
+ } + + { + isPopulated && !isFetching && !!editions.value.length && + + + {translate('Edition')} + + + + + } @@ -131,6 +150,9 @@ EditBookModalContent.propTypes = { authorName: PropTypes.string.isRequired, statistics: PropTypes.object.isRequired, item: PropTypes.object.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isPopulated: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired, onInputChange: PropTypes.func.isRequired, onSavePress: PropTypes.func.isRequired, diff --git a/frontend/src/Book/Edit/EditBookModalContentConnector.js b/frontend/src/Book/Edit/EditBookModalContentConnector.js index ebf61ab2a..f4e9fbf83 100644 --- a/frontend/src/Book/Edit/EditBookModalContentConnector.js +++ b/frontend/src/Book/Edit/EditBookModalContentConnector.js @@ -4,6 +4,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { saveBook, setBookValue } from 'Store/Actions/bookActions'; +import { clearEditions, fetchEditions } from 'Store/Actions/editionActions'; import createAuthorSelector from 'Store/Selectors/createAuthorSelector'; import createBookSelector from 'Store/Selectors/createBookSelector'; import selectSettings from 'Store/Selectors/selectSettings'; @@ -12,15 +13,25 @@ import EditBookModalContent from './EditBookModalContent'; function createMapStateToProps() { return createSelector( (state) => state.books, + (state) => state.editions, createBookSelector(), createAuthorSelector(), - (bookState, book, author) => { + (bookState, editionState, book, author) => { const { isSaving, saveError, pendingChanges } = bookState; + const { + isFetching, + isPopulated, + error, + items + } = editionState; + + book.editions = items; + const bookSettings = _.pick(book, [ 'monitored', 'anyEditionOk', @@ -34,6 +45,9 @@ function createMapStateToProps() { authorName: author.authorName, bookType: book.bookType, statistics: book.statistics, + isFetching, + isPopulated, + error, isSaving, saveError, item: settings.settings, @@ -44,6 +58,8 @@ function createMapStateToProps() { } const mapDispatchToProps = { + dispatchFetchEditions: fetchEditions, + dispatchClearEditions: clearEditions, dispatchSetBookValue: setBookValue, dispatchSaveBook: saveBook }; @@ -53,12 +69,20 @@ class EditBookModalContentConnector extends Component { // // Lifecycle + componentDidMount() { + this.props.dispatchFetchEditions({ bookId: this.props.bookId }); + } + componentDidUpdate(prevProps, prevState) { if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { this.props.onModalClose(); } } + componentWillUnmount() { + this.props.dispatchClearEditions(); + } + // // Listeners @@ -90,6 +114,8 @@ EditBookModalContentConnector.propTypes = { bookId: PropTypes.number, isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, + dispatchFetchEditions: PropTypes.func.isRequired, + dispatchClearEditions: PropTypes.func.isRequired, dispatchSetBookValue: PropTypes.func.isRequired, dispatchSaveBook: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired diff --git a/frontend/src/Store/Actions/editionActions.js b/frontend/src/Store/Actions/editionActions.js new file mode 100644 index 000000000..f6e0c6f93 --- /dev/null +++ b/frontend/src/Store/Actions/editionActions.js @@ -0,0 +1,54 @@ +import { createAction } from 'redux-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createClearReducer from './Creators/Reducers/createClearReducer'; + +// +// Variables + +export const section = 'editions'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + itemMap: {} +}; + +// +// Actions Types + +export const FETCH_EDITIONS = 'editions/fetchEditions'; +export const CLEAR_EDITIONS = 'editions/clearEditions'; + +// +// Action Creators + +export const fetchEditions = createThunk(FETCH_EDITIONS); +export const clearEditions = createAction(CLEAR_EDITIONS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_EDITIONS]: createFetchHandler(section, '/edition') +}); + +// +// Reducers +export const reducers = createHandleActions({ + + [CLEAR_EDITIONS]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + itemMap: {} + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 705ec4164..7565fede3 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -14,6 +14,7 @@ import * as calendar from './calendarActions'; import * as captcha from './captchaActions'; import * as commands from './commandActions'; import * as customFilters from './customFilterActions'; +import * as editions from './editionActions'; import * as history from './historyActions'; import * as interactiveImportActions from './interactiveImportActions'; import * as oAuth from './oAuthActions'; @@ -47,6 +48,7 @@ export default [ captcha, commands, customFilters, + editions, history, interactiveImportActions, oAuth, diff --git a/src/NzbDrone.Core/Books/Repositories/EditionRepository.cs b/src/NzbDrone.Core/Books/Repositories/EditionRepository.cs index b7d767c64..3a49ead6c 100644 --- a/src/NzbDrone.Core/Books/Repositories/EditionRepository.cs +++ b/src/NzbDrone.Core/Books/Repositories/EditionRepository.cs @@ -9,6 +9,7 @@ namespace NzbDrone.Core.Books { public interface IEditionRepository : IBasicRepository { + List GetAllMonitoredEditions(); Edition FindByForeignEditionId(string foreignEditionId); List FindByBook(int id); List FindByAuthor(int id); @@ -25,6 +26,11 @@ namespace NzbDrone.Core.Books { } + public List GetAllMonitoredEditions() + { + return Query(x => x.Monitored == true); + } + public Edition FindByForeignEditionId(string foreignEditionId) { var edition = Query(x => x.ForeignEditionId == foreignEditionId).SingleOrDefault(); diff --git a/src/NzbDrone.Core/Books/Services/EditionService.cs b/src/NzbDrone.Core/Books/Services/EditionService.cs index 81fd1563b..421a537ff 100644 --- a/src/NzbDrone.Core/Books/Services/EditionService.cs +++ b/src/NzbDrone.Core/Books/Services/EditionService.cs @@ -12,7 +12,7 @@ namespace NzbDrone.Core.Books { Edition GetEdition(int id); Edition GetEditionByForeignEditionId(string foreignEditionId); - List GetAllEditions(); + List GetAllMonitoredEditions(); void InsertMany(List editions); void UpdateMany(List editions); void DeleteMany(List editions); @@ -48,9 +48,9 @@ namespace NzbDrone.Core.Books return _editionRepository.FindByForeignEditionId(foreignEditionId); } - public List GetAllEditions() + public List GetAllMonitoredEditions() { - return _editionRepository.All().ToList(); + return _editionRepository.GetAllMonitoredEditions(); } public void InsertMany(List editions) diff --git a/src/Readarr.Api.V1/Books/BookController.cs b/src/Readarr.Api.V1/Books/BookController.cs index f8e708f60..ed4dbe9d6 100644 --- a/src/Readarr.Api.V1/Books/BookController.cs +++ b/src/Readarr.Api.V1/Books/BookController.cs @@ -73,7 +73,7 @@ namespace Readarr.Api.V1.Books var books = _bookService.GetAllBooks(); var authors = _authorService.GetAllAuthors().ToDictionary(x => x.AuthorMetadataId); - var editions = _editionService.GetAllEditions().GroupBy(x => x.BookId).ToDictionary(x => x.Key, y => y.ToList()); + var editions = _editionService.GetAllMonitoredEditions().GroupBy(x => x.BookId).ToDictionary(x => x.Key, y => y.ToList()); foreach (var book in books) { diff --git a/src/Readarr.Api.V1/Editions/EditionController.cs b/src/Readarr.Api.V1/Editions/EditionController.cs new file mode 100644 index 000000000..71aedc399 --- /dev/null +++ b/src/Readarr.Api.V1/Editions/EditionController.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Books; +using Readarr.Api.V1.Books; +using Readarr.Http; + +namespace NzbDrone.Api.V1.Editions +{ + [V1ApiController] + public class EditionController : Controller + { + private readonly IEditionService _editionService; + + public EditionController(IEditionService editionService) + { + _editionService = editionService; + } + + [HttpGet] + public List GetEditions(int bookId) + { + var editions = _editionService.GetEditionsByBook(bookId); + + return editions.ToResource(); + } + } +} From 4887ed0d2fd3891711189c7e42694794d8f70b7e Mon Sep 17 00:00:00 2001 From: ta264 Date: Fri, 19 Nov 2021 11:33:23 +0000 Subject: [PATCH 6/6] New: Book editor on author details page --- frontend/src/Author/Details/AuthorDetails.js | 135 +++++++++++++- .../Author/Details/AuthorDetailsConnector.js | 14 +- .../src/Author/Details/AuthorDetailsSeason.js | 48 ++++- frontend/src/Author/Details/BookRow.js | 19 ++ frontend/src/Book/Editor/BookEditorFooter.css | 70 +++++++ frontend/src/Book/Editor/BookEditorFooter.js | 156 ++++++++++++++++ .../src/Book/Editor/BookEditorFooterLabel.css | 8 + .../src/Book/Editor/BookEditorFooterLabel.js | 40 ++++ .../src/Book/Editor/Delete/DeleteBookModal.js | 31 ++++ .../Editor/Delete/DeleteBookModalContent.css | 9 + .../Editor/Delete/DeleteBookModalContent.js | 172 ++++++++++++++++++ .../Delete/DeleteBookModalContentConnector.js | 54 ++++++ frontend/src/Helpers/Props/icons.js | 2 + frontend/src/Store/Actions/bookActions.js | 8 + .../src/Store/Actions/bookEditorActions.js | 114 ++++++++++++ frontend/src/Store/Actions/index.js | 2 + src/NzbDrone.Core/Localization/Core/en.json | 6 + .../Books/BookEditorController.cs | 48 +++++ .../Books/BookEditorResource.cs | 12 ++ 19 files changed, 939 insertions(+), 9 deletions(-) create mode 100644 frontend/src/Book/Editor/BookEditorFooter.css create mode 100644 frontend/src/Book/Editor/BookEditorFooter.js create mode 100644 frontend/src/Book/Editor/BookEditorFooterLabel.css create mode 100644 frontend/src/Book/Editor/BookEditorFooterLabel.js create mode 100644 frontend/src/Book/Editor/Delete/DeleteBookModal.js create mode 100644 frontend/src/Book/Editor/Delete/DeleteBookModalContent.css create mode 100644 frontend/src/Book/Editor/Delete/DeleteBookModalContent.js create mode 100644 frontend/src/Book/Editor/Delete/DeleteBookModalContentConnector.js create mode 100644 frontend/src/Store/Actions/bookEditorActions.js create mode 100644 src/Readarr.Api.V1/Books/BookEditorController.cs create mode 100644 src/Readarr.Api.V1/Books/BookEditorResource.cs diff --git a/frontend/src/Author/Details/AuthorDetails.js b/frontend/src/Author/Details/AuthorDetails.js index 4eb43455d..310f18c3f 100644 --- a/frontend/src/Author/Details/AuthorDetails.js +++ b/frontend/src/Author/Details/AuthorDetails.js @@ -5,6 +5,7 @@ import DeleteAuthorModal from 'Author/Delete/DeleteAuthorModal'; import EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector'; import AuthorHistoryTable from 'Author/History/AuthorHistoryTable'; import MonitoringOptionsModal from 'Author/MonitoringOptions/MonitoringOptionsModal'; +import BookEditorFooter from 'Book/Editor/BookEditorFooter'; import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable'; import IconButton from 'Components/Link/IconButton'; import Link from 'Components/Link/Link'; @@ -22,6 +23,7 @@ import InteractiveSearchTable from 'InteractiveSearch/InteractiveSearchTable'; import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector'; import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; import selectAll from 'Utilities/Table/selectAll'; import toggleSelected from 'Utilities/Table/toggleSelected'; import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal'; @@ -53,13 +55,56 @@ class AuthorDetails extends Component { isDeleteAuthorModalOpen: false, isInteractiveImportModalOpen: false, isMonitorOptionsModalOpen: false, + isBookEditorActive: false, allExpanded: false, allCollapsed: false, expandedState: {}, + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, selectedTabIndex: 0 }; } + // + // Control + + setSelectedState = (items) => { + const { + selectedState + } = this.state; + + const newSelectedState = {}; + + items.forEach((item) => { + const isItemSelected = selectedState[item.id]; + + if (isItemSelected) { + newSelectedState[item.id] = isItemSelected; + } else { + newSelectedState[item.id] = false; + } + }); + + const selectedCount = getSelectedIds(newSelectedState).length; + const newStateCount = Object.keys(newSelectedState).length; + let isAllSelected = false; + let isAllUnselected = false; + + if (selectedCount === 0) { + isAllUnselected = true; + } else if (selectedCount === newStateCount) { + isAllSelected = true; + } + + this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected }); + } + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + // // Listeners @@ -114,6 +159,10 @@ class AuthorDetails extends Component { this.setState({ isMonitorOptionsModalOpen: false }); } + onBookEditorTogglePress = () => { + this.setState({ isBookEditorActive: !this.state.isBookEditorActive }); + } + onExpandAllPress = () => { const { allExpanded, @@ -137,6 +186,27 @@ class AuthorDetails extends Component { }); } + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectAllPress = () => { + this.onSelectAllChange({ value: !this.state.allSelected }); + } + + onSelectedChange = (items, id, value, shiftKey = false) => { + this.setState((state) => { + return toggleSelected(state, items, id, value, shiftKey); + }); + } + + onSaveSelected = (changes) => { + this.props.onSaveSelected({ + bookIds: this.getSelectedIds(), + ...changes + }); + } + onTabSelect = (index, lastIndex) => { this.setState({ selectedTabIndex: index }); } @@ -165,6 +235,10 @@ class AuthorDetails extends Component { nextAuthor, onRefreshPress, onSearchPress, + isSaving, + saveError, + isDeleting, + deleteError, statistics } = this.props; @@ -175,6 +249,9 @@ class AuthorDetails extends Component { isDeleteAuthorModalOpen, isInteractiveImportModalOpen, isMonitorOptionsModalOpen, + isBookEditorActive, + allSelected, + selectedState, allExpanded, allCollapsed, expandedState, @@ -189,6 +266,8 @@ class AuthorDetails extends Component { expandIcon = icons.EXPAND; } + const selectedBookIds = this.getSelectedIds(); + return ( @@ -252,6 +331,33 @@ class AuthorDetails extends Component { iconName={icons.DELETE} onPress={this.onDeleteAuthorPress} /> + + + + { + isBookEditorActive ? + : + + } + + { + isBookEditorActive ? + : + null + } + @@ -377,7 +483,11 @@ class AuthorDetails extends Component { @@ -422,7 +532,6 @@ class AuthorDetails extends Component { } -
@@ -474,6 +583,19 @@ class AuthorDetails extends Component { onModalClose={this.onMonitorOptionsClose} /> + + { + isBookEditorActive && + + } ); } @@ -493,7 +615,6 @@ AuthorDetails.propTypes = { images: PropTypes.arrayOf(PropTypes.object).isRequired, alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired, tags: PropTypes.arrayOf(PropTypes.number).isRequired, - isSaving: PropTypes.bool.isRequired, isRefreshing: PropTypes.bool.isRequired, isSearching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired, @@ -510,13 +631,17 @@ AuthorDetails.propTypes = { isSmallScreen: PropTypes.bool.isRequired, onMonitorTogglePress: PropTypes.func.isRequired, onRefreshPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired + onSearchPress: PropTypes.func.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + onSaveSelected: PropTypes.func.isRequired }; AuthorDetails.defaultProps = { statistics: {}, - tags: [], - isSaving: false + tags: [] }; export default AuthorDetails; diff --git a/frontend/src/Author/Details/AuthorDetailsConnector.js b/frontend/src/Author/Details/AuthorDetailsConnector.js index 5bc1add8c..13b34e77a 100644 --- a/frontend/src/Author/Details/AuthorDetailsConnector.js +++ b/frontend/src/Author/Details/AuthorDetailsConnector.js @@ -6,6 +6,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import * as commandNames from 'Commands/commandNames'; import { toggleAuthorMonitored } from 'Store/Actions/authorActions'; +import { saveBookEditor } from 'Store/Actions/bookEditorActions'; import { clearBookFiles, fetchBookFiles } from 'Store/Actions/bookFileActions'; import { executeCommand } from 'Store/Actions/commandActions'; import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; @@ -21,7 +22,8 @@ import AuthorDetails from './AuthorDetails'; const selectBooks = createSelector( (state) => state.books, - (books) => { + (state) => state.bookEditor, + (books, editor) => { const { items, isFetching, @@ -37,7 +39,8 @@ const selectBooks = createSelector( isBooksPopulated: isPopulated, booksError: error, hasBooks, - hasMonitoredBooks + hasMonitoredBooks, + ...editor }; } ); @@ -187,6 +190,7 @@ function createMapStateToProps() { const mapDispatchToProps = { fetchSeries, clearSeries, + saveBookEditor, fetchBookFiles, clearBookFiles, toggleAuthorMonitored, @@ -282,6 +286,10 @@ class AuthorDetailsConnector extends Component { }); } + onSaveSelected = (payload) => { + this.props.saveBookEditor(payload); + } + // // Render @@ -292,6 +300,7 @@ class AuthorDetailsConnector extends Component { onMonitorTogglePress={this.onMonitorTogglePress} onRefreshPress={this.onRefreshPress} onSearchPress={this.onSearchPress} + onSaveSelected={this.onSaveSelected} /> ); } @@ -307,6 +316,7 @@ AuthorDetailsConnector.propTypes = { isRenamingAuthor: PropTypes.bool.isRequired, fetchSeries: PropTypes.func.isRequired, clearSeries: PropTypes.func.isRequired, + saveBookEditor: PropTypes.func.isRequired, fetchBookFiles: PropTypes.func.isRequired, clearBookFiles: PropTypes.func.isRequired, toggleAuthorMonitored: PropTypes.func.isRequired, diff --git a/frontend/src/Author/Details/AuthorDetailsSeason.js b/frontend/src/Author/Details/AuthorDetailsSeason.js index 1abc27847..34ea09b38 100644 --- a/frontend/src/Author/Details/AuthorDetailsSeason.js +++ b/frontend/src/Author/Details/AuthorDetailsSeason.js @@ -4,6 +4,7 @@ import React, { Component } from 'react'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import { sortDirections } from 'Helpers/Props'; +import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; import getToggledRange from 'Utilities/Table/getToggledRange'; import BookRowConnector from './BookRowConnector'; import styles from './AuthorDetailsSeason.css'; @@ -21,6 +22,26 @@ class AuthorDetailsSeason extends Component { }; } + componentDidMount() { + this.props.setSelectedState(this.props.items); + } + + componentDidUpdate(prevProps) { + const { + items, + sortKey, + sortDirection, + setSelectedState + } = this.props; + + if (sortKey !== prevProps.sortKey || + sortDirection !== prevProps.sortDirection || + hasDifferentItemsOrOrder(prevProps.items, items) + ) { + setSelectedState(items); + } + } + // // Listeners @@ -42,26 +63,42 @@ class AuthorDetailsSeason extends Component { this.props.onMonitorBookPress(_.uniq(bookIds), monitored); } + onSelectedChange = ({ id, value, shiftKey = false }) => { + const { + onSelectedChange, + items + } = this.props; + + return onSelectedChange(items, id, value, shiftKey); + } + // // Render render() { const { items, + isBookEditorActive, columns, sortKey, sortDirection, onSortPress, - onTableOptionChange + onTableOptionChange, + selectedState } = this.props; + let titleColumns = columns; + if (!isBookEditorActive) { + titleColumns = columns.filter((x) => x.name !== 'select'); + } + return (
); }) @@ -92,9 +132,13 @@ AuthorDetailsSeason.propTypes = { sortKey: PropTypes.string, sortDirection: PropTypes.oneOf(sortDirections.all), items: PropTypes.arrayOf(PropTypes.object).isRequired, + isBookEditorActive: PropTypes.bool.isRequired, + selectedState: PropTypes.object.isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, onTableOptionChange: PropTypes.func.isRequired, onExpandPress: PropTypes.func.isRequired, + setSelectedState: PropTypes.func.isRequired, + onSelectedChange: PropTypes.func.isRequired, onSortPress: PropTypes.func.isRequired, onMonitorBookPress: PropTypes.func.isRequired, uiSettings: PropTypes.object.isRequired diff --git a/frontend/src/Author/Details/BookRow.js b/frontend/src/Author/Details/BookRow.js index 292232bf2..969b38aed 100644 --- a/frontend/src/Author/Details/BookRow.js +++ b/frontend/src/Author/Details/BookRow.js @@ -6,6 +6,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton'; import StarRating from 'Components/StarRating'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import TableRow from 'Components/Table/TableRow'; import BookStatus from './BookStatus'; import styles from './BookRow.css'; @@ -65,6 +66,9 @@ class BookRow extends Component { authorMonitored, titleSlug, bookFiles, + isBookEditorActive, + isSelected, + onSelectedChange, columns } = this.props; @@ -84,6 +88,18 @@ class BookRow extends Component { return null; } + if (isBookEditorActive && name === 'select') { + return ( + + ); + } + if (name === 'monitored') { return ( { + this.setState({ [name]: value }); + + if (value === NO_CHANGE) { + return; + } + + switch (name) { + case 'monitored': + this.props.onSaveSelected({ [name]: value === 'monitored' }); + break; + default: + this.props.onSaveSelected({ [name]: value }); + } + } + + onDeleteSelectedPress = () => { + this.setState({ isDeleteBookModalOpen: true }); + } + + onDeleteBookModalClose = () => { + this.setState({ isDeleteBookModalOpen: false }); + } + + // + // Render + + render() { + const { + bookIds, + selectedCount, + isSaving, + isDeleting + } = this.props; + + const { + monitored, + isDeleteBookModalOpen + } = this.state; + + const monitoredOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'monitored', value: 'Monitored' }, + { key: 'unmonitored', value: 'Unmonitored' } + ]; + + return ( + +
+ + + +
+ +
+
+ + +
+ + Delete + +
+
+
+ + + +
+ ); + } +} + +BookEditorFooter.propTypes = { + bookIds: PropTypes.arrayOf(PropTypes.number).isRequired, + selectedCount: PropTypes.number.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + onSaveSelected: PropTypes.func.isRequired +}; + +export default BookEditorFooter; diff --git a/frontend/src/Book/Editor/BookEditorFooterLabel.css b/frontend/src/Book/Editor/BookEditorFooterLabel.css new file mode 100644 index 000000000..9b4b40be6 --- /dev/null +++ b/frontend/src/Book/Editor/BookEditorFooterLabel.css @@ -0,0 +1,8 @@ +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.savingIcon { + margin-left: 8px; +} diff --git a/frontend/src/Book/Editor/BookEditorFooterLabel.js b/frontend/src/Book/Editor/BookEditorFooterLabel.js new file mode 100644 index 000000000..7bda47bbf --- /dev/null +++ b/frontend/src/Book/Editor/BookEditorFooterLabel.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import { icons } from 'Helpers/Props'; +import styles from './BookEditorFooterLabel.css'; + +function BookEditorFooterLabel(props) { + const { + className, + label, + isSaving + } = props; + + return ( +
+ {label} + + { + isSaving && + + } +
+ ); +} + +BookEditorFooterLabel.propTypes = { + className: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isSaving: PropTypes.bool.isRequired +}; + +BookEditorFooterLabel.defaultProps = { + className: styles.label +}; + +export default BookEditorFooterLabel; diff --git a/frontend/src/Book/Editor/Delete/DeleteBookModal.js b/frontend/src/Book/Editor/Delete/DeleteBookModal.js new file mode 100644 index 000000000..9d0bd46b1 --- /dev/null +++ b/frontend/src/Book/Editor/Delete/DeleteBookModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DeleteBookModalContentConnector from './DeleteBookModalContentConnector'; + +function DeleteBookModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +DeleteBookModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default DeleteBookModal; diff --git a/frontend/src/Book/Editor/Delete/DeleteBookModalContent.css b/frontend/src/Book/Editor/Delete/DeleteBookModalContent.css new file mode 100644 index 000000000..1e4dc7711 --- /dev/null +++ b/frontend/src/Book/Editor/Delete/DeleteBookModalContent.css @@ -0,0 +1,9 @@ +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.deleteFilesMessage { + margin-top: 20px; + color: $dangerColor; +} diff --git a/frontend/src/Book/Editor/Delete/DeleteBookModalContent.js b/frontend/src/Book/Editor/Delete/DeleteBookModalContent.js new file mode 100644 index 000000000..f94039939 --- /dev/null +++ b/frontend/src/Book/Editor/Delete/DeleteBookModalContent.js @@ -0,0 +1,172 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './DeleteBookModalContent.css'; + +class DeleteBookModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + deleteFiles: false, + addImportListExclusion: true + }; + } + + // + // Listeners + + onDeleteFilesChange = ({ value }) => { + this.setState({ deleteFiles: value }); + } + + onAddImportListExclusionChange = ({ value }) => { + this.setState({ addImportListExclusion: value }); + } + + onDeleteBookConfirmed = () => { + const { + deleteFiles, + addImportListExclusion + } = this.state; + + this.setState({ deleteFiles: false }); + this.props.onDeleteSelectedPress(deleteFiles, addImportListExclusion); + } + + // + // Render + + render() { + const { + book, + files, + onModalClose + } = this.props; + + const { + deleteFiles, + addImportListExclusion + } = this.state; + + return ( + + + Delete Selected Book + + + +
+ + {`Delete File${book.length > 1 ? 's' : ''}`} + + + + + + {translate('AddListExclusion')} + + + + + { + !addImportListExclusion && +
+
+ {translate('IfYouDontAddAnImportListExclusionAndTheAuthorHasAMetadataProfileOtherThanNoneThenThisBookMayBeReaddedDuringTheNextAuthorRefresh')} +
+
+ } + +
+ +
+ {`Are you sure you want to delete ${book.length} selected book${book.length > 1 ? 's' : ''}${deleteFiles ? ' and their files' : ''}?`} +
+ +
    + { + book.map((s) => { + return ( +
  • + {s.title} +
  • + ); + }) + } +
+ + { + deleteFiles && +
+
+ {translate('TheFollowingFilesWillBeDeleted')} +
+
    + { + files.map((s) => { + return ( +
  • + {s.path} +
  • + ); + }) + } +
+
+ } +
+ + + + + + +
+ ); + } +} + +DeleteBookModalContent.propTypes = { + book: PropTypes.arrayOf(PropTypes.object).isRequired, + files: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteSelectedPress: PropTypes.func.isRequired +}; + +export default DeleteBookModalContent; diff --git a/frontend/src/Book/Editor/Delete/DeleteBookModalContentConnector.js b/frontend/src/Book/Editor/Delete/DeleteBookModalContentConnector.js new file mode 100644 index 000000000..234e56a29 --- /dev/null +++ b/frontend/src/Book/Editor/Delete/DeleteBookModalContentConnector.js @@ -0,0 +1,54 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { bulkDeleteBook } from 'Store/Actions/bookEditorActions'; +import DeleteBookModalContent from './DeleteBookModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { bookIds }) => bookIds, + (state) => state.books.items, + (state) => state.bookFiles.items, + (bookIds, allBooks, allBookFiles) => { + const selectedBook = _.intersectionWith(allBooks, bookIds, (s, id) => { + return s.id === id; + }); + + const sortedBook = _.orderBy(selectedBook, 'title'); + + const selectedFiles = _.intersectionWith(allBookFiles, bookIds, (s, id) => { + return s.bookId === id; + }); + + const files = _.orderBy(selectedFiles, ['bookId', 'path']); + + const book = _.map(sortedBook, (s) => { + return { + title: s.title, + path: s.path + }; + }); + + return { + book, + files + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onDeleteSelectedPress(deleteFiles, addImportListExclusion) { + dispatch(bulkDeleteBook({ + bookIds: props.bookIds, + deleteFiles, + addImportListExclusion + })); + + props.onModalClose(); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteBookModalContent); diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index fd1cf2109..8b2f10ebc 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -36,6 +36,7 @@ import { faCaretDown as fasCaretDown, faCheck as fasCheck, faCheckCircle as fasCheckCircle, + faCheckSquare as fasCheckSquare, faChevronCircleDown as fasChevronCircleDown, faChevronCircleRight as fasChevronCircleRight, faChevronCircleUp as fasChevronCircleUp, @@ -127,6 +128,7 @@ export const CARET_DOWN = fasCaretDown; export const CHECK = fasCheck; export const CHECK_INDETERMINATE = fasMinus; export const CHECK_CIRCLE = fasCheckCircle; +export const CHECK_SQUARE = fasCheckSquare; export const CIRCLE = fasCircle; export const CIRCLE_OUTLINE = farCircle; export const CLEAR = fasTrashAlt; diff --git a/frontend/src/Store/Actions/bookActions.js b/frontend/src/Store/Actions/bookActions.js index 63549ef37..b20ac56aa 100644 --- a/frontend/src/Store/Actions/bookActions.js +++ b/frontend/src/Store/Actions/bookActions.js @@ -132,6 +132,14 @@ export const defaultState = { }, columns: [ + { + name: 'select', + columnLabel: 'Select', + isSortable: false, + isVisible: true, + isModifiable: false, + isHidden: true + }, { name: 'monitored', columnLabel: 'Monitored', diff --git a/frontend/src/Store/Actions/bookEditorActions.js b/frontend/src/Store/Actions/bookEditorActions.js new file mode 100644 index 000000000..83e53e414 --- /dev/null +++ b/frontend/src/Store/Actions/bookEditorActions.js @@ -0,0 +1,114 @@ +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, updateItem } from './baseActions'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'bookEditor'; + +// +// State + +export const defaultState = { + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null +}; + +// +// Actions Types + +export const SAVE_BOOK_EDITOR = 'bookEditor/saveBookEditor'; +export const BULK_DELETE_BOOK = 'bookEditor/bulkDeleteBook'; + +// +// Action Creators + +export const saveBookEditor = createThunk(SAVE_BOOK_EDITOR); +export const bulkDeleteBook = createThunk(BULK_DELETE_BOOK); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [SAVE_BOOK_EDITOR]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: '/book/editor', + method: 'PUT', + data: JSON.stringify(payload), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(batchActions([ + ...data.map((book) => { + return updateItem({ + id: book.id, + section: 'books', + ...book + }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }, + + [BULK_DELETE_BOOK]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isDeleting: true + })); + + const promise = createAjaxRequest({ + url: '/book/editor', + method: 'DELETE', + data: JSON.stringify(payload), + dataType: 'json' + }).request; + + promise.done(() => { + // SignalR will take care of removing the book from the collection + + dispatch(set({ + section, + isDeleting: false, + deleteError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 7565fede3..1627bff02 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -6,6 +6,7 @@ import * as authorHistory from './authorHistoryActions'; import * as authorIndex from './authorIndexActions'; import * as blocklist from './blocklistActions'; import * as books from './bookActions'; +import * as bookEditor from './bookEditorActions'; import * as bookFiles from './bookFileActions'; import * as bookHistory from './bookHistoryActions'; import * as bookIndex from './bookIndexActions'; @@ -43,6 +44,7 @@ export default [ bookHistory, bookIndex, books, + bookEditor, bookStudio, calendar, captcha, diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index aff7febff..beeaeda2f 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -66,6 +66,7 @@ "Book": "Book", "BookAvailableButMissing": "Book Available, but Missing", "BookDownloaded": "Book Downloaded", + "BookEditor": "Book Editor", "BookFileCountBookCountTotalTotalBookCountInterp": "{0} / {1} (Total: {2})", "BookFileCounttotalBookCountBooksDownloadedInterp": "{0}/{1} books downloaded", "BookFilesCountMessage": "No book files", @@ -73,6 +74,7 @@ "BookIsDownloading": "Book is downloading", "BookIsDownloadingInterp": "Book is downloading - {0}% {1}", "BookIsNotMonitored": "Book is not monitored", + "BookList": "Book List", "BookMissingFromDisk": "Book missing from disk", "BookMonitoring": "Book Monitoring", "BookNaming": "Book Naming", @@ -558,7 +560,9 @@ "SearchSelected": "Search Selected", "Season": "Season", "Security": "Security", + "SelectAll": "Select All", "SelectedCountAuthorsSelectedInterp": "{0} Author(s) Selected", + "SelectedCountBooksSelectedInterp": "{0} Book(s) Selected", "SendAnonymousUsageData": "Send Anonymous Usage Data", "SendMetadataToCalibre": "Send Metadata to Calibre", "Series": "Series", @@ -644,6 +648,7 @@ "TestAllLists": "Test All Lists", "TheAuthorFolderAndAllOfItsContentWillBeDeleted": "The author folder {0} and all of its content will be deleted.", "TheBooksFilesWillBeDeleted": "The book's files will be deleted.", + "TheFollowingFilesWillBeDeleted": "The following files will be deleted:", "ThisWillApplyToAllIndexersPleaseFollowTheRulesSetForthByThem": "This will apply to all indexers, please follow the rules set forth by them", "Time": "Time", "TimeFormat": "Time Format", @@ -706,6 +711,7 @@ "UnmappedFiles": "UnmappedFiles", "Unmonitored": "Unmonitored", "UnmonitoredHelpText": "Include unmonitored books in the iCal feed", + "UnselectAll": "Unselect All", "UpdateAll": "Update all", "UpdateAutomaticallyHelpText": "Automatically download and install updates. You will still be able to install from System: Updates", "UpdateCovers": "Update Covers", diff --git a/src/Readarr.Api.V1/Books/BookEditorController.cs b/src/Readarr.Api.V1/Books/BookEditorController.cs new file mode 100644 index 000000000..7a4e9ea1e --- /dev/null +++ b/src/Readarr.Api.V1/Books/BookEditorController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Books; +using NzbDrone.Core.Messaging.Commands; +using Readarr.Http; + +namespace Readarr.Api.V1.Books +{ + [V1ApiController("book/editor")] + public class BookEditorController : Controller + { + private readonly IBookService _bookService; + private readonly IManageCommandQueue _commandQueueManager; + + public BookEditorController(IBookService bookService, IManageCommandQueue commandQueueManager) + { + _bookService = bookService; + _commandQueueManager = commandQueueManager; + } + + [HttpPut] + public IActionResult SaveAll([FromBody] BookEditorResource resource) + { + var booksToUpdate = _bookService.GetBooks(resource.BookIds); + + foreach (var book in booksToUpdate) + { + if (resource.Monitored.HasValue) + { + book.Monitored = resource.Monitored.Value; + } + } + + _bookService.UpdateMany(booksToUpdate); + return Accepted(booksToUpdate.ToResource()); + } + + [HttpDelete] + public object DeleteBook([FromBody] BookEditorResource resource) + { + foreach (var bookId in resource.BookIds) + { + _bookService.DeleteBook(bookId, resource.DeleteFiles ?? false, resource.AddImportListExclusion ?? false); + } + + return new object(); + } + } +} diff --git a/src/Readarr.Api.V1/Books/BookEditorResource.cs b/src/Readarr.Api.V1/Books/BookEditorResource.cs new file mode 100644 index 000000000..d2658d8c3 --- /dev/null +++ b/src/Readarr.Api.V1/Books/BookEditorResource.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace Readarr.Api.V1.Books +{ + public class BookEditorResource + { + public List BookIds { get; set; } + public bool? Monitored { get; set; } + public bool? DeleteFiles { get; set; } + public bool? AddImportListExclusion { get; set; } + } +}