From d8ed41f5e88ee4e404b7d386d40be1eb1a69a8d8 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sun, 17 Sep 2023 12:01:57 +0300 Subject: [PATCH 001/820] Bump version to 1.4.4 --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 64b91de8d..17189486e 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,7 +9,7 @@ variables: testsFolder: './_tests' yarnCacheFolder: $(Pipeline.Workspace)/.yarn nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages - majorVersion: '1.4.3' + majorVersion: '1.4.4' minorVersion: $[counter('minorVersion', 1076)] lidarrVersion: '$(majorVersion).$(minorVersion)' buildName: '$(Build.SourceBranchName).$(lidarrVersion)' From 780420e799a804a7169bee29c11d074bd4ff8671 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 15 Sep 2023 16:26:57 +0300 Subject: [PATCH 002/820] Log exceptions for failed fetches in Custom and Sonarr import lists Co-authored-by: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> (cherry picked from commit c1d9187bb66c0524048020613d816918b84b5532) --- src/NzbDrone.Core/ImportLists/Custom/CustomImport.cs | 4 +++- src/NzbDrone.Core/ImportLists/Lidarr/LidarrImport.cs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/ImportLists/Custom/CustomImport.cs b/src/NzbDrone.Core/ImportLists/Custom/CustomImport.cs index afce13495..0cd6d5151 100644 --- a/src/NzbDrone.Core/ImportLists/Custom/CustomImport.cs +++ b/src/NzbDrone.Core/ImportLists/Custom/CustomImport.cs @@ -45,8 +45,10 @@ namespace NzbDrone.Core.ImportLists.Custom _importListStatusService.RecordSuccess(Definition.Id); } - catch + catch (Exception ex) { + _logger.Debug(ex, "Failed to fetch data for list {0} ({1})", Definition.Name, Name); + _importListStatusService.RecordFailure(Definition.Id); } diff --git a/src/NzbDrone.Core/ImportLists/Lidarr/LidarrImport.cs b/src/NzbDrone.Core/ImportLists/Lidarr/LidarrImport.cs index be0f28fd8..80cb2b6e1 100644 --- a/src/NzbDrone.Core/ImportLists/Lidarr/LidarrImport.cs +++ b/src/NzbDrone.Core/ImportLists/Lidarr/LidarrImport.cs @@ -75,8 +75,10 @@ namespace NzbDrone.Core.ImportLists.Lidarr _importListStatusService.RecordSuccess(Definition.Id); } - catch + catch (Exception ex) { + _logger.Debug(ex, "Failed to fetch data for list {0} ({1})", Definition.Name, Name); + _importListStatusService.RecordFailure(Definition.Id); } From febf4b26a1fb8dd7149aa9b512f483b83e0dd5c2 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 30 Aug 2023 22:11:27 +0300 Subject: [PATCH 003/820] Use await on reading the response content (cherry picked from commit 82d586e7015d7ea06356ca436024a8af5a4fb677) Closes #4127 --- src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 1d47c57a2..e3459f985 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -116,7 +116,7 @@ namespace NzbDrone.Common.Http.Dispatchers } else { - data = responseMessage.Content.ReadAsByteArrayAsync(cts.Token).GetAwaiter().GetResult(); + data = await responseMessage.Content.ReadAsByteArrayAsync(cts.Token); } } catch (Exception ex) From 0037a56c2b74644f24449d2300a826a0a1e15b23 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sat, 16 Sep 2023 15:07:39 +0300 Subject: [PATCH 004/820] Use async requests for media cover proxy (cherry picked from commit ad1f185330a30a2a9d27c9d3f18d384e66727c2a) Closes #4126 --- .../Frontend/Mappers/IMapHttpRequestsToDisk.cs | 3 ++- .../Frontend/Mappers/MediaCoverProxyMapper.cs | 7 ++++--- .../Mappers/StaticResourceMapperBase.cs | 7 ++++--- .../Frontend/StaticResourceController.cs | 17 +++++++++-------- src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs | 8 +++++--- 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/Lidarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs b/src/Lidarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs index 0edecbd4c..4fdecade1 100644 --- a/src/Lidarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs +++ b/src/Lidarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; namespace Lidarr.Http.Frontend.Mappers @@ -6,6 +7,6 @@ namespace Lidarr.Http.Frontend.Mappers { string Map(string resourceUrl); bool CanHandle(string resourceUrl); - IActionResult GetResponse(string resourceUrl); + Task GetResponse(string resourceUrl); } } diff --git a/src/Lidarr.Http/Frontend/Mappers/MediaCoverProxyMapper.cs b/src/Lidarr.Http/Frontend/Mappers/MediaCoverProxyMapper.cs index 5b07560e9..76c3248f2 100644 --- a/src/Lidarr.Http/Frontend/Mappers/MediaCoverProxyMapper.cs +++ b/src/Lidarr.Http/Frontend/Mappers/MediaCoverProxyMapper.cs @@ -1,6 +1,7 @@ using System; using System.Net; using System.Text.RegularExpressions; +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; using NzbDrone.Core.MediaCover; @@ -9,7 +10,7 @@ namespace Lidarr.Http.Frontend.Mappers { public class MediaCoverProxyMapper : IMapHttpRequestsToDisk { - private readonly Regex _regex = new Regex(@"/MediaCoverProxy/(?\w+)/(?(.+)\.(jpg|png|gif))"); + private readonly Regex _regex = new (@"/MediaCoverProxy/(?\w+)/(?(.+)\.(jpg|png|gif))"); private readonly IMediaCoverProxy _mediaCoverProxy; private readonly IContentTypeProvider _mimeTypeProvider; @@ -30,7 +31,7 @@ namespace Lidarr.Http.Frontend.Mappers return resourceUrl.StartsWith("/MediaCoverProxy/", StringComparison.InvariantCultureIgnoreCase); } - public IActionResult GetResponse(string resourceUrl) + public async Task GetResponse(string resourceUrl) { var match = _regex.Match(resourceUrl); @@ -42,7 +43,7 @@ namespace Lidarr.Http.Frontend.Mappers var hash = match.Groups["hash"].Value; var filename = match.Groups["filename"].Value; - var imageData = _mediaCoverProxy.GetImage(hash); + var imageData = await _mediaCoverProxy.GetImage(hash); if (!_mimeTypeProvider.TryGetContentType(filename, out var contentType)) { diff --git a/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs b/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs index 1de8f93f5..947042c12 100644 --- a/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs +++ b/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Text; +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Net.Http.Headers; @@ -30,7 +31,7 @@ namespace Lidarr.Http.Frontend.Mappers public abstract bool CanHandle(string resourceUrl); - public IActionResult GetResponse(string resourceUrl) + public Task GetResponse(string resourceUrl) { var filePath = Map(resourceUrl); @@ -41,10 +42,10 @@ namespace Lidarr.Http.Frontend.Mappers contentType = "application/octet-stream"; } - return new FileStreamResult(GetContentStream(filePath), new MediaTypeHeaderValue(contentType) + return Task.FromResult(new FileStreamResult(GetContentStream(filePath), new MediaTypeHeaderValue(contentType) { Encoding = contentType == "text/plain" ? Encoding.UTF8 : null - }); + })); } _logger.Warn("File {0} not found", filePath); diff --git a/src/Lidarr.Http/Frontend/StaticResourceController.cs b/src/Lidarr.Http/Frontend/StaticResourceController.cs index 27ae50466..877c5c39e 100644 --- a/src/Lidarr.Http/Frontend/StaticResourceController.cs +++ b/src/Lidarr.Http/Frontend/StaticResourceController.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Lidarr.Http.Extensions; using Lidarr.Http.Frontend.Mappers; using Microsoft.AspNetCore.Authorization; @@ -25,27 +26,27 @@ namespace Lidarr.Http.Frontend [AllowAnonymous] [HttpGet("login")] - public IActionResult LoginPage() + public async Task LoginPage() { - return MapResource("login"); + return await MapResource("login"); } [EnableCors("AllowGet")] [AllowAnonymous] [HttpGet("/content/{**path:regex(^(?!api/).*)}")] - public IActionResult IndexContent([FromRoute] string path) + public async Task IndexContent([FromRoute] string path) { - return MapResource("Content/" + path); + return await MapResource("Content/" + path); } [HttpGet("")] [HttpGet("/{**path:regex(^(?!(api|feed)/).*)}")] - public IActionResult Index([FromRoute] string path) + public async Task Index([FromRoute] string path) { - return MapResource(path); + return await MapResource(path); } - private IActionResult MapResource(string path) + private async Task MapResource(string path) { path = "/" + (path ?? ""); @@ -53,7 +54,7 @@ namespace Lidarr.Http.Frontend if (mapper != null) { - var result = mapper.GetResponse(path); + var result = await mapper.GetResponse(path); if (result != null) { diff --git a/src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs b/src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs index 8f6ed8c9a..b8b0cc3a0 100644 --- a/src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs +++ b/src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using NzbDrone.Common.Cache; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; @@ -12,7 +13,7 @@ namespace NzbDrone.Core.MediaCover string RegisterUrl(string url); string GetUrl(string hash); - byte[] GetImage(string hash); + Task GetImage(string hash); } public class MediaCoverProxy : IMediaCoverProxy @@ -52,13 +53,14 @@ namespace NzbDrone.Core.MediaCover return result; } - public byte[] GetImage(string hash) + public async Task GetImage(string hash) { var url = GetUrl(hash); var request = new HttpRequest(url); + var response = await _httpClient.GetAsync(request); - return _httpClient.Get(request).ResponseData; + return response.ResponseData; } } } From 5d3ff26703e3fcc3646d58353c26d4808ae789e5 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 17 Sep 2023 23:45:31 -0700 Subject: [PATCH 005/820] Fixed: Don't allow quality profile to be created without all qualities (cherry picked from commit 32e1ae2f64827272d351991838200884876e52b4) --- .../Profiles/Quality/QualityItemsValidator.cs | 44 +++++++++++++++++++ .../Quality/QualityProfileController.cs | 2 + 2 files changed, 46 insertions(+) diff --git a/src/Lidarr.Api.V1/Profiles/Quality/QualityItemsValidator.cs b/src/Lidarr.Api.V1/Profiles/Quality/QualityItemsValidator.cs index 772a18bb0..2664a8d99 100644 --- a/src/Lidarr.Api.V1/Profiles/Quality/QualityItemsValidator.cs +++ b/src/Lidarr.Api.V1/Profiles/Quality/QualityItemsValidator.cs @@ -17,6 +17,8 @@ namespace Lidarr.Api.V1.Profiles.Quality ruleBuilder.SetValidator(new ItemGroupIdValidator()); ruleBuilder.SetValidator(new UniqueIdValidator()); ruleBuilder.SetValidator(new UniqueQualityIdValidator()); + ruleBuilder.SetValidator(new AllQualitiesValidator()); + return ruleBuilder.SetValidator(new ItemGroupNameValidator()); } } @@ -151,4 +153,46 @@ namespace Lidarr.Api.V1.Profiles.Quality return true; } } + + public class AllQualitiesValidator : PropertyValidator + { + protected override string GetDefaultMessageTemplate() => "Must contain all qualities"; + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue is not IList items) + { + return false; + } + + var qualityIds = new HashSet(); + + foreach (var item in items) + { + if (item.Id > 0) + { + foreach (var quality in item.Items) + { + qualityIds.Add(quality.Quality.Id); + } + } + else + { + qualityIds.Add(item.Quality.Id); + } + } + + var allQualityIds = NzbDrone.Core.Qualities.Quality.All; + + foreach (var quality in allQualityIds) + { + if (!qualityIds.Contains(quality.Id)) + { + return false; + } + } + + return true; + } + } } diff --git a/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileController.cs b/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileController.cs index c9e936112..5078f96a4 100644 --- a/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileController.cs +++ b/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileController.cs @@ -24,6 +24,7 @@ namespace Lidarr.Api.V1.Profiles.Quality SharedValidator.RuleFor(c => c.Name).NotEmpty(); SharedValidator.RuleFor(c => c.Cutoff).ValidCutoff(); SharedValidator.RuleFor(c => c.Items).ValidItems(); + SharedValidator.RuleFor(c => c.FormatItems).Must(items => { var all = _formatService.All().Select(f => f.Id).ToList(); @@ -31,6 +32,7 @@ namespace Lidarr.Api.V1.Profiles.Quality return all.Except(ids).Empty(); }).WithMessage("All Custom Formats and no extra ones need to be present inside your Profile! Try refreshing your browser."); + SharedValidator.RuleFor(c => c).Custom((profile, context) => { if (profile.FormatItems.Where(x => x.Score > 0).Sum(x => x.Score) < profile.MinFormatScore && From a445747c4dd4904ce490772d193395e61df3ee11 Mon Sep 17 00:00:00 2001 From: Qstick Date: Mon, 18 Sep 2023 03:09:12 -0400 Subject: [PATCH 006/820] Fixed: Show correct error on unauthorized caps call (cherry picked from commit f2b0fc946e1fb1b4649f1b46a003bd2add09a461) --- .../Indexers/Newznab/NewznabCapabilitiesProvider.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs index 5de9805b7..34f8e7d5f 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs @@ -7,6 +7,7 @@ using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; +using NzbDrone.Core.Indexers.Exceptions; namespace NzbDrone.Core.Indexers.Newznab { @@ -73,6 +74,13 @@ namespace NzbDrone.Core.Indexers.Newznab _logger.Debug(ex, "Failed to parse newznab api capabilities for {0}", indexerSettings.BaseUrl); throw; } + catch (ApiKeyException ex) + { + ex.WithData(response, 128 * 1024); + _logger.Trace("Unexpected Response content ({0} bytes): {1}", response.ResponseData.Length, response.Content); + _logger.Debug(ex, "Failed to parse newznab api capabilities for {0}, invalid API key", indexerSettings.BaseUrl); + throw; + } catch (Exception ex) { ex.WithData(response, 128 * 1024); From 6c3473079621522a4e9319804b043c0d187d3c21 Mon Sep 17 00:00:00 2001 From: Weblate Date: Sun, 17 Sep 2023 09:02:16 +0000 Subject: [PATCH 007/820] Multiple Translations updated by Weblate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ignore-downstream Co-authored-by: Akashi2020 Co-authored-by: Anonymous Co-authored-by: Anthony Veaudry Co-authored-by: Fixer Co-authored-by: Gyuyeop Kim Co-authored-by: Havok Dan Co-authored-by: Herve Lauwerier Co-authored-by: Richard de Souza Leite Co-authored-by: Weblate Co-authored-by: mati300m Co-authored-by: 宿命 <331874545@qq.com> Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/el/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/fr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/ko/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pl/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pt/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pt_BR/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/ro/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/tr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/zh_CN/ Translation: Servarr/Lidarr --- src/NzbDrone.Core/Localization/Core/el.json | 7 +- src/NzbDrone.Core/Localization/Core/fr.json | 52 ++++++----- src/NzbDrone.Core/Localization/Core/ko.json | 8 +- src/NzbDrone.Core/Localization/Core/pl.json | 4 +- src/NzbDrone.Core/Localization/Core/pt.json | 58 ++++++++----- .../Localization/Core/pt_BR.json | 20 ++--- src/NzbDrone.Core/Localization/Core/ro.json | 7 +- src/NzbDrone.Core/Localization/Core/tr.json | 3 +- .../Localization/Core/zh_CN.json | 87 +++++++++++-------- 9 files changed, 150 insertions(+), 96 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/el.json b/src/NzbDrone.Core/Localization/Core/el.json index aa9d4d523..5eb0d056d 100644 --- a/src/NzbDrone.Core/Localization/Core/el.json +++ b/src/NzbDrone.Core/Localization/Core/el.json @@ -1031,5 +1031,10 @@ "NotificationStatusSingleClientHealthCheckMessage": "Μη διαθέσιμες λίστες λόγω αποτυχιών: {0}", "WhatsNew": "Τι νέα?", "AddNewArtistRootFolderHelpText": "Ο υποφάκελος \"{0}\" θα δημιουργηθεί αυτόματα", - "ErrorLoadingContent": "Υπήρξε ένα σφάλμα κατά τη φόρτωση του αρχείου" + "ErrorLoadingContent": "Υπήρξε ένα σφάλμα κατά τη φόρτωση του αρχείου", + "AppUpdated": "{appName} Ενημερώθηκε", + "AppUpdatedVersion": "ξαναφορτωθεί", + "AutoAdd": "Προσθήκη", + "AddConditionImplementation": "Προσθήκη", + "AddConnectionImplementation": "Προσθήκη" } diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index 4db4ff467..2c9e5a451 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -10,14 +10,14 @@ "Automatic": "Automatique", "BackupNow": "Sauvegarder maintenant", "Backups": "Sauvegardes", - "BindAddress": "Adresse d'attache", + "BindAddress": "Adresse de liaison", "BindAddressHelpText": "Adresse IP valide, localhost ou '*' pour toutes les interfaces", "Branch": "Branche", "BypassProxyForLocalAddresses": "Contourner le proxy pour les adresses locales", "Cancel": "Annuler", "CertificateValidation": "Validation du certificat", - "BackupRetentionHelpText": "Les sauvegardes automatiques plus anciennes que la période de conservation seront automatiquement effacées", - "CertificateValidationHelpText": "Modifier le degré de rigueur de la validation de la certification HTTPS. Ne changez rien si vous ne comprenez pas les risques.", + "BackupRetentionHelpText": "Les sauvegardes automatiques plus anciennes que la période de rétention seront nettoyées automatiquement", + "CertificateValidationHelpText": "Modifier le niveau de rigueur de la validation de la certification HTTPS. Ne pas modifier si vous ne maîtrisez pas les risques.", "Actions": "Actions", "IllRestartLater": "Je redémarrerai plus tard", "ImportExtraFiles": "Importer les fichiers extra", @@ -49,13 +49,13 @@ "ChangeFileDate": "Changer la date du fichier", "ChangeHasNotBeenSavedYet": "Les changements n'ont pas encore été sauvegardés", "ChmodFolder": "chmod Dossier", - "ChmodFolderHelpText": "Nombre, appliqué durant l'import/renommage vers les dossiers et fichiers multimédias (sans exécuter les bits)", + "ChmodFolderHelpText": "Octal, appliqué lors de l'importation/du renommage des dossiers et fichiers multimédias (sans bits d'exécution)", "Unmonitored": "Non surveillé", "ChmodFolderHelpTextWarning": "Fonctionne uniquement si l'utilisateur exécutant Lidarr est le propriétaire du fichier. Il est recommandé de vérifier les permissions du client de téléchargement.", - "ChownGroupHelpText": "Nom du Groupe ou GID. Utiliser le GID pour les systèmes de fichier distants.", + "ChownGroupHelpText": "Nom du groupe ou gid. Utilisez gid pour les systèmes de fichiers distants.", "ChownGroupHelpTextWarning": "Fonctionne uniquement si l'utilisateur exécutant Lidarr est le propriétaire du fichier. Il est recommandé de vérifier que le client de téléchargement utilise le meme Groupe que Lidarr.", "Clear": "Effacer", - "ClickToChangeQuality": "Cliquer pour changer la qualité", + "ClickToChangeQuality": "Cliquez pour changer la qualité", "ClientPriority": "Priorité du client", "CloneIndexer": "Dupliqué l'indexeur", "CloneProfile": "Dupliqué le profil", @@ -253,7 +253,7 @@ "PublishedDate": "Date de publication", "Quality": "Qualité", "QualityDefinitions": "Définitions qualité", - "QualityProfile": "Profil de Qualité", + "QualityProfile": "Profil de qualité", "QualityProfiles": "Profils qualité", "QualitySettings": "Paramètres Qualité", "Queue": "File d'attente", @@ -423,17 +423,17 @@ "20MinutesTwenty": "20 Minutes: {0}", "45MinutesFourtyFive": "45 Minutes: {0}", "60MinutesSixty": "60 Minutes : {0}", - "AgeWhenGrabbed": "Age (au moment du téléchargement)", + "AgeWhenGrabbed": "Âge (au moment de la saisie)", "AlbumIsDownloadingInterp": "L'album est en cours de téléchargement - {0}% {1}", - "AlreadyInYourLibrary": "Déjà disponible dans votre librairie", - "AlternateTitles": "Titre alternatif", + "AlreadyInYourLibrary": "Déjà dans la bibliothèque", + "AlternateTitles": "Titres alternatifs", "AlternateTitleslength1Title": "Titre", "AlternateTitleslength1Titles": "Titres", "AnalyticsEnabledHelpText": "Envoyer des informations anonymes sur l'utilisation et les erreurs vers les serveurs de Lidarr. Cela inclut des informations sur votre navigateur, quelle page Lidarr WebUI vous utilisez, les rapports d'erreur ainsi que le système d'exploitation et sa version. Nous utiliserons ces informations pour prioriser les nouvelles fonctionnalités et les corrections de bugs.", "AnalyticsEnabledHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "ArtistAlbumClickToChangeTrack": "Cliquer pour changer le film", "AuthenticationMethodHelpText": "Requière un identifiant et un mot de passe pour accéder à Lidarr", - "AutoRedownloadFailedHelpText": "Chercher et essayer de télécharger une version différente automatiquement", + "AutoRedownloadFailedHelpText": "Recherche automatique et tentative de téléchargement d'une version différente", "BackupFolderHelpText": "Les chemins relatifs pointeront sous le repertoire AppData de Lidarr", "BindAddressHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "DelayingDownloadUntilInterp": "Retarder le téléchargement jusqu'au {0} à {1}", @@ -485,7 +485,7 @@ "AddMissing": "Ajouter manquant", "AddNewItem": "Ajouter un nouvel élément", "CatalogNumber": "Numéro de catalogue", - "ChownGroup": "groupe chown", + "ChownGroup": "chown Groupe", "CollapseMultipleAlbums": "Réduire plusieurs albums", "CollapseMultipleAlbumsHelpText": "Réduire plusieurs albums publiés le même jour", "Album": "Album", @@ -534,7 +534,7 @@ "AddQualityProfile": "Ajouter un profil de qualité", "AddRemotePathMapping": "Ajouter un mappage des chemins d'accès", "AddRootFolder": "Ajouter un dossier racine", - "AfterManualRefresh": "Après un rafraichissement manuel", + "AfterManualRefresh": "Après le rafraîchissement manuel", "Age": "Âge", "Albums": "Album", "All": "Tout", @@ -640,8 +640,8 @@ "Import": "Importer", "NoTagsHaveBeenAddedYet": "Aucune identification n'a été ajoutée pour l'instant", "Ok": "OK", - "AddDelayProfile": "Ajouter un profil différé", - "AddImportListExclusion": "Supprimer les exclusions de liste d'imports", + "AddDelayProfile": "Ajouter un profil de délai", + "AddImportListExclusion": "Ajouter une exclusion à la liste des importations", "EditMetadataProfile": "profil de métadonnées", "AddConnection": "Ajouter une connexion", "ImportListExclusions": "Supprimer les exclusions de liste d'imports", @@ -664,7 +664,7 @@ "ArtistName": "Nom de l'artiste", "ArtistType": "Type d'artiste", "Discography": "discographie", - "ClickToChangeReleaseGroup": "Cliquer pour changer le groupe de publication", + "ClickToChangeReleaseGroup": "Cliquez pour changer de groupe de diffusion", "Episode": "épisode", "SelectReleaseGroup": "Sélectionner le groupe de publication", "Theme": "Thème", @@ -769,7 +769,7 @@ "ApplyChanges": "Appliquer les modifications", "BlocklistReleaseHelpText": "Empêche Lidarr de récupérer automatiquement cette version", "FailedToLoadQueue": "Erreur lors du chargement de la file", - "BlocklistReleases": "Mettre cette release sur la liste noire", + "BlocklistReleases": "Publications de la liste de blocage", "DeleteConditionMessageText": "Voulez-vous vraiment supprimer la liste '{0}' ?", "Negated": "Inversé", "RemoveSelectedItem": "Supprimer l'élément sélectionné", @@ -790,7 +790,7 @@ "QueueIsEmpty": "La file d'attente est vide", "ResetQualityDefinitionsMessageText": "Êtes-vous sûr de vouloir réinitialiser les définitions de qualité ?", "ApplyTagsHelpTextRemove": "Suprimer : Suprime les étiquettes renseignées", - "ApplyTagsHelpTextReplace": "Remplacer : Remplace les tags par les tags renseignés (ne pas renseigner de tags pour effacer tous les tags)", + "ApplyTagsHelpTextReplace": "Remplacer : Remplace les balises par les balises saisies (ne pas saisir de balises pour effacer toutes les balises)", "DownloadClientTagHelpText": "Utiliser seulement cet indexeur pour les films avec au moins un tag correspondant. Laissez vide pour l'utiliser avec tous les films.", "RemoveSelectedItemBlocklistMessageText": "Êtes-vous sûr de vouloir supprimer les films sélectionnés de la liste noire ?", "RemovingTag": "Suppression du tag", @@ -800,11 +800,11 @@ "RemoveSelectedItemsQueueMessageText": "Êtes-vous sûr de vouloir supprimer {0} objet(s) de la file d'attente ?", "Yes": "Oui", "ApplyTagsHelpTextHowToApplyArtists": "Comment appliquer des étiquettes aux indexeurs sélectionnés", - "ApplyTagsHelpTextHowToApplyDownloadClients": "Comment appliquer des tags au film sélectionné", + "ApplyTagsHelpTextHowToApplyDownloadClients": "Comment appliquer des balises aux clients de téléchargement sélectionnés", "DeleteSelectedDownloadClientsMessageText": "Voulez-vous vraiment supprimer l'indexeur '{0}' ?", "DeleteSelectedImportListsMessageText": "Voulez-vous vraiment supprimer l'indexeur '{0}' ?", "DeleteSelectedIndexersMessageText": "Voulez-vous vraiment supprimer l'indexeur '{0}' ?", - "ApplyTagsHelpTextHowToApplyImportLists": "Comment appliquer des tags au film sélectionné", + "ApplyTagsHelpTextHowToApplyImportLists": "Comment appliquer des balises aux listes d'importation sélectionnées", "ApplyTagsHelpTextHowToApplyIndexers": "Comment appliquer des tags aux indexeurs sélectionnés", "CountDownloadClientsSelected": "{0} client(s) de téléchargement sélectionné(s)", "EditSelectedDownloadClients": "Modifier les clients de téléchargement sélectionnés", @@ -812,7 +812,7 @@ "SomeResultsAreHiddenByTheAppliedFilter": "Tous les résultats ont été dissimulés par le filtre actuellement appliqué", "SuggestTranslationChange": "Suggérer un changement de traduction", "UpdateSelected": "Mettre à jour la sélection", - "AllResultsAreHiddenByTheAppliedFilter": "Tous les résultats ont été dissimulés par le filtre actuellement appliqué", + "AllResultsAreHiddenByTheAppliedFilter": "Tous les résultats sont masqués par le filtre appliqué", "NoResultsFound": "Aucun résultat trouvé", "AddConditionImplementation": "Ajouter une condition - {implementationName}", "AddConnectionImplementation": "Ajouter une connexion - {implementationName}", @@ -829,10 +829,16 @@ "EditConditionImplementation": "Ajouter une connexion - {implementationName}", "Enabled": "Activé", "NotificationStatusAllClientHealthCheckMessage": "Toutes les applications sont indisponibles en raison de dysfonctionnements", - "AddIndexerImplementation": "Ajouter une condition - {implementationName}", + "AddIndexerImplementation": "Ajouter un indexeur - {implementationName}", "EditConnectionImplementation": "Ajouter une connexion - {implementationName}", "EditIndexerImplementation": "Ajouter une condition - {implementationName}", "AddNewArtistRootFolderHelpText": "'{0}' le sous-dossier sera créé automatiquement", "ImportLists": "liste d'importation", - "ErrorLoadingContent": "Une erreur s'est produite lors du chargement de cet élément" + "ErrorLoadingContent": "Une erreur s'est produite lors du chargement de cet élément", + "AppUpdated": "{appName} Mise à jour", + "AutoAdd": "Ajout automatique", + "AutomaticUpdatesDisabledDocker": "Les mises à jour automatiques ne sont pas directement prises en charge lors de l'utilisation du mécanisme de mise à jour de Docker. Vous devrez mettre à jour l'image du conteneur en dehors de {appName} ou utiliser un script", + "AddImportList": "Ajouter une liste d'importation", + "AddDownloadClientImplementation": "Ajouter un client de téléchargement - {implementationName}", + "AddImportListImplementation": "Ajouter une liste d'importation - {implementationName}" } diff --git a/src/NzbDrone.Core/Localization/Core/ko.json b/src/NzbDrone.Core/Localization/Core/ko.json index 45b520995..dd2594ddd 100644 --- a/src/NzbDrone.Core/Localization/Core/ko.json +++ b/src/NzbDrone.Core/Localization/Core/ko.json @@ -133,7 +133,7 @@ "Group": "그룹", "HasPendingChangesNoChanges": "변경 사항 없음", "HasPendingChangesSaveChanges": "변경 사항을 저장하다", - "History": "역사", + "History": "내역", "Host": "주최자", "Hostname": "호스트 이름", "ICalFeed": "iCal 피드", @@ -332,7 +332,7 @@ "UnableToLoadRemotePathMappings": "원격 경로 매핑을로드 할 수 없습니다.", "UnableToLoadRootFolders": "루트 폴더를로드 할 수 없습니다.", "UnableToLoadTags": "태그를로드 할 수 없습니다.", - "UnableToLoadTheCalendar": "캘린더를로드 할 수 없습니다.", + "UnableToLoadTheCalendar": "달력을 불러올 수 없습니다.", "UnableToLoadUISettings": "UI 설정을로드 할 수 없습니다.", "Ungroup": "그룹 해제", "Unmonitored": "모니터링되지 않음", @@ -467,7 +467,7 @@ "AddDelayProfile": "지연 프로필 추가", "Added": "추가됨", "AddIndexer": "인덱서 추가", - "AddNew": "새로 추가", + "AddNew": "새로 추가하기", "AddQualityProfile": "품질 프로필 추가", "AddRemotePathMapping": "원격 경로 매핑 추가", "AddRootFolder": "루트 폴더 추가", @@ -546,7 +546,7 @@ "ShowAdvanced": "고급보기", "SizeOnDisk": "디스크 크기", "SourceTitle": "소스 제목", - "System": "체계", + "System": "시스템", "Test": "테스트", "TimeLeft": "Timeleft", "Title": "표제", diff --git a/src/NzbDrone.Core/Localization/Core/pl.json b/src/NzbDrone.Core/Localization/Core/pl.json index 8114e12ee..121f5da3b 100644 --- a/src/NzbDrone.Core/Localization/Core/pl.json +++ b/src/NzbDrone.Core/Localization/Core/pl.json @@ -52,7 +52,7 @@ "DeleteImportListExclusionMessageText": "Czy na pewno chcesz usunąć to wykluczenie listy importu?", "DeleteImportListMessageText": "Czy na pewno chcesz usunąć listę „{0}”?", "DeleteIndexerMessageText": "Czy na pewno chcesz usunąć indeksator „{0}”?", - "DeleteMetadataProfileMessageText": "Czy na pewno chcesz usunąć profil jakości {0}", + "DeleteMetadataProfileMessageText": "Czy na pewno usunąć informacje dodatkowe '{0name}'?", "DeleteNotification": "Usuń powiadomienie", "DeleteNotificationMessageText": "Czy na pewno chcesz usunąć powiadomienie „{0}”?", "DeleteQualityProfile": "Usuń profil jakości", @@ -577,7 +577,7 @@ "Info": "Informacje", "OutputPath": "Ścieżka wyjściowa", "Activity": "Aktywność", - "AddConnection": "Edytuj kolekcję", + "AddConnection": "Dodaj połączenie", "AddImportListExclusion": "Usuń wykluczenie listy importu", "General": "Generał", "Started": "Rozpoczęto", diff --git a/src/NzbDrone.Core/Localization/Core/pt.json b/src/NzbDrone.Core/Localization/Core/pt.json index 394075f2f..dda8b28e0 100644 --- a/src/NzbDrone.Core/Localization/Core/pt.json +++ b/src/NzbDrone.Core/Localization/Core/pt.json @@ -224,8 +224,8 @@ "ArtistAlbumClickToChangeTrack": "Clique para mudar o livro", "ArtistNameHelpText": "O nome do autor/livro a eliminar (pode ser qualquer palavra)", "Authentication": "Autenticação", - "AuthenticationMethodHelpText": "Solicitar nome de utilizador e palavra-passe para acessar ao Lidarr", - "AutoRedownloadFailedHelpText": "Pesquisar e tentar transferir automaticamente uma versão diferente", + "AuthenticationMethodHelpText": "Solicitar Nome de Usuário e Senha para acessar o Lidarr", + "AutoRedownloadFailedHelpText": "Procurar automaticamente e tente baixar uma versão diferente", "BackupFolderHelpText": "Caminhos relativos estarão na pasta AppData do Lidarr", "BackupIntervalHelpText": "Intervalo para criar cópia de segurança das configurações e da base de dados do Lidarr", "BackupNow": "Criar cópia de segurança", @@ -349,8 +349,8 @@ "APIKey": "Chave da API", "About": "Sobre", "AddListExclusion": "Adicionar exclusão de lista", - "AddingTag": "A adicionar etiqueta", - "AgeWhenGrabbed": "Tempo de vida (quando capturado)", + "AddingTag": "Adicionando etiqueta", + "AgeWhenGrabbed": "Idade (quando capturada)", "AnalyticsEnabledHelpTextWarning": "Requer reinício para aplicar alterações", "Automatic": "Automático", "MetadataSettings": "Definições de metadados", @@ -410,7 +410,7 @@ "UsingExternalUpdateMechanismBranchUsedByExternalUpdateMechanism": "Ramificação utilizada pelo mecanismo externo de atualização", "Actions": "Ações", "ApiKeyHelpTextWarning": "Requer reinício para aplicar alterações", - "AppDataDirectory": "Pasta AppData", + "AppDataDirectory": "Diretório AppData", "ApplyTags": "Aplicar etiquetas", "BindAddressHelpTextWarning": "Requer reinício para aplicar alterações", "Blocklist": "Lista de bloqueio", @@ -518,10 +518,10 @@ "AllowArtistChangeClickToChangeArtist": "Clique para mudar o autor", "AllowFingerprintingHelpTextWarning": "Isto exige que o Lidarr leia partes do ficheiro, o que pode reduzir a velocidade das análises e gerar maior atividade na rede ou no disco.", "AlreadyInYourLibrary": "Já está na sua biblioteca", - "AlternateTitles": "Título alternativo", + "AlternateTitles": "Títulos Alternativos", "AlternateTitleslength1Title": "Título", "AlternateTitleslength1Titles": "Títulos", - "Analytics": "Análises", + "Analytics": "Análise", "AnalyticsEnabledHelpText": "Envia informações anônimas de uso e de erros aos servidores do Lidarr. Isso inclui informações sobre seu browser, páginas utilizadas na WebUI do Lidarr, relatórios de erros, bem como as versões do sistema operativo e da aplicação. Utilizaremos essas informações para priorizar funcionalidades e correções de bugs.", "DelayingDownloadUntilInterp": "Atrasando a transferência até {0} às {1}", "Edit": "Editar", @@ -537,15 +537,15 @@ "AddDelayProfile": "Adicionar perfil de atraso", "Added": "Adicionado", "AddIndexer": "Adicionar indexador", - "AddNew": "Adicionar novo", - "AddQualityProfile": "Adicionar perfil de qualidade", + "AddNew": "Adicionar Novo", + "AddQualityProfile": "Adicionar Perfil de Qualidade", "AddRemotePathMapping": "Adicionar mapeamento de caminho remoto", "AddRootFolder": "Adicionar pasta raiz", "AfterManualRefresh": "Após a atualização manual", - "Age": "Tempo de vida", - "AllFiles": "Todos os ficheiros", + "Age": "Idade", + "AllFiles": "Todos os Arquivos", "Always": "Sempre", - "ApplicationURL": "URL da aplicação", + "ApplicationURL": "URL do Aplicativo", "ApplicationUrlHelpText": "O URL desta aplicação externa, incluindo http(s)://, porta e URL base", "Apply": "Aplicar", "AudioInfo": "Informações do áudio", @@ -646,8 +646,8 @@ "MonitoredOnly": "Apenas monitorado", "MoveAutomatically": "Mover automaticamente", "Save": "Guardar", - "AddConnection": "Editar Coleção", - "AddImportListExclusion": "Eliminar exclusão da lista de importação", + "AddConnection": "Adicionar Conexão", + "AddImportListExclusion": "Adicionar exclusão na lista de importação", "AddMetadataProfile": "perfil de metadados", "ImportListExclusions": "Eliminar exclusão da lista de importação", "EditMetadataProfile": "perfil de metadados", @@ -714,7 +714,7 @@ "RemotePathMappingCheckWrongOSPath": "O cliente remoto {0} coloca as transferências em {1}, mas esse não é um caminho {2} válido. Revise os mapeamentos de caminho remoto e as definições do cliente de transferências.", "UpdateCheckStartupTranslocationMessage": "Não é possível instalar a atualização porque a pasta de arranque \"{0}\" está em uma pasta de transposição de aplicações.", "UpdateCheckUINotWritableMessage": "Não é possível instalar a atualização porque a pasta da IU \"{0}\" não tem permissões de escrita para o utilizador \"{1}\".", - "AppDataLocationHealthCheckMessage": "Não foi possivél actualizar para prevenir apagar a AppData durante a actualização", + "AppDataLocationHealthCheckMessage": "Não foi possível atualizar para prevenir apagar a AppData durante a atualização", "ColonReplacement": "Substituição de dois-pontos", "Disabled": "Desativado", "DownloadClientCheckDownloadingToRoot": "O cliente {0} coloca as transferências na pasta raiz {1}. Não transfira para a pasta raiz.", @@ -751,7 +751,7 @@ "SystemTimeCheckMessage": "A hora do sistema está atrasada em mais de 1 dia. As tarefas agendadas podem não ocorrer corretamente até a hora ser corrigida", "UpdateAvailable": "Nova atualização disponível", "UpdateCheckStartupNotWritableMessage": "Não é possível instalar a atualização porque a pasta de arranque \"{0}\" não tem permissões de escrita para o utilizador \"{1}\".", - "ApiKeyValidationHealthCheckMessage": "Por favor, actualize a sua API Key para ter no minimo {0} caracteres. Pode fazer através das definições ou do ficheiro de configuração", + "ApiKeyValidationHealthCheckMessage": "Por favor, atualize a sua API Key para ter no mínimo {0} caracteres. Pode fazer através das definições ou do ficheiro de configuração", "DeleteRemotePathMapping": "Editar mapeamento de caminho remoto", "DeleteRemotePathMappingMessageText": "Tem a certeza que quer eliminar este mapeamento de caminho remoto?", "BlocklistReleaseHelpText": "Impede o Lidarr de capturar automaticamente estes ficheiros novamente", @@ -787,7 +787,7 @@ "DownloadClientTagHelpText": "Só use este indexador para filmes com pelo menos uma etiqueta correspondente. Deixe em branco para usar com todos os filmes.", "SkipRedownloadHelpText": "Impede que o Lidarr tente transferir versões alternativas para itens removidos", "ApplyTagsHelpTextHowToApplyArtists": "Como aplicar etiquetas aos filmes selecionados", - "ApplyTagsHelpTextHowToApplyImportLists": "Como aplicar etiquetas aos filmes selecionados", + "ApplyTagsHelpTextHowToApplyImportLists": "Como aplicar etiquetas às listas de importação selecionadas", "ApplyTagsHelpTextHowToApplyIndexers": "Como aplicar etiquetas aos indexadores selecionados", "DeleteSelectedDownloadClientsMessageText": "Tem a certeza que quer eliminar o indexador \"{0}\"?", "DeleteSelectedImportListsMessageText": "Tem a certeza que quer eliminar o indexador \"{0}\"?", @@ -795,9 +795,9 @@ "SomeResultsAreHiddenByTheAppliedFilter": "Alguns resultados estão ocultos pelo filtro aplicado", "SuggestTranslationChange": "Sugerir mudança na tradução", "UpdateSelected": "Atualizar selecionado(s)", - "ApplyTagsHelpTextHowToApplyDownloadClients": "Como aplicar etiquetas aos filmes selecionados", + "ApplyTagsHelpTextHowToApplyDownloadClients": "Como aplicar etiquetas aos clientes de download selecionados", "CountArtistsSelected": "{0} autor(es) selecionado(s)", - "AllResultsAreHiddenByTheAppliedFilter": "Todos os resultados estão ocultos pelo filtro aplicado", + "AllResultsAreHiddenByTheAppliedFilter": "Todos os resultados foram ocultados pelo filtro aplicado", "NoResultsFound": "Nenhum resultado encontrado", "Priority": "Prioridade", "ImportListRootFolderMissingRootHealthCheckMessage": "Pasta de raiz ausente para a(s) lista(s) de importação: {0}", @@ -808,5 +808,23 @@ "WhatsNew": "O que há de novo?", "AddNewArtistRootFolderHelpText": "A subpasta \"{0}\" será criada automaticamente", "ConnectionLostToBackend": "O Radarr perdeu a ligação com o back-end e precisará ser recarregado para restaurar a funcionalidade.", - "Enabled": "Ativado" + "Enabled": "Ativado", + "AppUpdated": "{appName} Atualizado", + "AutoAdd": "Adicionar automaticamente", + "AddConditionImplementation": "Adicionar Condição - {implementationName}", + "AddConnectionImplementation": "Adicionar Conexão - {implementationName}", + "AddDownloadClientImplementation": "Adicionar Cliente de Download - {implementationName}", + "AddImportList": "Adicionar Lista de Importação", + "AddImportListImplementation": "Adicionar Lista de Importação - {implementationName}", + "AddIndexerImplementation": "Adicionar Indexador - {implementationName}", + "AddReleaseProfile": "Adicionar Perfil de Lançamento", + "Absolute": "Absoluto", + "AppUpdatedVersion": "{appName} foi atualizado para a versão `{version}`, para obter as alterações mais recentes, você precisará recarregar {appName}", + "EditImportListImplementation": "Adicionar Lista de Importação - {implementationName}", + "EditReleaseProfile": "Adicionar Perfil de Lançamento", + "Episode": "Episódio", + "EditConditionImplementation": "Adicionar Condição - {implementationName}", + "EditConnectionImplementation": "Adicionar Conexão - {implementationName}", + "EditDownloadClientImplementation": "Adicionar Cliente de Download - {implementationName}", + "EditIndexerImplementation": "Adicionar Indexador - {implementationName}" } diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index 2c8179632..b70092406 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -414,7 +414,7 @@ "Release": " Lançamento, versão", "ReleaseDate": "Data de lançamento", "ReleaseGroup": "Grupo de lançamento", - "ReleaseProfiles": "Perfis de Lançamento", + "ReleaseProfiles": "Perfis de Lançamentos", "ReleaseRejected": "Lançamento Rejeitado", "ReleaseStatuses": "Status da versão", "ReleaseWillBeProcessedInterp": "A versão será processada {0}", @@ -475,7 +475,7 @@ "SupportsSearchvalueWillBeUsedWhenInteractiveSearchIsUsed": "Será usado com a pesquisa interativa", "TagAudioFilesWithMetadata": "Marcar arquivos de áudio com metadados", "TagIsNotUsedAndCanBeDeleted": "A tag não é usada e pode ser excluída", - "Tags": "Etiquetas", + "Tags": "Tags", "Tasks": "Tarefas", "TBA": "A ser anunciado", "TestAll": "Testar tudo", @@ -1014,13 +1014,13 @@ "EditSelectedDownloadClients": "Editar clientes de download selecionados", "EditSelectedImportLists": "Editar listas de importação selecionadas", "EditSelectedIndexers": "Editar indexadores selecionados", - "ExistingTag": "Etiqueta existente", + "ExistingTag": "Tag existente", "Implementation": "Implementação", "ManageClients": "Gerenciar clientes", "ManageIndexers": "Gerenciar indexadores", "NoChange": "Sem alteração", - "RemovingTag": "Removendo etiqueta", - "SetTags": "Definir etiquetas", + "RemovingTag": "Removendo tag", + "SetTags": "Definir tags", "Yes": "Sim", "No": "Não", "DownloadClientTagHelpText": "Use este cliente de download apenas para artistas com pelo menos uma tag correspondente. Deixe em branco para usar com todos os artistas.", @@ -1028,9 +1028,9 @@ "RemoveSelectedItemQueueMessageText": "Tem certeza de que deseja remover 1 item da fila?", "RemoveSelectedItemsQueueMessageText": "Tem certeza de que deseja remover {0} itens da fila?", "SkipRedownloadHelpText": "Evita que o Lidarr tente baixar versões alternativas para os itens removidos", - "ApplyTagsHelpTextAdd": "Adicionar: adicione as etiquetas à lista existente de etiquetas", - "ApplyTagsHelpTextRemove": "Remover: remove as etiquetas inseridas", - "ApplyTagsHelpTextReplace": "Substituir: Substitua as etiquetas pelas etiquetas inseridas (não digite nenhuma etiqueta para limpar todas as etiquetas)", + "ApplyTagsHelpTextAdd": "Adicionar: adicione as tags à lista existente de tags", + "ApplyTagsHelpTextRemove": "Remover: Remove as tags inseridas", + "ApplyTagsHelpTextReplace": "Substituir: Substitua as tags pelas tags inseridas (não digite nenhuma tag para limpar todas as tags)", "ApplyTagsHelpTextHowToApplyArtists": "Como aplicar tags aos artistas selecionados", "ApplyTagsHelpTextHowToApplyDownloadClients": "Como aplicar tags aos clientes de download selecionados", "ApplyTagsHelpTextHowToApplyImportLists": "Como aplicar tags às listas de importação selecionadas", @@ -1051,10 +1051,10 @@ "SomeResultsAreHiddenByTheAppliedFilter": "Alguns resultados estão ocultos pelo filtro aplicado", "AllResultsAreHiddenByTheAppliedFilter": "Todos os resultados são ocultados pelo filtro aplicado", "AppUpdated": "{appName} Atualizado", - "AppUpdatedVersion": "{appName} foi atualizado para a versão `{version}`, para obter as alterações mais recentes, você precisará recarregar {appName}", + "AppUpdatedVersion": "{appName} foi atualizado para a versão `{version}`. Para obter as alterações mais recentes, você precisará recarregar {appName}", "ConnectionLost": "Conexão Perdida", "ConnectionLostReconnect": "{appName} tentará se conectar automaticamente ou você pode clicar em recarregar abaixo.", - "ConnectionLostToBackend": "{appName} perdeu sua conexão com o backend e precisará ser recarregado para restaurar a funcionalidade.", + "ConnectionLostToBackend": "{appName} perdeu a conexão com o backend e precisará ser recarregado para restaurar a funcionalidade.", "RecentChanges": "Mudanças Recentes", "WhatsNew": "O que há de novo?", "NotificationStatusAllClientHealthCheckMessage": "Todas as notificações estão indisponíveis devido a falhas", diff --git a/src/NzbDrone.Core/Localization/Core/ro.json b/src/NzbDrone.Core/Localization/Core/ro.json index 345554139..bfe0f58c7 100644 --- a/src/NzbDrone.Core/Localization/Core/ro.json +++ b/src/NzbDrone.Core/Localization/Core/ro.json @@ -680,5 +680,10 @@ "RecentChanges": "Schimbări recente", "WhatsNew": "Ce mai e nou?", "Authentication": "Autentificare", - "NotificationStatusSingleClientHealthCheckMessage": "Notificări indisponibile datorită erorilor: {0}" + "NotificationStatusSingleClientHealthCheckMessage": "Notificări indisponibile datorită erorilor: {0}", + "AddDownloadClientImplementation": "Adăugați client de descărcare - {implementationName}", + "AddIndexerImplementation": "Adăugați Indexator - {implementationName}", + "AddConnectionImplementation": "Adăugați conexiune - {implementationName}", + "Album": "Album", + "AddConnection": "Adăugați conexiune" } diff --git a/src/NzbDrone.Core/Localization/Core/tr.json b/src/NzbDrone.Core/Localization/Core/tr.json index b4fe905bd..71b71d6c5 100644 --- a/src/NzbDrone.Core/Localization/Core/tr.json +++ b/src/NzbDrone.Core/Localization/Core/tr.json @@ -683,5 +683,6 @@ "Absolute": "Mutlak", "AddNewArtistRootFolderHelpText": "'{0}' alt klasörü otomatik olarak oluşturulacak", "Priority": "Öncelik", - "AddMetadataProfile": "üstveri profili" + "AddMetadataProfile": "üstveri profili", + "EditMetadataProfile": "üstveri profili" } diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json index 493bc9eeb..bc2e602fb 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_CN.json +++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json @@ -6,7 +6,7 @@ "BackupRetentionHelpText": "早于保留周期的自动备份将被自动清除", "Authentication": "认证", "Dates": "日期", - "DeleteBackupMessageText": "您确定要删除备份 '{0}' 吗?", + "DeleteBackupMessageText": "您确定要删除备份“{name}”吗?", "DeleteNotificationMessageText": "您确定要删除消息推送 “{name}” 吗?", "Docker": "Docker", "DownloadClient": "下载客户端", @@ -142,7 +142,7 @@ "SSLPort": "SSL端口", "Settings": "设置", "ShowSearch": "显示搜索按钮", - "Source": "源", + "Source": "来源", "StartupDirectory": "启动目录", "Status": "状态", "Style": "类型", @@ -247,7 +247,7 @@ "MustNotContain": "必须不包含", "NamingSettings": "命名设置", "NETCore": ".NET", - "NoHistory": "无历史记录", + "NoHistory": "无历史记录。", "None": "无", "OnGrabHelpText": "抓取中", "OnRename": "重命名中", @@ -377,7 +377,7 @@ "ChownGroupHelpTextWarning": "这只在运行Radarr的用户是文件所有者的情况下才有效。最好确保下载客户端使用与Radarr相同的组。", "CompletedDownloadHandling": "完成下载处理", "Component": "组件", - "CopyUsingHardlinksHelpText": "拷贝文件时torrents文件还在做种中则使用硬链接", + "CopyUsingHardlinksHelpText": "硬链接允许Lidarr导入torrents种子到剧集文件夹,而无需占用额外的磁盘空间或复制文件的整个内容。只有当源和目标在同一卷上时,硬链接才会起作用", "CopyUsingHardlinksHelpTextWarning": "有时候,文件锁可能会阻止对正在做种的文件进行重命名。您可以暂时禁用做种功能,并使用Radarr的重命名功能作为解决方案。", "CreateEmptyArtistFolders": "为艺术家创建空文件夹", "CreateEmptyArtistFoldersHelpText": "硬盘扫描过程中创建缺失的电影目录", @@ -390,12 +390,12 @@ "DeleteImportListExclusion": "删除导入排除列表", "DeleteImportListExclusionMessageText": "你确定要删除这个导入排除列表吗?", "DeleteImportListMessageText": "您确定要删除列表 “{name}” 吗?", - "DeleteMetadataProfileMessageText": "确定要删除元数据配置吗‘{0}’?", + "DeleteMetadataProfileMessageText": "您确定要删除元数据配置文件“{name}”吗?", "DeleteQualityProfile": "删除质量配置", "DeleteQualityProfileMessageText": "你确定要删除质量配置 “{name}” 吗?", "DeleteReleaseProfile": "删除发布组配置", - "DeleteReleaseProfileMessageText": "您确定要删除这个延迟配置?", - "DeleteRootFolderMessageText": "您确定要删除索引 '{0}'吗?", + "DeleteReleaseProfileMessageText": "你确定你要删除这个发行版配置文件?", + "DeleteRootFolderMessageText": "您确定要删除根文件夹“{name}”吗?", "DeleteSelectedTrackFiles": "删除选择的电影文件", "DeleteSelectedTrackFilesMessageText": "您确定要删除选择的电影文件吗?", "DeleteTrackFileMessageText": "您确认您想删除吗?", @@ -446,7 +446,7 @@ "LaunchBrowserHelpText": " 启动浏览器时导航到Radarr主页。", "Level": "等级", "ShowSizeOnDisk": "显示占用磁盘体积", - "UnableToLoadHistory": "无法加载历史记录", + "UnableToLoadHistory": "无法加载历史记录。", "Folders": "文件夹", "PreviewRename": "预览重命名", "Profiles": "配置", @@ -522,7 +522,7 @@ "MissingAlbumsData": "监控没有文件或尚未发布的书籍", "OnReleaseImport": "在发行导入时", "QualityProfileIdHelpText": "质量配置列表项应该被添加", - "ReleaseProfiles": "刷新配置文件", + "ReleaseProfiles": "发行版概要", "RootFolderPathHelpText": "根目录文件夹列表项需添加", "ScrubAudioTagsHelpText": "从文件中删除现有标签,只留下Readarr添加的标签。", "ScrubExistingTags": "覆盖现有标签", @@ -594,7 +594,7 @@ "DeleteMetadataProfile": "删除元数据配置", "DeleteRootFolder": "删除根目录", "Details": "详情", - "Donations": "捐赠", + "Donations": "赞助", "DoNotUpgradeAutomatically": "不要自动升级", "DownloadFailed": "下载失败", "DownloadPropersAndRepacksHelpTexts2": "使用“不要偏好”按首字母而不是专有/重新打包进行排序", @@ -682,7 +682,7 @@ "Save": "保存", "Seeders": "种子", "Select...": "'选择...", - "SelectedCountArtistsSelectedInterp": "{0} 作者选中", + "SelectedCountArtistsSelectedInterp": "{selectedCount}艺术家已选中", "SelectFolder": "选择文件夹", "SelectQuality": "选择质量", "ShouldMonitorExistingHelpText": "自动监控此列表中已经在Readarr中的书籍", @@ -709,7 +709,7 @@ "UpgradesAllowed": "允许升级", "Wanted": "想要的", "Warn": "警告", - "WouldYouLikeToRestoreBackup": "想要恢复备份 {0} 吗?", + "WouldYouLikeToRestoreBackup": "是否要还原备份“{name}”?", "WriteMetadataTags": "编写元数据标签", "WriteMetadataToAudioFiles": "将元数据写入音频文件", "OnImportFailureHelpText": "在导入失败时", @@ -841,7 +841,7 @@ "Retagged": "已重新打标", "RootFolderPath": "根目录路径", "TBA": "待定", - "TheArtistFolderStrongpathstrongAndAllOfItsContentWillBeDeleted": "歌手目录 {path} 及目录下所有内容都将被删除。", + "TheArtistFolderStrongpathstrongAndAllOfItsContentWillBeDeleted": "艺术家文件夹“{0}”及其所有内容将被删除。", "Theme": "主题", "ThemeHelpText": "改变应用界面主题,选择“自动”主题会通过操作系统主题来自适应白天黑夜模式。(受Theme.Park启发)", "MassAlbumsCutoffUnmetWarning": "你确定要搜索所有 '{0}' 个缺失专辑么?", @@ -873,7 +873,7 @@ "IndexerRssHealthCheckNoAvailableIndexers": "由于索引器错误,所有支持rss的索引器暂时不可用", "IndexerRssHealthCheckNoIndexers": "没有任何索引器开启了RSS同步,Radarr不会自动抓取新发布的影片", "IndexerSearchCheckNoInteractiveMessage": "没有可用的交互式搜索的索引器,因此 Lidarr 不会提供任何交互式搜索的结果", - "ItsEasyToAddANewArtistJustStartTypingTheNameOfTheArtistYouWantToAdd": "添加电影很简单,只需要输入您想要添加的电影名称即可", + "ItsEasyToAddANewArtistJustStartTypingTheNameOfTheArtistYouWantToAdd": "添加新的艺术家很容易,只需开始输入要添加的艺术家的名称即可。", "Loading": "加载中", "Monitor": "是否监控", "MountCheckMessage": "挂载的电影目录包含只读的目录: ", @@ -893,8 +893,8 @@ "CustomFormats": "自定义命名格式", "Customformat": "自定义命名格式", "CutoffFormatScoreHelpText": "一旦自定义格式分数满足则Radarr不会再下载影片", - "DeleteCustomFormatMessageText": "是否确实要删除条件“{0}”?", - "DeleteFormatMessageText": "你确定要删除格式标签 “{0}” 吗?", + "DeleteCustomFormatMessageText": "您确定要删除自定义格式“{name}”吗?", + "DeleteFormatMessageText": "您确定要删除格式标签“{name}”吗?", "ImportListStatusCheckAllClientMessage": "所有的列表因错误不可用", "ImportListStatusCheckSingleClientMessage": "列表因错误不可用:{0}", "ImportMechanismHealthCheckMessage": "启用下载完成处理", @@ -948,15 +948,15 @@ "ListRefreshInterval": "列表刷新间隔", "ApiKeyValidationHealthCheckMessage": "请将API密钥更新为至少{0}个字符长。您可以通过设置或配置文件执行此操作", "ApplyChanges": "应用更改", - "CountDownloadClientsSelected": "已选择 {0} 个下载客户端", - "CountImportListsSelected": "已选择 {0} 个导入列表", - "CountIndexersSelected": "已选择 {0} 个索引器", - "DeleteSelectedDownloadClients": "删除下载客户端", - "DeleteSelectedDownloadClientsMessageText": "您确定要删除 {0} 个选定的下载客户端吗?", + "CountDownloadClientsSelected": "{selectedCount}下载客户端已选中", + "CountImportListsSelected": "{selectedCount}导入列表已选中", + "CountIndexersSelected": "{selectedCount}索引器已选中", + "DeleteSelectedDownloadClients": "删除选定的下载客户端", + "DeleteSelectedDownloadClientsMessageText": "您确定要删除{count}选定的下载客户端吗?", "DeleteSelectedImportLists": "删除导入列表", - "DeleteSelectedImportListsMessageText": "您确定要删除 {0} 个选定的导入列表吗?", + "DeleteSelectedImportListsMessageText": "您确定要删除选定的{count}导入列表吗?", "DeleteSelectedIndexers": "删除索引器", - "DeleteSelectedIndexersMessageText": "您确定要删除 {0} 个选定的索引器吗?", + "DeleteSelectedIndexersMessageText": "您确定要删除{count}选定的索引器吗?", "ManageClients": "管理客户", "ListWillRefreshEveryInterp": "列表将每 {0} 刷新一次", "AutomaticAdd": "自动添加", @@ -993,7 +993,7 @@ "NoEventsFound": "无事件", "RemoveSelectedItem": "删除所选项目", "RemoveSelectedItems": "删除所选项目", - "RemoveFromDownloadClientHelpTextWarning": "移除操作会从下载客户端中删除任务和已下载文件。", + "RemoveFromDownloadClientHelpTextWarning": "删除将从下载客户端删除下载和文件。", "RemovingTag": "移除标签", "Yes": "确定", "ResetQualityDefinitions": "重置质量定义", @@ -1002,13 +1002,13 @@ "SuggestTranslationChange": "建议翻译改变 Suggest translation change", "BlocklistReleaseHelpText": "防止Radarr再次自动抓取此版本", "UpdateSelected": "更新已选", - "DeleteConditionMessageText": "是否确实要删除条件“{0}”?", + "DeleteConditionMessageText": "您确定要删除条件“{name}”吗?", "DownloadClientSortingCheckMessage": "下载客户端 {0} 为 Radarr 的类别启用了 {1} 排序。 您应该在下载客户端中禁用排序以避免导入问题。", "FailedToLoadQueue": "读取队列失败", "MonitorExistingAlbums": "监测现存专辑", "MonitorNewAlbums": "监控新专辑", "ApplyTagsHelpTextHowToApplyArtists": "如何将标记应用于所选剧集", - "DeleteRemotePathMapping": "删除远程映射路径", + "DeleteRemotePathMapping": "删除远程路径映射", "DownloadClientTagHelpText": "仅将此下载客户端用于至少具有一个匹配标签的电影。留空可用于所有电影。", "QueueIsEmpty": "空队列", "ResetTitlesHelpText": "重置定义标题和值", @@ -1017,15 +1017,15 @@ "RemoveSelectedItemQueueMessageText": "您确定要从队列中删除 1 项吗?", "RemoveSelectedItemsQueueMessageText": "您确定要从队列中删除 {0} 个项目吗?", "ShowNextAlbum": "显示最后专辑", - "CountArtistsSelected": "{0} 作者选中", + "CountArtistsSelected": "{selectedCount}艺术家已选中", "AllResultsAreHiddenByTheAppliedFilter": "根据过滤条件所有结果已隐藏", "NoResultsFound": "无结果", "ConnectionLost": "连接丢失", "ConnectionLostReconnect": "{appName} 将会尝试自动连接,您也可以点击下方的重新加载。", - "ConnectionLostToBackend": "{appName} 与后端的链接已断开,需要重新加载恢复功能。", + "ConnectionLostToBackend": "{appName}失去了与后端的连接,需要重新加载以恢复功能。", "WhatsNew": "什么是新的?", - "NotificationStatusAllClientHealthCheckMessage": "由于故障所用应用程序都不可用", - "NotificationStatusSingleClientHealthCheckMessage": "由于故障应用程序不可用", + "NotificationStatusAllClientHealthCheckMessage": "由于故障,所有通知都不可用", + "NotificationStatusSingleClientHealthCheckMessage": "由于失败导致通知不可用:{0}", "RecentChanges": "最近修改", "AddConnectionImplementation": "添加集合 - {implementationName}", "AddDownloadClientImplementation": "添加下载客户端 - {implementationName}", @@ -1037,7 +1037,7 @@ "AddImportList": "添加导入列表", "AddImportListImplementation": "添加导入列表 - {implementationName}", "ErrorLoadingContent": "加载此内容时出错", - "HealthMessagesInfoBox": "您可以通过单击行尾的wiki链接(图书图标)或检查您的[日志]({link})来查找有关这些健康检查的更多信息。如果您在解读这些信息时遇到困难,可以通过以下链接联系我们获得支持。", + "HealthMessagesInfoBox": "您可以通过单击行尾的wiki链接(图书图标)或检查[logs]({link})来查找有关这些运行状况检查消息原因的更多信息。如果你在理解这些信息方面有困难,你可以通过下面的链接联系我们的支持。", "ImportListRootFolderMissingRootHealthCheckMessage": "在导入列表中缺少根目录文件夹:{0}", "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "导入列表中缺失多个根目录文件夹", "NoCutoffUnmetItems": "没有未达截止条件的项目", @@ -1045,7 +1045,7 @@ "BypassIfHighestQualityHelpText": "当发布版本在质量配置文件中具有最高启用质量时,跳过延迟", "PreferProtocol": "首选 {preferredProtocol}", "SkipRedownloadHelpText": "阻止 Readarr 尝试下载已删除项目的替代版本", - "AppUpdatedVersion": "{appName} 已经更新到 {version} 版本,重新加载 {appName} 使更新生效", + "AppUpdatedVersion": "{appName}已更新为版本` {version}`,为了获得最新的更改,您需要重新加载{appName}", "DeleteFormat": "删除格式", "CloneCondition": "克隆条件", "DashOrSpaceDashDependingOnName": "破折号或空格破折号取决于名字", @@ -1053,7 +1053,7 @@ "EditConnectionImplementation": "编辑连接 - {implementationName}", "EditDownloadClientImplementation": "编辑下载客户端 - {implementationName}", "EditImportListImplementation": "编辑导入列表 - {implementationName}", - "EditIndexerImplementation": "添加索引器 - {implementationName}", + "EditIndexerImplementation": "编辑索引器 - {implementationName}", "Enabled": "已启用", "NoMissingItems": "没有缺失的项目", "Priority": "优先级", @@ -1063,5 +1063,24 @@ "SmartReplace": "智能替换", "IndexerDownloadClientHealthCheckMessage": "有无效下载客户端的索引器:{0}。", "TagsHelpText": "发布配置将应用于至少有一个匹配标记的剧集。留空适用于所有剧集", - "AddNewArtistRootFolderHelpText": "将自动创建 '{folder}' 子文件夹" + "AddNewArtistRootFolderHelpText": "将自动创建 '{folder}' 子文件夹", + "NoAlbums": "没有专辑", + "AlbumCount": "专辑数量", + "DownloadedImporting": "“已下载-导入”", + "AddImportListExclusionArtistHelpText": "阻止艺术家通过导入列表添加到Lidarr", + "AddImportListExclusionAlbumHelpText": "阻止专辑通过导入列表添加到Lidarr列表", + "GroupInformation": "群组信息", + "OneAlbum": "1专辑", + "MonitorAlbum": "监控专辑", + "CountAlbums": "{albumCount} 专辑", + "DownloadedWaitingToImport": "“已下载-等待导入”", + "ExpandEPByDefaultHelpText": "EPs", + "FilterAlbumPlaceholder": "过滤专辑", + "Inactive": "不活跃", + "ShowNextAlbumHelpText": "在海报下展示下一张专辑", + "TrackFiles": "跟踪文件", + "AddNewAlbum": "添加新专辑", + "AddNewArtist": "添加新艺术家", + "DeleteSelected": "删除所选", + "FilterArtistPlaceholder": "过滤艺术家" } From 3a740290359e0f669d4b705fb269d073ad828211 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 18 Sep 2023 17:49:26 +0300 Subject: [PATCH 008/820] Preserve the protocol in Artist Image Closes #4141 --- frontend/src/Artist/ArtistImage.js | 9 +++------ frontend/src/Artist/ArtistLogo.js | 8 +++----- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/frontend/src/Artist/ArtistImage.js b/frontend/src/Artist/ArtistImage.js index 6ebb48fe3..669cba8d8 100644 --- a/frontend/src/Artist/ArtistImage.js +++ b/frontend/src/Artist/ArtistImage.js @@ -7,13 +7,10 @@ function findImage(images, coverType) { } function getUrl(image, coverType, size) { - if (image) { - // Remove protocol - let url = image.url.replace(/^https?:/, ''); + const imageUrl = image?.url; - url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`); - - return url; + if (imageUrl) { + return imageUrl.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`); } } diff --git a/frontend/src/Artist/ArtistLogo.js b/frontend/src/Artist/ArtistLogo.js index 10358625f..93b91c2da 100644 --- a/frontend/src/Artist/ArtistLogo.js +++ b/frontend/src/Artist/ArtistLogo.js @@ -10,12 +10,10 @@ function findLogo(images) { } function getLogoUrl(logo, size) { - if (logo) { - // Remove protocol - let url = logo.url.replace(/^https?:/, ''); - url = url.replace('logo.jpg', `logo-${size}.jpg`); + const logoUrl = logo?.url; - return url; + if (logoUrl) { + return logoUrl.replace('logo.jpg', `logo-${size}.jpg`); } } From 13b3555d2521bceba59133567da304fe446a65f0 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Thu, 14 Sep 2023 17:07:32 -0700 Subject: [PATCH 009/820] Fixed: Don't try to create metadata images if source files doesn't exist (cherry picked from commit 9a1022386a031c928fc0495d6ab990ebce605ec1) Closes #4132 --- src/NzbDrone.Core/Extras/Metadata/MetadataService.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs b/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs index 8c8279c0e..087a3f270 100644 --- a/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs +++ b/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs @@ -470,6 +470,7 @@ namespace NzbDrone.Core.Extras.Metadata private void DownloadImage(Artist artist, ImageFileResult image) { var fullPath = Path.Combine(artist.Path, image.RelativePath); + var downloaded = true; try { @@ -477,12 +478,19 @@ namespace NzbDrone.Core.Extras.Metadata { _httpClient.DownloadFile(image.Url, fullPath); } - else + else if (_diskProvider.FileExists(image.Url)) { _diskProvider.CopyFile(image.Url, fullPath); } + else + { + downloaded = false; + } - _mediaFileAttributeService.SetFilePermissions(fullPath); + if (downloaded) + { + _mediaFileAttributeService.SetFilePermissions(fullPath); + } } catch (WebException ex) { From 70fb57fc8339df95e89a188f74b3960ff7a88560 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 17 Sep 2023 23:27:44 -0700 Subject: [PATCH 010/820] Fixed: Skip free space check only applies during import (cherry picked from commit 5ff254b6468fc66a337dc0a13f4be53a997c52fd) Closes #4134 --- src/NzbDrone.Core/Localization/Core/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 1c771374e..fc6d15180 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -915,7 +915,7 @@ "SizeLimit": "Size Limit", "SizeOnDisk": "Size on Disk", "SkipFreeSpaceCheck": "Skip Free Space Check", - "SkipFreeSpaceCheckWhenImportingHelpText": "Use when Lidarr is unable to detect free space from your artist root folder", + "SkipFreeSpaceCheckWhenImportingHelpText": "Use when Lidarr is unable to detect free space of your root folder during file import", "SkipRedownload": "Skip Redownload", "SkipRedownloadHelpText": "Prevents Lidarr from trying download alternative releases for the removed items", "SmartReplace": "Smart Replace", From f620f92cad33588c48ee679cfb4364ede724cc91 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 18 Sep 2023 18:08:30 +0300 Subject: [PATCH 011/820] Log request failures in Notifiarr --- .../Notifications/Notifiarr/NotifiarrProxy.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs b/src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs index f2a246edd..2be620c4e 100644 --- a/src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs +++ b/src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs @@ -1,4 +1,5 @@ using System.Net.Http; +using NLog; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; using NzbDrone.Core.Notifications.Webhook; @@ -14,10 +15,12 @@ namespace NzbDrone.Core.Notifications.Notifiarr { private const string URL = "https://notifiarr.com"; private readonly IHttpClient _httpClient; + private readonly Logger _logger; - public NotifiarrProxy(IHttpClient httpClient) + public NotifiarrProxy(IHttpClient httpClient, Logger logger) { _httpClient = httpClient; + _logger = logger; } public void SendNotification(WebhookPayload payload, NotifiarrSettings settings) @@ -47,12 +50,14 @@ namespace NzbDrone.Core.Notifications.Notifiarr switch ((int)responseCode) { case 401: + _logger.Error("HTTP 401 - API key is invalid"); throw new NotifiarrException("API key is invalid"); case 400: throw new NotifiarrException("Unable to send notification. Ensure Lidarr Integration is enabled & assigned a channel on Notifiarr"); case 502: case 503: case 504: + _logger.Error("Unable to send notification. Service Unavailable"); throw new NotifiarrException("Unable to send notification. Service Unavailable", ex); case 520: case 521: @@ -61,6 +66,7 @@ namespace NzbDrone.Core.Notifications.Notifiarr case 524: throw new NotifiarrException("Cloudflare Related HTTP Error - Unable to send notification", ex); default: + _logger.Error(ex, "Unknown HTTP Error - Unable to send notification"); throw new NotifiarrException("Unknown HTTP Error - Unable to send notification", ex); } } From 5f53282e51a2686aabefde334dabdefd73ced814 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 17 Sep 2023 23:56:17 -0700 Subject: [PATCH 012/820] New: Don't treat 400 responses from Notifiarr as errors (cherry picked from commit 5eb420bbe12f59d0a5392abf3d351be28ca210e6) Closes #4137 --- src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs b/src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs index 2be620c4e..ec89c48d0 100644 --- a/src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs +++ b/src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs @@ -53,7 +53,10 @@ namespace NzbDrone.Core.Notifications.Notifiarr _logger.Error("HTTP 401 - API key is invalid"); throw new NotifiarrException("API key is invalid"); case 400: - throw new NotifiarrException("Unable to send notification. Ensure Lidarr Integration is enabled & assigned a channel on Notifiarr"); + // 400 responses shouldn't be treated as an actual error because it's a misconfiguration + // between Lidarr and Notifiarr for a specific event, but shouldn't stop all events. + _logger.Error("HTTP 400 - Unable to send notification. Ensure Lidarr Integration is enabled & assigned a channel on Notifiarr"); + break; case 502: case 503: case 504: From 00ba296b283e1028846cb9ae3b3ac7b35ec374a6 Mon Sep 17 00:00:00 2001 From: Stevie Robinson Date: Tue, 12 Sep 2023 00:19:44 +0200 Subject: [PATCH 013/820] Add health check for dl clients removing completed downloads + enable for sab and qbit (cherry picked from commit 7f2cd8a0e99b537a1c616998514bacdd8468a016) Closes #4138 --- ...ntRemovesCompletedDownloadsCheckFixture.cs | 77 +++++++++++++++++++ .../Clients/QBittorrent/QBittorrent.cs | 3 +- .../Download/Clients/Sabnzbd/Sabnzbd.cs | 2 + .../Clients/Sabnzbd/SabnzbdCategory.cs | 1 + .../Download/DownloadClientInfo.cs | 1 + ...oadClientRemovesCompletedDownloadsCheck.cs | 64 +++++++++++++++ src/NzbDrone.Core/Localization/Core/en.json | 1 + 7 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientRemovesCompletedDownloadsCheckFixture.cs create mode 100644 src/NzbDrone.Core/HealthCheck/Checks/DownloadClientRemovesCompletedDownloadsCheck.cs diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientRemovesCompletedDownloadsCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientRemovesCompletedDownloadsCheckFixture.cs new file mode 100644 index 000000000..abaad836f --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientRemovesCompletedDownloadsCheckFixture.cs @@ -0,0 +1,77 @@ +using System; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.Localization; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class DownloadClientRemovesCompletedDownloadsCheckFixture : CoreTest + { + private DownloadClientInfo _clientStatus; + private Mock _downloadClient; + + private static Exception[] DownloadClientExceptions = + { + new DownloadClientUnavailableException("error"), + new DownloadClientAuthenticationException("error"), + new DownloadClientException("error") + }; + + [SetUp] + public void Setup() + { + _clientStatus = new DownloadClientInfo + { + IsLocalhost = true, + SortingMode = null, + RemovesCompletedDownloads = true + }; + + _downloadClient = Mocker.GetMock(); + _downloadClient.Setup(s => s.Definition) + .Returns(new DownloadClientDefinition { Name = "Test" }); + + _downloadClient.Setup(s => s.GetStatus()) + .Returns(_clientStatus); + + Mocker.GetMock() + .Setup(s => s.GetDownloadClients(It.IsAny())) + .Returns(new IDownloadClient[] { _downloadClient.Object }); + + Mocker.GetMock() + .Setup(s => s.GetLocalizedString(It.IsAny())) + .Returns("Some Warning Message"); + } + + [Test] + public void should_return_warning_if_removing_completed_downloads_is_enabled() + { + Subject.Check().ShouldBeWarning(); + } + + [Test] + public void should_return_ok_if_remove_completed_downloads_is_not_enabled() + { + _clientStatus.RemovesCompletedDownloads = false; + Subject.Check().ShouldBeOk(); + } + + [Test] + [TestCaseSource("DownloadClientExceptions")] + public void should_return_ok_if_client_throws_downloadclientexception(Exception ex) + { + _downloadClient.Setup(s => s.GetStatus()) + .Throws(ex); + + Subject.Check().ShouldBeOk(); + + ExceptionVerification.ExpectedErrors(0); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index 5399f99ff..1fda41d25 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -387,7 +387,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent return new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost", - OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, destDir) } + OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, destDir) }, + RemovesCompletedDownloads = (config.MaxRatioEnabled || config.MaxSeedingTimeEnabled) && (config.MaxRatioAction == QBittorrentMaxRatioAction.Remove || config.MaxRatioAction == QBittorrentMaxRatioAction.DeleteFiles) }; } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs index 8804190ea..662e84e76 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs @@ -276,6 +276,8 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd status.OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, category.FullPath) }; } + status.RemovesCompletedDownloads = config.Misc.history_retention != "0"; + return status; } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs index e25a91701..189b08257 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs @@ -29,6 +29,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd public string[] date_categories { get; set; } public bool enable_date_sorting { get; set; } public bool pre_check { get; set; } + public string history_retention { get; set; } } public class SabnzbdCategory diff --git a/src/NzbDrone.Core/Download/DownloadClientInfo.cs b/src/NzbDrone.Core/Download/DownloadClientInfo.cs index 686258520..d76d8aa1a 100644 --- a/src/NzbDrone.Core/Download/DownloadClientInfo.cs +++ b/src/NzbDrone.Core/Download/DownloadClientInfo.cs @@ -12,6 +12,7 @@ namespace NzbDrone.Core.Download public bool IsLocalhost { get; set; } public string SortingMode { get; set; } + public bool RemovesCompletedDownloads { get; set; } public List OutputRootFolders { get; set; } } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientRemovesCompletedDownloadsCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientRemovesCompletedDownloadsCheck.cs new file mode 100644 index 000000000..9f160410b --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientRemovesCompletedDownloadsCheck.cs @@ -0,0 +1,64 @@ +using System; +using NLog; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.Localization; +using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.RootFolders; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ModelEvent))] + [CheckOn(typeof(ModelEvent))] + + public class DownloadClientRemovesCompletedDownloadsCheck : HealthCheckBase, IProvideHealthCheck + { + private readonly IProvideDownloadClient _downloadClientProvider; + private readonly Logger _logger; + + public DownloadClientRemovesCompletedDownloadsCheck(IProvideDownloadClient downloadClientProvider, + Logger logger, + ILocalizationService localizationService) + : base(localizationService) + { + _downloadClientProvider = downloadClientProvider; + _logger = logger; + } + + public override HealthCheck Check() + { + var clients = _downloadClientProvider.GetDownloadClients(true); + + foreach (var client in clients) + { + try + { + var clientName = client.Definition.Name; + var status = client.GetStatus(); + + if (status.RemovesCompletedDownloads) + { + return new HealthCheck(GetType(), + HealthCheckResult.Warning, + string.Format(_localizationService.GetLocalizedString("DownloadClientRemovesCompletedDownloadsHealthCheckMessage"), clientName, "Lidarr"), + "#download-client-removes-completed-downloads"); + } + } + catch (DownloadClientException ex) + { + _logger.Debug(ex, "Unable to communicate with {0}", client.Definition.Name); + } + catch (Exception ex) + { + _logger.Error(ex, "Unknown error occurred in DownloadClientHistoryRetentionCheck HealthCheck"); + } + } + + return new HealthCheck(GetType()); + } + } +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index fc6d15180..d1274ae74 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -283,6 +283,7 @@ "DownloadClientCheckDownloadingToRoot": "Download client {0} places downloads in the root folder {1}. You should not download to a root folder.", "DownloadClientCheckNoneAvailableMessage": "No download client is available", "DownloadClientCheckUnableToCommunicateMessage": "Unable to communicate with {0}.", + "DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "Download client {0} is set to remove completed downloads. This can result in downloads being removed from your client before {1} can import them.", "DownloadClientSettings": "Download Client Settings", "DownloadClientSortingCheckMessage": "Download client {0} has {1} sorting enabled for Lidarr's category. You should disable sorting in your download client to avoid import issues.", "DownloadClientStatusCheckAllClientMessage": "All download clients are unavailable due to failures", From 6e479235cba9e96276059a1d17c1f015dc1e152f Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 19 Sep 2023 07:05:28 +0300 Subject: [PATCH 014/820] Fixed: Skip parsing releases without title (cherry picked from commit c7824bb593291634bf14a5f7aa689666969b03bf) --- src/NzbDrone.Common/Extensions/PathExtensions.cs | 2 +- src/NzbDrone.Core/Indexers/HttpIndexerBase.cs | 10 +++++++++- src/NzbDrone.Core/Parser/QualityParser.cs | 7 ++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index 0c60d2e65..dad4cdc5f 100644 --- a/src/NzbDrone.Common/Extensions/PathExtensions.cs +++ b/src/NzbDrone.Common/Extensions/PathExtensions.cs @@ -170,7 +170,7 @@ namespace NzbDrone.Common.Extensions { if (text.IsNullOrWhiteSpace()) { - throw new ArgumentNullException("text"); + throw new ArgumentNullException(nameof(text)); } return text.IndexOfAny(Path.GetInvalidPathChars()) >= 0; diff --git a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs index 365a6af5a..9f60f6e18 100644 --- a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs @@ -257,9 +257,17 @@ namespace NzbDrone.Core.Indexers protected virtual bool IsValidRelease(ReleaseInfo release) { + if (release.Title.IsNullOrWhiteSpace()) + { + _logger.Trace("Invalid Release: '{0}' from indexer: {1}. No title provided.", release.InfoUrl, Definition.Name); + + return false; + } + if (release.DownloadUrl.IsNullOrWhiteSpace()) { - _logger.Trace("Invalid Release: '{0}' from indexer: {1}. No Download URL provided.", release.Title, release.Indexer); + _logger.Trace("Invalid Release: '{0}' from indexer: {1}. No Download URL provided.", release.Title, Definition.Name); + return false; } diff --git a/src/NzbDrone.Core/Parser/QualityParser.cs b/src/NzbDrone.Core/Parser/QualityParser.cs index 17faab082..e8bceb239 100644 --- a/src/NzbDrone.Core/Parser/QualityParser.cs +++ b/src/NzbDrone.Core/Parser/QualityParser.cs @@ -46,7 +46,12 @@ namespace NzbDrone.Core.Parser public static QualityModel ParseQuality(string name, string desc, int fileBitrate, int fileSampleSize = 0) { - Logger.Debug("Trying to parse quality for {0}", name); + Logger.Debug("Trying to parse quality for '{0}'", name); + + if (name.IsNullOrWhiteSpace()) + { + return new QualityModel { Quality = Quality.Unknown }; + } var normalizedName = name.Replace('_', ' ').Trim().ToLower(); var result = ParseQualityModifiers(name, normalizedName); From 44add0d884aa1a509bf132fa0d2cde98c318ec41 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 18 Sep 2023 17:45:35 +0300 Subject: [PATCH 015/820] Preserve the protocol for fanart images (cherry picked from commit d8633b968830fd20d73612e9f0d0559b0bcb304c) Closes #4145 --- frontend/src/Album/Details/AlbumDetails.js | 6 +----- frontend/src/Artist/Details/ArtistDetails.js | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/frontend/src/Album/Details/AlbumDetails.js b/frontend/src/Album/Details/AlbumDetails.js index 0a6c883a4..a23237bd9 100644 --- a/frontend/src/Album/Details/AlbumDetails.js +++ b/frontend/src/Album/Details/AlbumDetails.js @@ -39,11 +39,7 @@ const intermediateFontSize = parseInt(fonts.intermediateFontSize); const lineHeight = parseFloat(fonts.lineHeight); function getFanartUrl(images) { - const fanartImage = _.find(images, { coverType: 'fanart' }); - if (fanartImage) { - // Remove protocol - return fanartImage.url.replace(/^https?:/, ''); - } + return _.find(images, { coverType: 'fanart' })?.url; } function formatDuration(timeSpan) { diff --git a/frontend/src/Artist/Details/ArtistDetails.js b/frontend/src/Artist/Details/ArtistDetails.js index 7ff777f1e..c735e80a6 100644 --- a/frontend/src/Artist/Details/ArtistDetails.js +++ b/frontend/src/Artist/Details/ArtistDetails.js @@ -44,11 +44,7 @@ const defaultFontSize = parseInt(fonts.defaultFontSize); const lineHeight = parseFloat(fonts.lineHeight); function getFanartUrl(images) { - const fanartImage = _.find(images, { coverType: 'fanart' }); - if (fanartImage) { - // Remove protocol - return fanartImage.url.replace(/^https?:/, ''); - } + return _.find(images, { coverType: 'fanart' })?.url; } function getExpandedState(newState) { From 44824df7e4d99e04991442ed618322a9b400fb30 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 19 Sep 2023 21:32:36 +0300 Subject: [PATCH 016/820] Check for empty description as well in ParseQuality --- src/NzbDrone.Core/Parser/QualityParser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Parser/QualityParser.cs b/src/NzbDrone.Core/Parser/QualityParser.cs index e8bceb239..c2d3aa3c6 100644 --- a/src/NzbDrone.Core/Parser/QualityParser.cs +++ b/src/NzbDrone.Core/Parser/QualityParser.cs @@ -48,7 +48,7 @@ namespace NzbDrone.Core.Parser { Logger.Debug("Trying to parse quality for '{0}'", name); - if (name.IsNullOrWhiteSpace()) + if (name.IsNullOrWhiteSpace() && desc.IsNullOrWhiteSpace()) { return new QualityModel { Quality = Quality.Unknown }; } From 55c65d3d3d01ff45763e7d1610baa1c1ca49be24 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Fri, 13 Jan 2023 17:39:00 -0800 Subject: [PATCH 017/820] Fixed: UTC time sent to UI for already imported message (cherry picked from commit 3f598ffa6fbec90ecdbb266de4b0fe7558fbbc30) Closes #3285 --- .../TrackImport/Specifications/AlreadyImportedSpecification.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlreadyImportedSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlreadyImportedSpecification.cs index f31c94497..dcda7f9a1 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlreadyImportedSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlreadyImportedSpecification.cs @@ -58,7 +58,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications if (lastImported.DownloadId == downloadClientItem.DownloadId) { _logger.Debug("Album previously imported at {0}", lastImported.Date); - return Decision.Reject("Album already imported at {0}", lastImported.Date); + return Decision.Reject("Album already imported at {0}", lastImported.Date.ToLocalTime()); } return Decision.Accept(); From 7941ca58a5b20960fb6f71123e550e72ab19081b Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sat, 23 Sep 2023 15:51:22 +0300 Subject: [PATCH 018/820] Rename Episode to Track in namespaces --- .../MediaFiles/TrackImport/GetSceneNameFixture.cs | 4 ++-- .../MediaFiles/TrackImport/Aggregation/AggregationService.cs | 1 - .../MediaFiles/TrackImport/SceneNameCalculator.cs | 2 +- .../Specifications/AlreadyImportedSpecification.cs | 3 +-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/GetSceneNameFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/GetSceneNameFixture.cs index 22f0b9b9a..d5d88fe69 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/GetSceneNameFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/GetSceneNameFixture.cs @@ -3,7 +3,7 @@ using System.IO; using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; -using NzbDrone.Core.MediaFiles.EpisodeImport; +using NzbDrone.Core.MediaFiles.TrackImport; using NzbDrone.Core.Music; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles.Qualities; @@ -11,7 +11,7 @@ using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; using NzbDrone.Test.Common; -namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport +namespace NzbDrone.Core.Test.MediaFiles.TrackImport { [TestFixture] public class GetSceneNameFixture : CoreTest diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationService.cs index b2a6725fc..e91b05f11 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationService.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.IO; using NLog; using NzbDrone.Common.Disk; -using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators; using NzbDrone.Core.Parser.Model; diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/SceneNameCalculator.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/SceneNameCalculator.cs index 6983b4bdf..26597d9bb 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/SceneNameCalculator.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/SceneNameCalculator.cs @@ -3,7 +3,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; -namespace NzbDrone.Core.MediaFiles.EpisodeImport +namespace NzbDrone.Core.MediaFiles.TrackImport { public static class SceneNameCalculator { diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlreadyImportedSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlreadyImportedSpecification.cs index dcda7f9a1..a433bf601 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlreadyImportedSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlreadyImportedSpecification.cs @@ -4,10 +4,9 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.History; -using NzbDrone.Core.MediaFiles.TrackImport; using NzbDrone.Core.Parser.Model; -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications { public class AlreadyImportedSpecification : IImportDecisionEngineSpecification { From 4fc95a7fb4526d727a89b044830711f151e57cd2 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 19 Sep 2023 20:12:30 +0300 Subject: [PATCH 019/820] Avoid returning null in static resource mapper Task (cherry picked from commit a1ea7accb32bc72f61ed4531d109f76fad843939) --- src/Lidarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs b/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs index 947042c12..5774df28e 100644 --- a/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs +++ b/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs @@ -50,7 +50,7 @@ namespace Lidarr.Http.Frontend.Mappers _logger.Warn("File {0} not found", filePath); - return null; + return Task.FromResult(null); } protected virtual Stream GetContentStream(string filePath) From 3a494c6040c61fca5250a60c89c28bccc9b20491 Mon Sep 17 00:00:00 2001 From: Stevie Robinson Date: Wed, 27 Sep 2023 16:48:15 +0200 Subject: [PATCH 020/820] Fixed: SABnzbd history retention to allow at least 14 days (cherry picked from commit a3938d8e0264b48b35f4715cbc15329fb489218a) --- .../SabnzbdTests/SabnzbdFixture.cs | 24 +++++++++++++++++++ .../Download/Clients/Sabnzbd/Sabnzbd.cs | 11 ++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs index 811ee6444..0178d643a 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs @@ -452,6 +452,30 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests result.OutputRootFolders.First().Should().Be(fullCategoryDir); } + [TestCase("0")] + [TestCase("15d")] + public void should_set_history_removes_completed_downloads_false(string historyRetention) + { + _config.Misc.history_retention = historyRetention; + + var downloadClientInfo = Subject.GetStatus(); + + downloadClientInfo.RemovesCompletedDownloads.Should().BeFalse(); + } + + [TestCase("-1")] + [TestCase("15")] + [TestCase("3")] + [TestCase("3d")] + public void should_set_history_removes_completed_downloads_true(string historyRetention) + { + _config.Misc.history_retention = historyRetention; + + var downloadClientInfo = Subject.GetStatus(); + + downloadClientInfo.RemovesCompletedDownloads.Should().BeTrue(); + } + [TestCase(@"Y:\nzbget\root", @"completed\downloads", @"vv", @"Y:\nzbget\root\completed\downloads", @"Y:\nzbget\root\completed\downloads\vv")] [TestCase(@"Y:\nzbget\root", @"completed", @"vv", @"Y:\nzbget\root\completed", @"Y:\nzbget\root\completed\vv")] [TestCase(@"/nzbget/root", @"completed/downloads", @"vv", @"/nzbget/root/completed/downloads", @"/nzbget/root/completed/downloads/vv")] diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs index 662e84e76..f6c725fa6 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs @@ -276,7 +276,16 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd status.OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, category.FullPath) }; } - status.RemovesCompletedDownloads = config.Misc.history_retention != "0"; + if (config.Misc.history_retention.IsNotNullOrWhiteSpace() && config.Misc.history_retention.EndsWith("d")) + { + int.TryParse(config.Misc.history_retention.AsSpan(0, config.Misc.history_retention.Length - 1), + out var daysRetention); + status.RemovesCompletedDownloads = daysRetention < 14; + } + else + { + status.RemovesCompletedDownloads = config.Misc.history_retention != "0"; + } return status; } From f5bbf21d724a8d8654e397adb5e195ce37772474 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Wed, 27 Sep 2023 09:50:06 -0500 Subject: [PATCH 021/820] Fixed: Only apply remote path mappings for completed items in Qbit (cherry picked from commit 583eb52ddc01b608ab6cb17e863a8830c17b7b75) --- .../QBittorrentTests/QBittorrentFixture.cs | 4 +- .../Clients/QBittorrent/QBittorrent.cs | 40 ++++++++----------- .../Clients/QBittorrent/QBittorrentTorrent.cs | 3 ++ 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs index 73fbd828c..09c1ea4c5 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs @@ -137,7 +137,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests { Mocker.GetMock() .Setup(s => s.GetTorrentProperties(torrent.Hash.ToLower(), It.IsAny())) - .Returns(new QBittorrentTorrentProperties { SavePath = torrent.SavePath }); + .Returns(new QBittorrentTorrentProperties { ContentPath = torrent.ContentPath, SavePath = torrent.SavePath }); Mocker.GetMock() .Setup(s => s.GetTorrentFiles(torrent.Hash.ToLower(), It.IsAny())) @@ -425,7 +425,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests Size = 1000, Progress = 0.7, Eta = 8640000, - State = "stalledDL", + State = "pausedUP", Label = "", SavePath = @"C:\Torrents".AsOsAgnostic(), ContentPath = @"C:\Torrents\Droned.S01.12".AsOsAgnostic() diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index 1fda41d25..ec8861bdb 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -302,19 +302,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent break; } - if (version >= new Version("2.6.1")) - { - if (torrent.ContentPath != torrent.SavePath) - { - item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.ContentPath)); - } - else if (item.Status == DownloadItemStatus.Completed) - { - item.Status = DownloadItemStatus.Warning; - item.Message = "Unable to Import. Path matches client base download directory, it's possible 'Keep top-level folder' is disabled for this torrent or 'Torrent Content Layout' is NOT set to 'Original' or 'Create Subfolder'?"; - } - } - queueItems.Add(item); } @@ -328,9 +315,22 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public override DownloadClientItem GetImportItem(DownloadClientItem item, DownloadClientItem previousImportAttempt) { - // On API version >= 2.6.1 this is already set correctly - if (!item.OutputPath.IsEmpty) + var properties = Proxy.GetTorrentProperties(item.DownloadId.ToLower(), Settings); + var savePath = new OsPath(properties.SavePath); + var version = Proxy.GetApiVersion(Settings); + + if (version >= new Version("2.6.1")) { + if (properties.ContentPath != savePath.ToString()) + { + item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(properties.ContentPath)); + } + else + { + item.Status = DownloadItemStatus.Warning; + item.Message = "Unable to Import. Path matches client base download directory, it's possible 'Keep top-level folder' is disabled for this torrent or 'Torrent Content Layout' is NOT set to 'Original' or 'Create Subfolder'?"; + } + return item; } @@ -341,11 +341,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent return item; } - var properties = Proxy.GetTorrentProperties(item.DownloadId.ToLower(), Settings); - var savePath = new OsPath(properties.SavePath); - - var result = item.Clone(); - // get the first subdirectory - QBittorrent returns `/` path separators even on windows... var relativePath = new OsPath(files[0].Name); while (!relativePath.Directory.IsEmpty) @@ -354,10 +349,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } var outputPath = savePath + relativePath.FileName; + item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath); - result.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath); - - return result; + return item; } public override DownloadClientInfo GetStatus() diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs index 41e300446..355456623 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs @@ -48,6 +48,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent [JsonProperty(PropertyName = "seeding_time")] public long SeedingTime { get; set; } // Torrent seeding time (in seconds) + + [JsonProperty(PropertyName = "content_path")] + public string ContentPath { get; set; } // Torrent save path } public class QBittorrentTorrentFile From 4fc2cd5e6e8bc6ac60b4c2c188a3531d0dc5960e Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sun, 1 Oct 2023 17:17:23 +0300 Subject: [PATCH 022/820] Bump version to 1.4.5 --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 17189486e..5793ee2f0 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,7 +9,7 @@ variables: testsFolder: './_tests' yarnCacheFolder: $(Pipeline.Workspace)/.yarn nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages - majorVersion: '1.4.4' + majorVersion: '1.4.5' minorVersion: $[counter('minorVersion', 1076)] lidarrVersion: '$(majorVersion).$(minorVersion)' buildName: '$(Build.SourceBranchName).$(lidarrVersion)' From 269ea5597ffc73ba23779d511289c4fd9e46e2e0 Mon Sep 17 00:00:00 2001 From: Stevie Robinson Date: Wed, 27 Sep 2023 19:21:44 +0200 Subject: [PATCH 023/820] Fixed: qBittorent history retention to allow at least 14 days seeding (cherry picked from commit 33b87acabf2b4c71ee24cda1a466dec6f4f76996) --- src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index ec8861bdb..e111da7ca 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -378,11 +378,13 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } } + var minimumRetention = 60 * 24 * 14; + return new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost", OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, destDir) }, - RemovesCompletedDownloads = (config.MaxRatioEnabled || config.MaxSeedingTimeEnabled) && (config.MaxRatioAction == QBittorrentMaxRatioAction.Remove || config.MaxRatioAction == QBittorrentMaxRatioAction.DeleteFiles) + RemovesCompletedDownloads = (config.MaxRatioEnabled || (config.MaxSeedingTimeEnabled && config.MaxSeedingTime < minimumRetention)) && (config.MaxRatioAction == QBittorrentMaxRatioAction.Remove || config.MaxRatioAction == QBittorrentMaxRatioAction.DeleteFiles) }; } From 2bfc6ae5d6c3cf5b6f255849dcd1a3d1f2a02bc1 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Wed, 27 Sep 2023 12:06:30 -0700 Subject: [PATCH 024/820] Fixed: Completed downloads in Qbit missing import path (cherry picked from commit 35365665cfd436ac276dd9591e23333bd26cf789) Closes #4162 --- .../QBittorrentTests/QBittorrentFixture.cs | 2 +- .../Clients/QBittorrent/QBittorrent.cs | 40 +++++++++++-------- .../Clients/QBittorrent/QBittorrentTorrent.cs | 3 -- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs index 09c1ea4c5..c83c6e5dd 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs @@ -137,7 +137,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests { Mocker.GetMock() .Setup(s => s.GetTorrentProperties(torrent.Hash.ToLower(), It.IsAny())) - .Returns(new QBittorrentTorrentProperties { ContentPath = torrent.ContentPath, SavePath = torrent.SavePath }); + .Returns(new QBittorrentTorrentProperties { SavePath = torrent.SavePath }); Mocker.GetMock() .Setup(s => s.GetTorrentFiles(torrent.Hash.ToLower(), It.IsAny())) diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index e111da7ca..2b581343c 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -302,6 +302,19 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent break; } + if (version >= new Version("2.6.1") && item.Status == DownloadItemStatus.Completed) + { + if (torrent.ContentPath != torrent.SavePath) + { + item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.ContentPath)); + } + else + { + item.Status = DownloadItemStatus.Warning; + item.Message = "Unable to Import. Path matches client base download directory, it's possible 'Keep top-level folder' is disabled for this torrent or 'Torrent Content Layout' is NOT set to 'Original' or 'Create Subfolder'?"; + } + } + queueItems.Add(item); } @@ -315,22 +328,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public override DownloadClientItem GetImportItem(DownloadClientItem item, DownloadClientItem previousImportAttempt) { - var properties = Proxy.GetTorrentProperties(item.DownloadId.ToLower(), Settings); - var savePath = new OsPath(properties.SavePath); - var version = Proxy.GetApiVersion(Settings); - - if (version >= new Version("2.6.1")) + // On API version >= 2.6.1 this is already set correctly + if (!item.OutputPath.IsEmpty) { - if (properties.ContentPath != savePath.ToString()) - { - item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(properties.ContentPath)); - } - else - { - item.Status = DownloadItemStatus.Warning; - item.Message = "Unable to Import. Path matches client base download directory, it's possible 'Keep top-level folder' is disabled for this torrent or 'Torrent Content Layout' is NOT set to 'Original' or 'Create Subfolder'?"; - } - return item; } @@ -341,6 +341,11 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent return item; } + var properties = Proxy.GetTorrentProperties(item.DownloadId.ToLower(), Settings); + var savePath = new OsPath(properties.SavePath); + + var result = item.Clone(); + // get the first subdirectory - QBittorrent returns `/` path separators even on windows... var relativePath = new OsPath(files[0].Name); while (!relativePath.Directory.IsEmpty) @@ -349,9 +354,10 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } var outputPath = savePath + relativePath.FileName; - item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath); - return item; + result.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath); + + return result; } public override DownloadClientInfo GetStatus() diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs index 355456623..41e300446 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs @@ -48,9 +48,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent [JsonProperty(PropertyName = "seeding_time")] public long SeedingTime { get; set; } // Torrent seeding time (in seconds) - - [JsonProperty(PropertyName = "content_path")] - public string ContentPath { get; set; } // Torrent save path } public class QBittorrentTorrentFile From 20e1a6e41c97ef1726a1a2ed592ee7d211dd5a20 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 1 Sep 2023 03:48:23 +0300 Subject: [PATCH 025/820] Fixed: Showing Grab ID in history details modal (cherry picked from commit 6394b3253a5ed41eb2442d3150964dc2b7a537e7) Closes #4088 --- .../History/Details/HistoryDetails.js | 115 ++++++++++++------ .../History/Details/HistoryDetailsModal.js | 3 + frontend/src/Activity/History/HistoryRow.js | 3 + .../src/Artist/History/ArtistHistoryRow.js | 3 + src/NzbDrone.Core/Localization/Core/en.json | 3 +- 5 files changed, 90 insertions(+), 37 deletions(-) diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js index cc0e69fd5..a439194f6 100644 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -60,6 +60,7 @@ function HistoryDetails(props) { eventType, sourceTitle, data, + downloadId, shortDateFormat, timeFormat } = props; @@ -72,7 +73,6 @@ function HistoryDetails(props) { nzbInfoUrl, downloadClient, downloadClientName, - downloadId, age, ageHours, ageMinutes, @@ -90,20 +90,22 @@ function HistoryDetails(props) { /> { - !!indexer && + indexer ? + /> : + null } { - !!releaseGroup && + releaseGroup ? + /> : + null } { @@ -119,7 +121,7 @@ function HistoryDetails(props) { nzbInfoUrl ? - Info URL + {translate('InfoUrl')} @@ -139,27 +141,30 @@ function HistoryDetails(props) { } { - !!downloadId && + downloadId ? + /> : + null } { - !!indexer && + age || ageHours || ageMinutes ? + /> : + null } { - !!publishedDate && + publishedDate ? + /> : + null } ); @@ -179,11 +184,21 @@ function HistoryDetails(props) { /> { - !!message && + downloadId ? + : + null + } + + { + message ? + /> : + null } ); @@ -205,12 +220,13 @@ function HistoryDetails(props) { /> { - !!droppedPath && + droppedPath ? + /> : + null } { @@ -360,9 +376,9 @@ function HistoryDetails(props) { const { indexer, releaseGroup, + customFormatScore, nzbInfoUrl, downloadClient, - downloadId, age, ageHours, ageMinutes, @@ -377,64 +393,80 @@ function HistoryDetails(props) { /> { - !!indexer && + indexer ? + /> : + null } { - !!releaseGroup && + releaseGroup ? + /> : + null } { - !!nzbInfoUrl && + customFormatScore && customFormatScore !== '0' ? + : + null + } + + { + nzbInfoUrl ? - Info URL + {translate('InfoUrl')} {nzbInfoUrl} - + : + null } { - !!downloadClient && + downloadClient ? + /> : + null } { - !!downloadId && + downloadId ? + /> : + null } { - !!indexer && + age || ageHours || ageMinutes ? + /> : + null } { - !!publishedDate && + publishedDate ? + /> : + null } ); @@ -454,11 +486,21 @@ function HistoryDetails(props) { /> { - !!message && + downloadId ? + : + null + } + + { + message ? + /> : + null } ); @@ -479,6 +521,7 @@ HistoryDetails.propTypes = { eventType: PropTypes.string.isRequired, sourceTitle: PropTypes.string.isRequired, data: PropTypes.object.isRequired, + downloadId: PropTypes.string, shortDateFormat: PropTypes.string.isRequired, timeFormat: PropTypes.string.isRequired }; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.js index 187db9cd4..5362a2f43 100644 --- a/frontend/src/Activity/History/Details/HistoryDetailsModal.js +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.js @@ -42,6 +42,7 @@ function HistoryDetailsModal(props) { eventType, sourceTitle, data, + downloadId, isMarkingAsFailed, shortDateFormat, timeFormat, @@ -64,6 +65,7 @@ function HistoryDetailsModal(props) { eventType={eventType} sourceTitle={sourceTitle} data={data} + downloadId={downloadId} shortDateFormat={shortDateFormat} timeFormat={timeFormat} /> @@ -98,6 +100,7 @@ HistoryDetailsModal.propTypes = { eventType: PropTypes.string.isRequired, sourceTitle: PropTypes.string.isRequired, data: PropTypes.object.isRequired, + downloadId: PropTypes.string, isMarkingAsFailed: PropTypes.bool.isRequired, shortDateFormat: PropTypes.string.isRequired, timeFormat: PropTypes.string.isRequired, diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js index 23dea5416..9f2da78d0 100644 --- a/frontend/src/Activity/History/HistoryRow.js +++ b/frontend/src/Activity/History/HistoryRow.js @@ -65,6 +65,7 @@ class HistoryRow extends Component { sourceTitle, date, data, + downloadId, isMarkingAsFailed, columns, shortDateFormat, @@ -244,6 +245,7 @@ class HistoryRow extends Component { eventType={eventType} sourceTitle={sourceTitle} data={data} + downloadId={downloadId} isMarkingAsFailed={isMarkingAsFailed} shortDateFormat={shortDateFormat} timeFormat={timeFormat} @@ -269,6 +271,7 @@ HistoryRow.propTypes = { sourceTitle: PropTypes.string.isRequired, date: PropTypes.string.isRequired, data: PropTypes.object.isRequired, + downloadId: PropTypes.string, isMarkingAsFailed: PropTypes.bool, markAsFailedError: PropTypes.object, columns: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/frontend/src/Artist/History/ArtistHistoryRow.js b/frontend/src/Artist/History/ArtistHistoryRow.js index d48e2eb31..4b2dfff42 100644 --- a/frontend/src/Artist/History/ArtistHistoryRow.js +++ b/frontend/src/Artist/History/ArtistHistoryRow.js @@ -82,6 +82,7 @@ class ArtistHistoryRow extends Component { customFormatScore, date, data, + downloadId, album } = this.props; @@ -128,6 +129,7 @@ class ArtistHistoryRow extends Component { eventType={eventType} sourceTitle={sourceTitle} data={data} + downloadId={downloadId} /> } position={tooltipPositions.LEFT} @@ -180,6 +182,7 @@ ArtistHistoryRow.propTypes = { customFormatScore: PropTypes.number.isRequired, date: PropTypes.string.isRequired, data: PropTypes.object.isRequired, + downloadId: PropTypes.string, fullArtist: PropTypes.bool.isRequired, artist: PropTypes.object.isRequired, album: PropTypes.object.isRequired, diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index d1274ae74..05f4a994b 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -401,7 +401,7 @@ "GoToArtistListing": "Go to artist listing", "GoToInterp": "Go to {0}", "Grab": "Grab", - "GrabID": "Grab ID", + "GrabId": "Grab ID", "GrabRelease": "Grab Release", "GrabReleaseMessageText": "Lidarr was unable to determine which artist and album this release was for. Lidarr may be unable to automatically import this release. Do you want to grab '{0}'?", "GrabSelected": "Grab Selected", @@ -474,6 +474,7 @@ "IndexerTagHelpText": "Only use this indexer for artist with at least one matching tag. Leave blank to use with all artists.", "Indexers": "Indexers", "Info": "Info", + "InfoUrl": "Info URL", "InstanceName": "Instance Name", "InstanceNameHelpText": "Instance name in tab and for Syslog app name", "InteractiveImport": "Interactive Import", From fb21dd0f196aa653f3c89dc9c7b53e33487b0ad8 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 10 Sep 2023 13:07:57 -0700 Subject: [PATCH 026/820] Fixed: Duplicate notifications for failed health checks Mock debouncer for testing (cherry picked from commit c0e54773e213f526a5046fa46aa7b57532471128) (cherry picked from commit bb7b2808e2f70389157408809ec47cc8860b4938) --- src/NzbDrone.Common/TPL/DebounceManager.cs | 17 +++ src/NzbDrone.Common/TPL/Debouncer.cs | 17 +-- .../HealthCheck/HealthCheckServiceFixture.cs | 10 +- .../HealthCheck/HealthCheckService.cs | 136 ++++++++++-------- .../HealthCheck/IProvideHealthCheck.cs | 7 - .../ServerSideNotificationService.cs | 57 ++++---- src/NzbDrone.Test.Common/MockDebouncer.cs | 21 +++ 7 files changed, 164 insertions(+), 101 deletions(-) create mode 100644 src/NzbDrone.Common/TPL/DebounceManager.cs create mode 100644 src/NzbDrone.Test.Common/MockDebouncer.cs diff --git a/src/NzbDrone.Common/TPL/DebounceManager.cs b/src/NzbDrone.Common/TPL/DebounceManager.cs new file mode 100644 index 000000000..60803a3a9 --- /dev/null +++ b/src/NzbDrone.Common/TPL/DebounceManager.cs @@ -0,0 +1,17 @@ +using System; + +namespace NzbDrone.Common.TPL +{ + public interface IDebounceManager + { + Debouncer CreateDebouncer(Action action, TimeSpan debounceDuration); + } + + public class DebounceManager : IDebounceManager + { + public Debouncer CreateDebouncer(Action action, TimeSpan debounceDuration) + { + return new Debouncer(action, debounceDuration); + } + } +} diff --git a/src/NzbDrone.Common/TPL/Debouncer.cs b/src/NzbDrone.Common/TPL/Debouncer.cs index f10d4bc4d..555f161bb 100644 --- a/src/NzbDrone.Common/TPL/Debouncer.cs +++ b/src/NzbDrone.Common/TPL/Debouncer.cs @@ -4,12 +4,12 @@ namespace NzbDrone.Common.TPL { public class Debouncer { - private readonly Action _action; - private readonly System.Timers.Timer _timer; - private readonly bool _executeRestartsTimer; + protected readonly Action _action; + protected readonly System.Timers.Timer _timer; + protected readonly bool _executeRestartsTimer; - private volatile int _paused; - private volatile bool _triggered; + protected volatile int _paused; + protected volatile bool _triggered; public Debouncer(Action action, TimeSpan debounceDuration, bool executeRestartsTimer = false) { @@ -29,11 +29,12 @@ namespace NzbDrone.Common.TPL } } - public void Execute() + public virtual void Execute() { lock (_timer) { _triggered = true; + if (_executeRestartsTimer) { _timer.Stop(); @@ -46,7 +47,7 @@ namespace NzbDrone.Common.TPL } } - public void Pause() + public virtual void Pause() { lock (_timer) { @@ -55,7 +56,7 @@ namespace NzbDrone.Common.TPL } } - public void Resume() + public virtual void Resume() { lock (_timer) { diff --git a/src/NzbDrone.Core.Test/HealthCheck/HealthCheckServiceFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/HealthCheckServiceFixture.cs index 74802a39e..4ec0860ea 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/HealthCheckServiceFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/HealthCheckServiceFixture.cs @@ -1,10 +1,14 @@ +using System; using System.Collections.Generic; using FluentAssertions; +using Moq; using NUnit.Framework; using NzbDrone.Common.Cache; using NzbDrone.Common.Messaging; +using NzbDrone.Common.TPL; using NzbDrone.Core.HealthCheck; using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.HealthCheck { @@ -19,10 +23,10 @@ namespace NzbDrone.Core.Test.HealthCheck Mocker.SetConstant>(new[] { _healthCheck }); Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); - Mocker.GetMock() - .Setup(v => v.GetServerChecks()) - .Returns(new List()); + Mocker.GetMock().Setup(s => s.CreateDebouncer(It.IsAny(), It.IsAny())) + .Returns((a, t) => new MockDebouncer(a, t)); } [Test] diff --git a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs index 13546bfd7..03b872676 100644 --- a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs +++ b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs @@ -1,11 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; -using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Messaging; using NzbDrone.Common.Reflection; +using NzbDrone.Common.TPL; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; @@ -27,30 +27,27 @@ namespace NzbDrone.Core.HealthCheck private readonly IProvideHealthCheck[] _startupHealthChecks; private readonly IProvideHealthCheck[] _scheduledHealthChecks; private readonly Dictionary _eventDrivenHealthChecks; - private readonly IServerSideNotificationService _serverSideNotificationService; private readonly IEventAggregator _eventAggregator; - private readonly ICacheManager _cacheManager; - private readonly Logger _logger; private readonly ICached _healthCheckResults; + private readonly HashSet _pendingHealthChecks; + private readonly Debouncer _debounce; private bool _hasRunHealthChecksAfterGracePeriod; private bool _isRunningHealthChecksAfterGracePeriod; public HealthCheckService(IEnumerable healthChecks, - IServerSideNotificationService serverSideNotificationService, IEventAggregator eventAggregator, ICacheManager cacheManager, - IRuntimeInfo runtimeInfo, - Logger logger) + IDebounceManager debounceManager, + IRuntimeInfo runtimeInfo) { _healthChecks = healthChecks.ToArray(); - _serverSideNotificationService = serverSideNotificationService; _eventAggregator = eventAggregator; - _cacheManager = cacheManager; - _logger = logger; - _healthCheckResults = _cacheManager.GetCache(GetType()); + _healthCheckResults = cacheManager.GetCache(GetType()); + _pendingHealthChecks = new HashSet(); + _debounce = debounceManager.CreateDebouncer(ProcessHealthChecks, TimeSpan.FromSeconds(5)); _startupHealthChecks = _healthChecks.Where(v => v.CheckOnStartup).ToArray(); _scheduledHealthChecks = _healthChecks.Where(v => v.CheckOnSchedule).ToArray(); @@ -77,49 +74,50 @@ namespace NzbDrone.Core.HealthCheck .ToDictionary(g => g.Key, g => g.ToArray()); } - private void PerformHealthCheck(IProvideHealthCheck[] healthChecks, IEvent message = null, bool performServerChecks = false) + private void ProcessHealthChecks() { - var results = new List(); + List healthChecks; - foreach (var healthCheck in healthChecks) + lock (_pendingHealthChecks) { - if (healthCheck is IProvideHealthCheckWithMessage && message != null) - { - results.Add(((IProvideHealthCheckWithMessage)healthCheck).Check(message)); - } - else - { - results.Add(healthCheck.Check()); - } + healthChecks = _pendingHealthChecks.ToList(); + _pendingHealthChecks.Clear(); } - if (performServerChecks) + _debounce.Pause(); + + try { - results.AddRange(_serverSideNotificationService.GetServerChecks()); + var results = healthChecks.Select(c => c.Check()) + .ToList(); + + foreach (var result in results) + { + if (result.Type == HealthCheckResult.Ok) + { + var previous = _healthCheckResults.Find(result.Source.Name); + + if (previous != null) + { + _eventAggregator.PublishEvent(new HealthCheckRestoredEvent(previous, !_hasRunHealthChecksAfterGracePeriod)); + } + + _healthCheckResults.Remove(result.Source.Name); + } + else + { + if (_healthCheckResults.Find(result.Source.Name) == null) + { + _eventAggregator.PublishEvent(new HealthCheckFailedEvent(result, !_hasRunHealthChecksAfterGracePeriod)); + } + + _healthCheckResults.Set(result.Source.Name, result); + } + } } - - foreach (var result in results) + finally { - if (result.Type == HealthCheckResult.Ok) - { - var previous = _healthCheckResults.Find(result.Source.Name); - - if (previous != null) - { - _eventAggregator.PublishEvent(new HealthCheckRestoredEvent(previous, !_hasRunHealthChecksAfterGracePeriod)); - } - - _healthCheckResults.Remove(result.Source.Name); - } - else - { - if (_healthCheckResults.Find(result.Source.Name) == null) - { - _eventAggregator.PublishEvent(new HealthCheckFailedEvent(result, !_hasRunHealthChecksAfterGracePeriod)); - } - - _healthCheckResults.Set(result.Source.Name, result); - } + _debounce.Resume(); } _eventAggregator.PublishEvent(new HealthCheckCompleteEvent()); @@ -127,24 +125,35 @@ namespace NzbDrone.Core.HealthCheck public void Execute(CheckHealthCommand message) { - if (message.Trigger == CommandTrigger.Manual) + var healthChecks = message.Trigger == CommandTrigger.Manual ? _healthChecks : _scheduledHealthChecks; + + lock (_pendingHealthChecks) { - PerformHealthCheck(_healthChecks, null, true); - } - else - { - PerformHealthCheck(_scheduledHealthChecks, null, true); + foreach (var healthCheck in healthChecks) + { + _pendingHealthChecks.Add(healthCheck); + } } + + ProcessHealthChecks(); } public void HandleAsync(ApplicationStartedEvent message) { - PerformHealthCheck(_startupHealthChecks, null, true); + lock (_pendingHealthChecks) + { + foreach (var healthCheck in _startupHealthChecks) + { + _pendingHealthChecks.Add(healthCheck); + } + } + + ProcessHealthChecks(); } public void HandleAsync(IEvent message) { - if (message is HealthCheckCompleteEvent) + if (message is HealthCheckCompleteEvent || message is ApplicationStartedEvent) { return; } @@ -155,7 +164,16 @@ namespace NzbDrone.Core.HealthCheck { _isRunningHealthChecksAfterGracePeriod = true; - PerformHealthCheck(_startupHealthChecks); + lock (_pendingHealthChecks) + { + foreach (var healthCheck in _startupHealthChecks) + { + _pendingHealthChecks.Add(healthCheck); + } + } + + // Call it directly so it's not debounced and any alerts can be sent. + ProcessHealthChecks(); // Update after running health checks so new failure notifications aren't sent 2x. _hasRunHealthChecksAfterGracePeriod = true; @@ -191,8 +209,12 @@ namespace NzbDrone.Core.HealthCheck } } - // TODO: Add debounce - PerformHealthCheck(filteredChecks.ToArray(), message); + lock (_pendingHealthChecks) + { + filteredChecks.ForEach(h => _pendingHealthChecks.Add(h)); + } + + _debounce.Execute(); } } } diff --git a/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs b/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs index 46e7048ef..d71f2653f 100644 --- a/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs @@ -1,5 +1,3 @@ -using NzbDrone.Common.Messaging; - namespace NzbDrone.Core.HealthCheck { public interface IProvideHealthCheck @@ -8,9 +6,4 @@ namespace NzbDrone.Core.HealthCheck bool CheckOnStartup { get; } bool CheckOnSchedule { get; } } - - public interface IProvideHealthCheckWithMessage : IProvideHealthCheck - { - HealthCheck Check(IEvent message); - } } diff --git a/src/NzbDrone.Core/HealthCheck/ServerSideNotificationService.cs b/src/NzbDrone.Core/HealthCheck/ServerSideNotificationService.cs index 0be0557ba..e09a49812 100644 --- a/src/NzbDrone.Core/HealthCheck/ServerSideNotificationService.cs +++ b/src/NzbDrone.Core/HealthCheck/ServerSideNotificationService.cs @@ -9,63 +9,68 @@ using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Localization; namespace NzbDrone.Core.HealthCheck { - public interface IServerSideNotificationService - { - public List GetServerChecks(); - } - - public class ServerSideNotificationService : IServerSideNotificationService + public class ServerSideNotificationService : HealthCheckBase { private readonly IHttpClient _client; private readonly IConfigFileProvider _configFileProvider; - private readonly IHttpRequestBuilderFactory _cloudRequestBuilder; + private readonly ILidarrCloudRequestBuilder _cloudRequestBuilder; private readonly Logger _logger; - private readonly ICached> _cache; + private readonly ICached _cache; public ServerSideNotificationService(IHttpClient client, - IConfigFileProvider configFileProvider, ILidarrCloudRequestBuilder cloudRequestBuilder, + IConfigFileProvider configFileProvider, + ILocalizationService localizationService, ICacheManager cacheManager, Logger logger) + : base(localizationService) { _client = client; + _cloudRequestBuilder = cloudRequestBuilder; _configFileProvider = configFileProvider; - _cloudRequestBuilder = cloudRequestBuilder.Services; _logger = logger; - _cache = cacheManager.GetCache>(GetType()); + _cache = cacheManager.GetCache(GetType()); } - public List GetServerChecks() + public override HealthCheck Check() { - return _cache.Get("ServerChecks", () => RetrieveServerChecks(), TimeSpan.FromHours(2)); + return _cache.Get("ServerChecks", RetrieveServerChecks, TimeSpan.FromHours(2)); } - private List RetrieveServerChecks() + private HealthCheck RetrieveServerChecks() { - var request = _cloudRequestBuilder.Create() - .Resource("/notification") - .AddQueryParam("version", BuildInfo.Version) - .AddQueryParam("os", OsInfo.Os.ToString().ToLowerInvariant()) - .AddQueryParam("arch", RuntimeInformation.OSArchitecture) - .AddQueryParam("runtime", "netcore") - .AddQueryParam("branch", _configFileProvider.Branch) - .Build(); + var request = _cloudRequestBuilder.Services.Create() + .Resource("/notification") + .AddQueryParam("version", BuildInfo.Version) + .AddQueryParam("os", OsInfo.Os.ToString().ToLowerInvariant()) + .AddQueryParam("arch", RuntimeInformation.OSArchitecture) + .AddQueryParam("runtime", "netcore") + .AddQueryParam("branch", _configFileProvider.Branch) + .Build(); + try { - _logger.Trace("Getting server side health notifications"); + _logger.Trace("Getting notifications"); + var response = _client.Execute(request); var result = Json.Deserialize>(response.Content); - return result.Select(x => new HealthCheck(GetType(), x.Type, x.Message, x.WikiUrl)).ToList(); + + var checks = result.Select(x => new HealthCheck(GetType(), x.Type, x.Message, x.WikiUrl)).ToList(); + + // Only one health check is supported, services returns an ordered list, so use the first one + return checks.FirstOrDefault() ?? new HealthCheck(GetType()); } catch (Exception ex) { - _logger.Error(ex, "Failed to retrieve server notifications"); - return new List(); + _logger.Error(ex, "Failed to retrieve notifications"); + + return new HealthCheck(GetType()); } } } diff --git a/src/NzbDrone.Test.Common/MockDebouncer.cs b/src/NzbDrone.Test.Common/MockDebouncer.cs new file mode 100644 index 000000000..aa85135b2 --- /dev/null +++ b/src/NzbDrone.Test.Common/MockDebouncer.cs @@ -0,0 +1,21 @@ +using System; +using NzbDrone.Common.TPL; + +namespace NzbDrone.Test.Common +{ + public class MockDebouncer : Debouncer + { + public MockDebouncer(Action action, TimeSpan debounceDuration) + : base(action, debounceDuration) + { + } + + public override void Execute() + { + lock (_timer) + { + _action(); + } + } + } +} From 8453531a51a339afde0f3cefc489f2d0e6f7891e Mon Sep 17 00:00:00 2001 From: Stevie Robinson Date: Fri, 1 Sep 2023 02:47:38 +0200 Subject: [PATCH 027/820] Fixed: Fallback to English translations if invalid UI language in config (cherry picked from commit 4c7201741276eccaea2fb1f33daecc31e8b2d54e) Closes #4086 --- frontend/src/Settings/UI/UISettings.js | 7 +++++++ src/Lidarr.Api.V1/Config/UiConfigController.cs | 13 +++++++++++++ src/NzbDrone.Core/Localization/Core/en.json | 1 + .../Localization/LocalizationService.cs | 2 +- 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js index f6219406f..902b922f9 100644 --- a/frontend/src/Settings/UI/UISettings.js +++ b/frontend/src/Settings/UI/UISettings.js @@ -268,6 +268,13 @@ class UISettings extends Component { helpTextWarning={translate('UILanguageHelpTextWarning')} onChange={onInputChange} {...settings.uiLanguage} + errors={ + languages.some((language) => language.key === settings.uiLanguage.value) ? + settings.uiLanguage.errors : + [ + ...settings.uiLanguage.errors, + { message: translate('InvalidUILanguage') } + ]} /> diff --git a/src/Lidarr.Api.V1/Config/UiConfigController.cs b/src/Lidarr.Api.V1/Config/UiConfigController.cs index 9aa47004b..8a28843be 100644 --- a/src/Lidarr.Api.V1/Config/UiConfigController.cs +++ b/src/Lidarr.Api.V1/Config/UiConfigController.cs @@ -1,9 +1,11 @@ using System.Linq; using System.Reflection; +using FluentValidation; using Lidarr.Http; using Lidarr.Http.REST.Attributes; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Languages; namespace Lidarr.Api.V1.Config { @@ -16,6 +18,17 @@ namespace Lidarr.Api.V1.Config : base(configService) { _configFileProvider = configFileProvider; + SharedValidator.RuleFor(c => c.UILanguage).Custom((value, context) => + { + if (!Language.All.Any(o => o.Id == value)) + { + context.AddFailure("Invalid UI Language value"); + } + }); + + SharedValidator.RuleFor(c => c.UILanguage) + .GreaterThanOrEqualTo(1) + .WithMessage("The UI Language value cannot be less than 1"); } [RestPutById] diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 05f4a994b..40125a77e 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -480,6 +480,7 @@ "InteractiveImport": "Interactive Import", "InteractiveSearch": "Interactive Search", "Interval": "Interval", + "InvalidUILanguage": "Your UI is set to an invalid language, correct it and save your settings", "IsCutoffCutoff": "Cutoff", "IsCutoffUpgradeUntilThisQualityIsMetOrExceeded": "Upgrade until this quality is met or exceeded", "IsExpandedHideAlbums": "Hide albums", diff --git a/src/NzbDrone.Core/Localization/LocalizationService.cs b/src/NzbDrone.Core/Localization/LocalizationService.cs index 38a2c0a69..115ca3f52 100644 --- a/src/NzbDrone.Core/Localization/LocalizationService.cs +++ b/src/NzbDrone.Core/Localization/LocalizationService.cs @@ -86,7 +86,7 @@ namespace NzbDrone.Core.Localization private string GetSetLanguageFileName() { - var isoLanguage = IsoLanguages.Get((Language)_configService.UILanguage); + var isoLanguage = IsoLanguages.Get((Language)_configService.UILanguage) ?? IsoLanguages.Get(Language.English); var language = isoLanguage.TwoLetterCode; if (isoLanguage.CountryCode.IsNotNullOrWhiteSpace()) From 6a2e3c9c8482d2d0484f39dc4d996295870ca04d Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 11 Apr 2022 19:34:02 -0700 Subject: [PATCH 028/820] Fixed Plex Library Updates (cherry picked from commit bd70fa54107c225ea08da53183e2be944e730475) Closes #2757 --- .../Notifications/Plex/Server/PlexSectionItem.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs index 2bef3dce6..ddaeaf884 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs @@ -6,9 +6,10 @@ namespace NzbDrone.Core.Notifications.Plex.Server public class PlexSectionItem { [JsonProperty("ratingKey")] - public int Id { get; set; } + public string Id { get; set; } public string Title { get; set; } + public string Guid { get; set; } } public class PlexSectionResponse @@ -26,5 +27,10 @@ namespace NzbDrone.Core.Notifications.Plex.Server { [JsonProperty("_children")] public List Items { get; set; } + + public PlexSectionResponseLegacy() + { + Items = new List(); + } } } From 60a6f36031a0dadbb18e5d189d87f98e24fd143a Mon Sep 17 00:00:00 2001 From: Weblate Date: Mon, 2 Oct 2023 16:09:50 +0000 Subject: [PATCH 029/820] Multiple Translations updated by Weblate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ignore-downstream Co-authored-by: Anonymous Co-authored-by: Dimitri Co-authored-by: Havok Dan Co-authored-by: Jaspils Co-authored-by: JoroBo123 Co-authored-by: RudyBzh Co-authored-by: SKAL Co-authored-by: Stevie Robinson Co-authored-by: Weblate Co-authored-by: mr cmuc Co-authored-by: 宿命 <331874545@qq.com> Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/bg/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/de/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/fr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/it/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/nl/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pt_BR/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/zh_CN/ Translation: Servarr/Lidarr --- src/NzbDrone.Core/Localization/Core/ar.json | 1 - src/NzbDrone.Core/Localization/Core/bg.json | 6 +- src/NzbDrone.Core/Localization/Core/ca.json | 1 - src/NzbDrone.Core/Localization/Core/cs.json | 1 - src/NzbDrone.Core/Localization/Core/da.json | 1 - src/NzbDrone.Core/Localization/Core/de.json | 3 +- src/NzbDrone.Core/Localization/Core/el.json | 1 - src/NzbDrone.Core/Localization/Core/es.json | 1 - src/NzbDrone.Core/Localization/Core/fi.json | 1 - src/NzbDrone.Core/Localization/Core/fr.json | 103 ++++++++++-------- src/NzbDrone.Core/Localization/Core/he.json | 1 - src/NzbDrone.Core/Localization/Core/hi.json | 1 - src/NzbDrone.Core/Localization/Core/hu.json | 1 - src/NzbDrone.Core/Localization/Core/is.json | 1 - src/NzbDrone.Core/Localization/Core/it.json | 4 +- src/NzbDrone.Core/Localization/Core/ja.json | 1 - src/NzbDrone.Core/Localization/Core/ko.json | 1 - src/NzbDrone.Core/Localization/Core/nl.json | 13 ++- src/NzbDrone.Core/Localization/Core/pl.json | 1 - src/NzbDrone.Core/Localization/Core/pt.json | 1 - .../Localization/Core/pt_BR.json | 8 +- src/NzbDrone.Core/Localization/Core/ro.json | 1 - src/NzbDrone.Core/Localization/Core/ru.json | 1 - src/NzbDrone.Core/Localization/Core/sv.json | 1 - src/NzbDrone.Core/Localization/Core/th.json | 1 - src/NzbDrone.Core/Localization/Core/tr.json | 1 - src/NzbDrone.Core/Localization/Core/uk.json | 1 - src/NzbDrone.Core/Localization/Core/vi.json | 1 - .../Localization/Core/zh_CN.json | 8 +- 29 files changed, 82 insertions(+), 85 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/ar.json b/src/NzbDrone.Core/Localization/Core/ar.json index 61f3e7fd1..5b0d1fee3 100644 --- a/src/NzbDrone.Core/Localization/Core/ar.json +++ b/src/NzbDrone.Core/Localization/Core/ar.json @@ -290,7 +290,6 @@ "Global": "عالمي", "GoToInterp": "انتقل إلى {0}", "Grab": "إختطاف", - "GrabID": "انتزاع معرف", "GrabRelease": "انتزاع الإصدار", "GrabReleaseMessageText": "لم يتمكن Lidarr من تحديد الفيلم الذي كان هذا الإصدار من أجله. قد يتعذر على Lidarr استيراد هذا الإصدار تلقائيًا. هل تريد انتزاع \"{0}\"؟", "GrabSelected": "انتزاع المحدد", diff --git a/src/NzbDrone.Core/Localization/Core/bg.json b/src/NzbDrone.Core/Localization/Core/bg.json index 1f925c889..33d9201ca 100644 --- a/src/NzbDrone.Core/Localization/Core/bg.json +++ b/src/NzbDrone.Core/Localization/Core/bg.json @@ -211,7 +211,6 @@ "Global": "Глобален", "GoToInterp": "Отидете на {0}", "Grab": "Грабнете", - "GrabID": "Идентификатор на грабване", "GrabRelease": "Grab Release", "GrabReleaseMessageText": "Lidarr не успя да определи за кой филм е предназначено това издание. Lidarr може да не може автоматично да импортира тази версия. Искате ли да вземете „{0}“?", "GrabSelected": "Grab Selected", @@ -676,5 +675,8 @@ "RecentChanges": "Последни промени", "WhatsNew": "Какво ново?", "ConnectionLostReconnect": "Radarr ще се опита да се свърже автоматично или можете да щракнете върху презареждане по-долу.", - "AddNewArtistRootFolderHelpText": "Подпапката „{0}“ ще бъде създадена автоматично" + "AddNewArtistRootFolderHelpText": "Подпапката „{0}“ ще бъде създадена автоматично", + "AddMetadataProfile": "Добави профил на метадата", + "AddNewArtist": "Добави нов изпълнител", + "AddNewAlbum": "Добави нов албум" } diff --git a/src/NzbDrone.Core/Localization/Core/ca.json b/src/NzbDrone.Core/Localization/Core/ca.json index 7ea5e8226..1c1676a92 100644 --- a/src/NzbDrone.Core/Localization/Core/ca.json +++ b/src/NzbDrone.Core/Localization/Core/ca.json @@ -117,7 +117,6 @@ "FileDateHelpText": "Canvia la data del fitxer en importar/reescanejar", "FileManagement": "Gestió del fitxers", "GoToInterp": "Vés a {0}", - "GrabID": "Captura ID", "HasPendingChangesNoChanges": "Sense Canvis", "Hostname": "Nom d'amfitrió", "ICalLink": "Enllaç iCal`", diff --git a/src/NzbDrone.Core/Localization/Core/cs.json b/src/NzbDrone.Core/Localization/Core/cs.json index 07ca4138f..f2c2dc694 100644 --- a/src/NzbDrone.Core/Localization/Core/cs.json +++ b/src/NzbDrone.Core/Localization/Core/cs.json @@ -384,7 +384,6 @@ "ForMoreInformationOnTheIndividualIndexersClickOnTheInfoButtons": "Další informace o jednotlivých indexerech zobrazíte kliknutím na informační tlačítka.", "GoToInterp": "Přejít na {0}", "Grab": "Urvat", - "GrabID": "Chyť ID", "GrabSelected": "Chyťte vybrané", "HasPendingChangesNoChanges": "Žádné změny", "IgnoredAddresses": "Ignorované adresy", diff --git a/src/NzbDrone.Core/Localization/Core/da.json b/src/NzbDrone.Core/Localization/Core/da.json index 43bd4de33..01de3748f 100644 --- a/src/NzbDrone.Core/Localization/Core/da.json +++ b/src/NzbDrone.Core/Localization/Core/da.json @@ -204,7 +204,6 @@ "ForMoreInformationOnTheIndividualDownloadClientsClickOnTheInfoButtons": "Klik på informationsknapperne for at få flere oplysninger om de enkelte downloadklienter.", "ForMoreInformationOnTheIndividualIndexersClickOnTheInfoButtons": "Klik på info-knapperne for at få flere oplysninger om de enkelte indeksatorer.", "Grab": "Tag fat", - "GrabID": "Grab ID", "GrabRelease": "Grab Release", "NamingSettings": "Navngivningsindstillinger", "New": "Ny", diff --git a/src/NzbDrone.Core/Localization/Core/de.json b/src/NzbDrone.Core/Localization/Core/de.json index 81b0cddf9..d609cb213 100644 --- a/src/NzbDrone.Core/Localization/Core/de.json +++ b/src/NzbDrone.Core/Localization/Core/de.json @@ -177,7 +177,6 @@ "ProxyPasswordHelpText": "Nur wenn ein Benutzername und Passwort erforderlich ist, muss es eingegeben werden. Ansonsten leer lassen.", "ProxyType": "Proxy Typ", "Grab": "Erfassen", - "GrabID": "Erfass ID", "PublishedDate": "Veröffentlichungs Datum", "Quality": "Qualität", "RemoveFromBlocklist": "Aus der Sperrliste entfernen", @@ -300,7 +299,7 @@ "ShowMonitored": "Beobachtete anzeigen", "ShowMonitoredHelpText": "Beobachtungsstatus unter dem Plakat anzeigen", "Size": " Größe", - "SkipFreeSpaceCheck": "Pürfung des freien Speichers überspringen", + "SkipFreeSpaceCheck": "Prüfung des freien Speichers überspringen", "ICalHttpUrlHelpText": "Füge diese URL in deinen Client ein oder klicke auf abonnieren wenn dein Browser Webcal untertützt", "ICalLink": "iCal Link", "IconForCutoffUnmet": "Symbol für Schwelle nicht erreicht", diff --git a/src/NzbDrone.Core/Localization/Core/el.json b/src/NzbDrone.Core/Localization/Core/el.json index 5eb0d056d..86275a99f 100644 --- a/src/NzbDrone.Core/Localization/Core/el.json +++ b/src/NzbDrone.Core/Localization/Core/el.json @@ -275,7 +275,6 @@ "Global": "Παγκόσμια", "GoToInterp": "Μετάβαση στο {0}", "Grab": "Αρπάζω", - "GrabID": "Πιάσε ταυτότητα", "GrabRelease": "Πιάσε την απελευθέρωση", "GrabReleaseMessageText": "Ο Lidarr δεν μπόρεσε να προσδιορίσει ποια ταινία ήταν αυτή η κυκλοφορία. Το Lidarr ενδέχεται να μην μπορεί να εισαγάγει αυτόματα αυτήν την κυκλοφορία. Θέλετε να τραβήξετε το \"{0}\";", "GrabSelected": "Επιλογή αρπαγής", diff --git a/src/NzbDrone.Core/Localization/Core/es.json b/src/NzbDrone.Core/Localization/Core/es.json index 7a22d872e..c85ad220a 100644 --- a/src/NzbDrone.Core/Localization/Core/es.json +++ b/src/NzbDrone.Core/Localization/Core/es.json @@ -52,7 +52,6 @@ "Files": "Archivos", "ForMoreInformationOnTheIndividualDownloadClientsClickOnTheInfoButtons": "Para más información individual de los gestores de descarga, haz clic en los botones de información.", "ForMoreInformationOnTheIndividualListsClickOnTheInfoButtons": "Para más información individual sobre las listas de importación, haz clic en los botones de información.", - "GrabID": "Capturar ID", "IgnoredHelpText": "Este lanzamiento será rechazado si contiene uno ó más de estos términos (mayúsculas ó minúsculas)", "IllRestartLater": "Lo reiniciaré más tarde", "LidarrSupportsAnyIndexerThatUsesTheNewznabStandardAsWellAsOtherIndexersListedBelow": "Lidarr soporta cualquier indexer que utilice el estandar Newznab, como también cualquiera de los indexers listados debajo.", diff --git a/src/NzbDrone.Core/Localization/Core/fi.json b/src/NzbDrone.Core/Localization/Core/fi.json index 9baa3260e..abd50e8ad 100644 --- a/src/NzbDrone.Core/Localization/Core/fi.json +++ b/src/NzbDrone.Core/Localization/Core/fi.json @@ -273,7 +273,6 @@ "Fixed": "Korjattu", "GoToInterp": "Siirry kohteeseen '{0}'", "Grab": "Sieppaa", - "GrabID": "Sieppauksen ID", "GrabRelease": "Sieppaa julkaisu", "ICalHttpUrlHelpText": "Kopioi URL-osoite kalenteripalveluusi/-sovellukseesi tai tilaa painamalla, jos selaimesi tukee Webcal-osoitteita.", "IgnoredAddresses": "Ohitetut osoitteet", diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index 2c9e5a451..e295b1f97 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -1,17 +1,17 @@ { "Language": "Langue", - "UILanguage": "UI Langue", + "UILanguage": "Langue de l'IU", "AppDataDirectory": "Dossier AppData", - "AddingTag": "Ajouter un tag", + "AddingTag": "Ajout d'une étiquette", "Analytics": "Statistiques", "About": "À propos", - "ApplyTags": "Appliquer les tags", + "ApplyTags": "Appliquer les étiquettes", "Authentication": "Authentification", "Automatic": "Automatique", "BackupNow": "Sauvegarder maintenant", "Backups": "Sauvegardes", "BindAddress": "Adresse de liaison", - "BindAddressHelpText": "Adresse IP valide, localhost ou '*' pour toutes les interfaces", + "BindAddressHelpText": "Adresse IP valide, localhost ou « * » pour toutes les interfaces", "Branch": "Branche", "BypassProxyForLocalAddresses": "Contourner le proxy pour les adresses locales", "Cancel": "Annuler", @@ -76,20 +76,20 @@ "DelayProfiles": "Profils de retard", "Delete": "Supprimer", "DeleteBackup": "Supprimer la sauvegarde", - "DeleteBackupMessageText": "Êtes-vous sûr de vouloir supprimer la sauvegarde '{0}' ?", + "DeleteBackupMessageText": "Voulez-vous supprimer la sauvegarde « {name} » ?", "DeleteDelayProfile": "Supprimer le profil de délai", "DeleteDelayProfileMessageText": "Êtes vous sûr de vouloir effacer ce profil de délai ?", "DeleteDownloadClient": "Supprimer le client de téléchargement", - "DeleteDownloadClientMessageText": "Êtes-vous sûr de vouloir supprimer le client de téléchargement '{0}' ?", + "DeleteDownloadClientMessageText": "Voulez-vous supprimer le client de téléchargement « {name} » ?", "DeleteEmptyFolders": "Supprimer les dossiers vides", "DeleteImportListExclusion": "Supprimer les exclusions de liste d'imports", "DeleteImportListExclusionMessageText": "Êtes vous sûr de vouloir effacer cette exclusion de liste d'imports ?", "DeleteImportListMessageText": "Voulez-vous vraiment supprimer la liste '{0}' ?", "DeleteIndexer": "Supprimer l'indexeur", - "DeleteIndexerMessageText": "Voulez-vous vraiment supprimer l'indexeur '{0}' ?", + "DeleteIndexerMessageText": "Voulez-vous vraiment supprimer l'indexeur « {name} » ?", "DeleteMetadataProfileMessageText": "Voulez-vous vraiment supprimer le profil de qualité {0}", "DeleteNotification": "Supprimer la notification", - "DeleteNotificationMessageText": "Êtes-vous sûr de vouloir supprimer la notification '{0}' ?", + "DeleteNotificationMessageText": "Voulez-vous supprimer la notification « {name} » ?", "DeleteQualityProfile": "Supprimer le profil qualité", "DeleteQualityProfileMessageText": "Voulez-vous vraiment supprimer le profil de qualité {0}", "DeleteReleaseProfile": "Supprimer le profil de délai", @@ -105,7 +105,7 @@ "DetailedProgressBarHelpText": "Afficher le texte sur la barre de progression", "DiskSpace": "Espace disque", "DownloadClient": "Client de téléchargement", - "DownloadClients": "Clients télécharg.", + "DownloadClients": "Clients de télécharg.", "DownloadClientSettings": "Réglages Clients de téléchargement", "DownloadFailedCheckDownloadClientForMoreDetails": "Téléchargement échoué : voir le client de téléchargement pour plus de détails", "DownloadFailedInterp": "Échec du téléchargement : {0}", @@ -144,7 +144,6 @@ "Global": "Global", "GoToInterp": "Aller à {0}", "Grab": "Attraper", - "GrabID": "ID du grab", "GrabRelease": "Télécharger la version", "GrabReleaseMessageText": "Lidarr n'a pas été en mesure de déterminer à quel film cette version était destinée. Lidarr peut être incapable d'importer automatiquement cette version. Voulez-vous récupérer '{0}' ?", "GrabSelected": "Saisir la sélection", @@ -237,7 +236,7 @@ "Permissions": "Autorisations", "Port": "Port", "PortNumber": "Numéro de port", - "PosterSize": "Taille des posters", + "PosterSize": "Taille des affiches", "PreviewRename": "Aperçu Renommage", "PriorityHelpText": "Priorité de l'indexeur de 1 (la plus élevée) à 50 (la plus basse). Par défaut: 25.", "Profiles": "Profils", @@ -252,9 +251,9 @@ "ProxyUsernameHelpText": "Il vous suffit de saisir un nom d'utilisateur et un mot de passe si vous en avez besoin. Sinon, laissez-les vides.", "PublishedDate": "Date de publication", "Quality": "Qualité", - "QualityDefinitions": "Définitions qualité", + "QualityDefinitions": "Définitions des qualités", "QualityProfile": "Profil de qualité", - "QualityProfiles": "Profils qualité", + "QualityProfiles": "Profils de qualité", "QualitySettings": "Paramètres Qualité", "Queue": "File d'attente", "ReadTheWikiForMoreInformation": "Consultez le Wiki pour plus d'informations", @@ -278,7 +277,7 @@ "RemotePath": "Dossier distant", "RemotePathHelpText": "Chemin racine du dossier auquel le client de téléchargement accède", "RemotePathMappings": "Mappages de chemins distants", - "Remove": "Enlever", + "Remove": "Retirer", "RemoveCompletedDownloadsHelpText": "Supprimer les téléchargements importés de l'historique du client de téléchargement", "RemovedFromTaskQueue": "Supprimé de la file d'attente des tâches", "RemoveFailedDownloadsHelpText": "Supprimer les téléchargements ayant échoué de l'historique du client de téléchargement", @@ -301,7 +300,7 @@ "RescanArtistFolderAfterRefresh": "Réanalyser le dossier de films après l'actualisation", "Reset": "Réinitialiser", "ResetAPIKey": "Réinitialiser la clé API", - "ResetAPIKeyMessageText": "Êtes vous sûr de vouloir réinitialiser votre Clé d'API ?", + "ResetAPIKeyMessageText": "Voulez-vous réinitialiser votre clé d'API ?", "Restart": "Redémarrer", "RetentionHelpText": "Usenet uniquement: définir sur zéro pour une rétention illimitée", "RetryingDownloadInterp": "Nouvelle tentative de téléchargement {0} à {1}", @@ -313,11 +312,11 @@ "ShownAboveEachColumnWhenWeekIsTheActiveView": "Affiché au dessus de chaque colonne quand \"Semaine\" est l'affichage actif", "ShowPath": "Afficher le chemin", "ShowQualityProfile": "Afficher le profil de qualité", - "ShowQualityProfileHelpText": "Afficher le profil de qualité sous l'affiche", + "ShowQualityProfileHelpText": "Affiche le profil de qualité sous l'affiche", "ShowRelativeDates": "Afficher les dates relatives", "ShowRelativeDatesHelpText": "Afficher les dates relatives (Aujourd'hui/ Hier/ etc) ou absolues", "ShowSearch": "Afficher la recherche", - "ShowSearchActionHelpText": "Afficher le bouton de recherche au survol de la souris", + "ShowSearchActionHelpText": "Afficher le bouton de recherche au survol du curseur", "ShowSizeOnDisk": "Afficher la taille sur le disque", "ShowUnknownArtistItems": "Afficher les éléments de film inconnus", "SSLCertPassword": "Mot de passe du certificat SSL", @@ -330,7 +329,7 @@ "SslPortHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "StandardTrackFormat": "Format de film standard", "StartTypingOrSelectAPathBelow": "Commencer à taper ou sélectionner un chemin ci-dessous", - "Status": "Statut", + "Status": "État", "Style": "Style", "SuccessMyWorkIsDoneNoFilesToRename": "Victoire ! Mon travail est terminé, aucun fichier à renommer.", "SuccessMyWorkIsDoneNoFilesToRetag": "Victoire ! Mon travail est terminé, aucun fichier à renommer.", @@ -338,8 +337,8 @@ "SupportsSearchvalueSearchIsNotSupportedWithThisIndexer": "La recherche n'est pas prise en charge avec cet indexeur", "SupportsSearchvalueWillBeUsedWhenAutomaticSearchesArePerformedViaTheUIOrByLidarr": "Sera utilisé lorsque les recherches automatiques sont effectuées via l'interface utilisateur ou par Lidarr", "SupportsSearchvalueWillBeUsedWhenInteractiveSearchIsUsed": "Sera utilisé lorsque la recherche interactive est utilisée", - "TagIsNotUsedAndCanBeDeleted": "La balise n'est pas utilisée et peut être supprimée", - "Tags": "Tags", + "TagIsNotUsedAndCanBeDeleted": "L'étiquette n'est pas utilisée et peut être supprimée", + "Tags": "Étiquettes", "Tasks": "Tâches", "TestAll": "Tout tester", "TestAllClients": "Tester tous les clients", @@ -396,7 +395,7 @@ "UnableToLoadReleaseProfiles": "Impossible de charger les profils de délai", "UnableToLoadRemotePathMappings": "Impossible de charger les mappages de chemins distants", "UnableToLoadRootFolders": "Impossible de charger les dossiers racine", - "UnableToLoadTags": "Impossible de charger les balises", + "UnableToLoadTags": "Impossible de charger les étiquettes", "UnableToLoadTheCalendar": "Impossible de charger le calendrier", "UnableToLoadUISettings": "Impossible de charger les paramètres de l'interface utilisateur", "Ungroup": "Dissocier", @@ -432,7 +431,7 @@ "AnalyticsEnabledHelpText": "Envoyer des informations anonymes sur l'utilisation et les erreurs vers les serveurs de Lidarr. Cela inclut des informations sur votre navigateur, quelle page Lidarr WebUI vous utilisez, les rapports d'erreur ainsi que le système d'exploitation et sa version. Nous utiliserons ces informations pour prioriser les nouvelles fonctionnalités et les corrections de bugs.", "AnalyticsEnabledHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "ArtistAlbumClickToChangeTrack": "Cliquer pour changer le film", - "AuthenticationMethodHelpText": "Requière un identifiant et un mot de passe pour accéder à Lidarr", + "AuthenticationMethodHelpText": "Exiger un identifiant et un mot de passe pour accéder à Lidarr", "AutoRedownloadFailedHelpText": "Recherche automatique et tentative de téléchargement d'une version différente", "BackupFolderHelpText": "Les chemins relatifs pointeront sous le repertoire AppData de Lidarr", "BindAddressHelpTextWarning": "Nécessite un redémarrage pour prendre effet", @@ -454,7 +453,7 @@ "ShowCutoffUnmetIconHelpText": "Afficher l'icône des fichiers lorsque la limite n'a pas été atteinte", "ShowDateAdded": "Afficher la date d'ajout", "ShowMonitored": "Afficher les éléments surveillés", - "ShowMonitoredHelpText": "Afficher le statut surveillé sous l'affiche", + "ShowMonitoredHelpText": "Affiche le statut surveillé sous l'affiche", "Size": " Taille", "SkipFreeSpaceCheck": "Ignorer la vérification de l'espace libre", "SkipFreeSpaceCheckWhenImportingHelpText": "À utiliser lorsque Lidarr ne parvient pas à détecter l'espace libre dans le dossier racine de votre film", @@ -627,10 +626,10 @@ "UpgradesAllowed": "Mises à niveau autorisées", "Wanted": "Recherché", "Warn": "Avertissement", - "WouldYouLikeToRestoreBackup": "Souhaitez-vous restaurer la sauvegarde {0} ?", + "WouldYouLikeToRestoreBackup": "Souhaitez-vous restaurer la sauvegarde « {name} » ?", "Added": "Ajouté", "ApplicationURL": "URL de l'application", - "ApplicationUrlHelpText": "URL externe de cette application, y compris http(s)://, le port ainsi que la base de URL", + "ApplicationUrlHelpText": "L'URL externe de cette application, y compris http(s)://, le port ainsi que la base de URL", "Apply": "Appliquer", "Error": "Erreur", "Events": "Événements", @@ -638,7 +637,7 @@ "NextExecution": "Prochaine exécution", "QualityLimitsHelpText": "Les limites sont automatiquement ajustées pour l'exécution du film.", "Import": "Importer", - "NoTagsHaveBeenAddedYet": "Aucune identification n'a été ajoutée pour l'instant", + "NoTagsHaveBeenAddedYet": "Aucune étiquette n'a encore été ajoutée", "Ok": "OK", "AddDelayProfile": "Ajouter un profil de délai", "AddImportListExclusion": "Ajouter une exclusion à la liste des importations", @@ -685,7 +684,7 @@ "Customformat": "Format Personnalisé", "CutoffFormatScoreHelpText": "Quand ce score de format personnalisé est atteint, Lidarr ne téléchargera plus de films", "DeleteCustomFormat": "Supprimer le format personnalisé", - "DeleteCustomFormatMessageText": "Voulez-vous vraiment supprimer l'indexeur '{0}' ?", + "DeleteCustomFormatMessageText": "Voulez-vous vraiment supprimer le format personnalisé « {name} » ?", "DeleteFormatMessageText": "Êtes-vous sûr de vouloir supprimer le tag {0} ?", "DownloadPropersAndRepacksHelpTextWarning": "Utiliser les mots préférés pour les mises à niveau automatiques des propres/repacks", "IncludeCustomFormatWhenRenamingHelpText": "Inclus dans {Custom Formats} renommer le format", @@ -713,7 +712,7 @@ "ShownClickToHide": "Montré, cliquez pour masquer", "ApiKeyValidationHealthCheckMessage": "Veuillez mettre à jour votre clé API pour qu'elle contienne au moins {0} caractères. Vous pouvez le faire via les paramètres ou le fichier de configuration", "AppDataLocationHealthCheckMessage": "La mise à jour ne sera pas possible afin empêcher la suppression de AppData lors de la mise à jour", - "ColonReplacement": "Remplacement pour le 'deux-points'", + "ColonReplacement": "Remplacement pour le « deux-points »", "Disabled": "Désactivé", "DownloadClientCheckDownloadingToRoot": "Le client de téléchargement {0} place les téléchargements dans le dossier racine {1}. Vous ne devez pas télécharger dans un dossier racine.", "DownloadClientCheckNoneAvailableMessage": "Aucun client de téléchargement n'est disponible", @@ -752,9 +751,9 @@ "RemotePathMappingCheckLocalWrongOSPath": "Le client de téléchargement {0} met les téléchargements dans {1} mais il ne s'agit pas d'un chemin {2} valide. Vérifiez les paramètres de votre client de téléchargement.", "RemotePathMappingCheckRemoteDownloadClient": "Le client de téléchargement distant {0} met les téléchargements dans {1} mais ce chemin ne semble pas exister. Vérifiez vos paramètres de chemins distants.", "RemotePathMappingCheckWrongOSPath": "Le client de téléchargement distant {0} met les téléchargements dans {1} mais ce chemin {2} est invalide. Vérifiez vos paramètres de chemins distants et les paramètres de votre client de téléchargement.", - "ReplaceWithDash": "Remplacer par Dash", - "ReplaceWithSpaceDash": "Remplacer par Space Dash", - "ReplaceWithSpaceDashSpace": "Remplacer par Space Dash Space", + "ReplaceWithDash": "Remplacer par un tiret", + "ReplaceWithSpaceDash": "Remplacer par un espace puis un tiret", + "ReplaceWithSpaceDashSpace": "Remplacer par un espace, un tiret puis un espace", "RootFolderCheckMultipleMessage": "Plusieurs dossiers racine sont manquants : {0}", "RootFolderCheckSingleMessage": "Dossier racine manquant : {0}", "SystemTimeCheckMessage": "L'heure du système est décalée de plus d'un jour. Les tâches planifiées peuvent ne pas s'exécuter correctement tant que l'heure ne sera pas corrigée", @@ -773,7 +772,7 @@ "DeleteConditionMessageText": "Voulez-vous vraiment supprimer la liste '{0}' ?", "Negated": "Inversé", "RemoveSelectedItem": "Supprimer l'élément sélectionné", - "ApplyTagsHelpTextAdd": "Ajouter : Ajouter les tags à la liste de tags existantes", + "ApplyTagsHelpTextAdd": "Ajouter : ajoute les étiquettes à la liste de étiquettes existantes", "DownloadClientSortingCheckMessage": "Le client de téléchargement {0} a activé le tri {1} pour la catégorie de Lidarr. Vous devez désactiver le tri dans votre client de téléchargement pour éviter les problèmes d'importation.", "AutomaticAdd": "Ajout automatique", "CountIndexersSelected": "{0} indexeur(s) sélectionné(s)", @@ -785,12 +784,12 @@ "RemoveSelectedItems": "Supprimer les éléments sélectionnés", "Required": "Obligatoire", "ResetQualityDefinitions": "Réinitialiser les définitions de qualité", - "SetTags": "Définir Tags", + "SetTags": "Définir les étiquettes", "NoEventsFound": "Aucun événement trouvé", "QueueIsEmpty": "La file d'attente est vide", "ResetQualityDefinitionsMessageText": "Êtes-vous sûr de vouloir réinitialiser les définitions de qualité ?", - "ApplyTagsHelpTextRemove": "Suprimer : Suprime les étiquettes renseignées", - "ApplyTagsHelpTextReplace": "Remplacer : Remplace les balises par les balises saisies (ne pas saisir de balises pour effacer toutes les balises)", + "ApplyTagsHelpTextRemove": "Supprimer : supprime les étiquettes renseignées", + "ApplyTagsHelpTextReplace": "Remplacer : remplace les étiquettes par les étiquettes renseignées (ne pas renseigner d'étiquette pour toutes les effacer)", "DownloadClientTagHelpText": "Utiliser seulement cet indexeur pour les films avec au moins un tag correspondant. Laissez vide pour l'utiliser avec tous les films.", "RemoveSelectedItemBlocklistMessageText": "Êtes-vous sûr de vouloir supprimer les films sélectionnés de la liste noire ?", "RemovingTag": "Suppression du tag", @@ -800,12 +799,12 @@ "RemoveSelectedItemsQueueMessageText": "Êtes-vous sûr de vouloir supprimer {0} objet(s) de la file d'attente ?", "Yes": "Oui", "ApplyTagsHelpTextHowToApplyArtists": "Comment appliquer des étiquettes aux indexeurs sélectionnés", - "ApplyTagsHelpTextHowToApplyDownloadClients": "Comment appliquer des balises aux clients de téléchargement sélectionnés", - "DeleteSelectedDownloadClientsMessageText": "Voulez-vous vraiment supprimer l'indexeur '{0}' ?", + "ApplyTagsHelpTextHowToApplyDownloadClients": "Comment appliquer des étiquettes aux clients de téléchargement sélectionnés", + "DeleteSelectedDownloadClientsMessageText": "Voulez-vous vraiment supprimer {count} client(s) de téléchargement sélectionné(s) ?", "DeleteSelectedImportListsMessageText": "Voulez-vous vraiment supprimer l'indexeur '{0}' ?", - "DeleteSelectedIndexersMessageText": "Voulez-vous vraiment supprimer l'indexeur '{0}' ?", - "ApplyTagsHelpTextHowToApplyImportLists": "Comment appliquer des balises aux listes d'importation sélectionnées", - "ApplyTagsHelpTextHowToApplyIndexers": "Comment appliquer des tags aux indexeurs sélectionnés", + "DeleteSelectedIndexersMessageText": "Voulez-vous vraiment supprimer les {count} indexeur(s) sélectionné(s) ?", + "ApplyTagsHelpTextHowToApplyImportLists": "Comment appliquer des étiquettes aux listes d'importation sélectionnées", + "ApplyTagsHelpTextHowToApplyIndexers": "Comment appliquer des étiquettes aux indexeurs sélectionnés", "CountDownloadClientsSelected": "{0} client(s) de téléchargement sélectionné(s)", "EditSelectedDownloadClients": "Modifier les clients de téléchargement sélectionnés", "EditSelectedIndexers": "Modifier les indexeurs sélectionnés", @@ -819,26 +818,38 @@ "ImportListRootFolderMissingRootHealthCheckMessage": "Le dossier racine est manquant pour importer la/les listes : {0}", "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Plusieurs dossiers racines sont manquants pour importer les listes : {0}", "ConnectionLost": "Connexion perdue", - "ConnectionLostReconnect": "Radarr essaiera de se connecter automatiquement, ou vous pouvez cliquer sur \"Recharger\" en bas.", - "ConnectionLostToBackend": "Radarr a perdu sa connexion au backend et devra être rechargé pour fonctionner à nouveau.", + "ConnectionLostReconnect": "{appName} essaiera de se connecter automatiquement, ou vous pouvez cliquer sur \"Recharger\" en bas.", + "ConnectionLostToBackend": "{appName} a perdu sa connexion au backend et devra être rechargé pour fonctionner à nouveau.", "RecentChanges": "Changements récents", - "NotificationStatusSingleClientHealthCheckMessage": "Applications indisponibles en raison de dysfonctionnements : {0}", + "NotificationStatusSingleClientHealthCheckMessage": "Notifications indisponibles en raison de dysfonctionnements : {0}", "Priority": "Priorité", "Release": " Sorti", "WhatsNew": "Quoi de neuf ?", "EditConditionImplementation": "Ajouter une connexion - {implementationName}", "Enabled": "Activé", - "NotificationStatusAllClientHealthCheckMessage": "Toutes les applications sont indisponibles en raison de dysfonctionnements", + "NotificationStatusAllClientHealthCheckMessage": "Toutes les notifications sont indisponibles en raison de dysfonctionnements", "AddIndexerImplementation": "Ajouter un indexeur - {implementationName}", "EditConnectionImplementation": "Ajouter une connexion - {implementationName}", "EditIndexerImplementation": "Ajouter une condition - {implementationName}", "AddNewArtistRootFolderHelpText": "'{0}' le sous-dossier sera créé automatiquement", "ImportLists": "liste d'importation", "ErrorLoadingContent": "Une erreur s'est produite lors du chargement de cet élément", - "AppUpdated": "{appName} Mise à jour", + "AppUpdated": "{appName} mis à jour", "AutoAdd": "Ajout automatique", "AutomaticUpdatesDisabledDocker": "Les mises à jour automatiques ne sont pas directement prises en charge lors de l'utilisation du mécanisme de mise à jour de Docker. Vous devrez mettre à jour l'image du conteneur en dehors de {appName} ou utiliser un script", "AddImportList": "Ajouter une liste d'importation", "AddDownloadClientImplementation": "Ajouter un client de téléchargement - {implementationName}", - "AddImportListImplementation": "Ajouter une liste d'importation - {implementationName}" + "AddImportListImplementation": "Ajouter une liste d'importation - {implementationName}", + "Implementation": "Implémentation", + "AppUpdatedVersion": "{appName} a été mis à jour vers la version `{version}`, pour profiter des derniers changements, vous devrez relancer {appName}", + "Clone": "Cloner", + "NoDownloadClientsFound": "Aucun client de téléchargement n'a été trouvé", + "ManageClients": "Gérer les clients", + "NoHistoryBlocklist": "Pas d'historique de liste noire", + "ManageDownloadClients": "Gérer les clients de téléchargement", + "IndexerDownloadClientHealthCheckMessage": "Indexeurs avec des clients de téléchargement invalides : {0].", + "EditDownloadClientImplementation": "Modifier le client de téléchargement - {implementationName}", + "ListWillRefreshEveryInterp": "La liste se rafraîchira tous/toutes la/les {0}", + "DeleteRootFolder": "Supprimer le dossier racine", + "NoIndexersFound": "Aucun indexeur n'a été trouvé" } diff --git a/src/NzbDrone.Core/Localization/Core/he.json b/src/NzbDrone.Core/Localization/Core/he.json index 8ca03c0f8..c62fb7530 100644 --- a/src/NzbDrone.Core/Localization/Core/he.json +++ b/src/NzbDrone.Core/Localization/Core/he.json @@ -64,7 +64,6 @@ "Global": "גלוֹבָּלִי", "GoToInterp": "עבור אל {0}", "Grab": "לִתְפּוֹס", - "GrabID": "תעודת זהות", "GrabRelease": "שחרור תפוס", "GrabReleaseMessageText": "רדאר לא הצליח לקבוע לאיזה סרט הסרט הזה נועד. ייתכן ש- Lidarr לא תוכל לייבא גרסה זו באופן אוטומטי. האם אתה רוצה לתפוס את '{0}'?", "Group": "קְבוּצָה", diff --git a/src/NzbDrone.Core/Localization/Core/hi.json b/src/NzbDrone.Core/Localization/Core/hi.json index fb43afccb..dd017de27 100644 --- a/src/NzbDrone.Core/Localization/Core/hi.json +++ b/src/NzbDrone.Core/Localization/Core/hi.json @@ -332,7 +332,6 @@ "Global": "वैश्विक", "GoToInterp": "{0} पर जाएं", "Grab": "लपकना", - "GrabID": "पकड़ो आईडी", "GrabRelease": "पकड़ो रिलीज", "GrabSelected": "पकड़ो पकड़ो", "IgnoredPlaceHolder": "नया प्रतिबंध जोड़ें", diff --git a/src/NzbDrone.Core/Localization/Core/hu.json b/src/NzbDrone.Core/Localization/Core/hu.json index df5f6a387..e3525bec1 100644 --- a/src/NzbDrone.Core/Localization/Core/hu.json +++ b/src/NzbDrone.Core/Localization/Core/hu.json @@ -362,7 +362,6 @@ "Global": "Globális", "GoToInterp": "Ugrás ide: {0}", "Grab": "Megfog", - "GrabID": "Megfogás ID", "GrabRelease": "Release megragadása", "GrabSelected": "Kiválasztott Megfogása", "Group": "Csoport", diff --git a/src/NzbDrone.Core/Localization/Core/is.json b/src/NzbDrone.Core/Localization/Core/is.json index 9a002aa77..f137d6065 100644 --- a/src/NzbDrone.Core/Localization/Core/is.json +++ b/src/NzbDrone.Core/Localization/Core/is.json @@ -119,7 +119,6 @@ "Global": "Alheimslegt", "GoToInterp": "Farðu í {0}", "Grab": "Grípa", - "GrabID": "Grípa skilríki", "GrabReleaseMessageText": "Lidarr gat ekki ákvarðað fyrir hvaða kvikmynd þessi útgáfa var gerð. Lidarr gæti hugsanlega ekki flutt þessa útgáfu sjálfkrafa inn. Viltu grípa '{0}'?", "GrabSelected": "Grípa valið", "Group": "Hópur", diff --git a/src/NzbDrone.Core/Localization/Core/it.json b/src/NzbDrone.Core/Localization/Core/it.json index 8e8154078..3379d149e 100644 --- a/src/NzbDrone.Core/Localization/Core/it.json +++ b/src/NzbDrone.Core/Localization/Core/it.json @@ -154,7 +154,6 @@ "Global": "Globale", "GoToInterp": "Vai a {0}", "Grab": "Preleva", - "GrabID": "ID di Prelievo", "GrabRelease": "Preleva Release", "GrabReleaseMessageText": "Lidarr non è stato in grado di determinare a quale film si riferisce questa release. Lidarr potrebbe non essere in grado di importarla automaticamente. Vuoi catturare '{0}'?", "GrabSelected": "Recupera selezione", @@ -804,5 +803,6 @@ "Priority": "Priorità", "AddNewArtistRootFolderHelpText": "La sottocartella '{0}' verrà creata automaticamente", "Enabled": "Abilitato", - "NotificationStatusSingleClientHealthCheckMessage": "Applicazioni non disponibili a causa di errori: {0}" + "NotificationStatusSingleClientHealthCheckMessage": "Applicazioni non disponibili a causa di errori: {0}", + "AddImportList": "Aggiungi lista da importare" } diff --git a/src/NzbDrone.Core/Localization/Core/ja.json b/src/NzbDrone.Core/Localization/Core/ja.json index 9bcea1e8b..b256c8f3b 100644 --- a/src/NzbDrone.Core/Localization/Core/ja.json +++ b/src/NzbDrone.Core/Localization/Core/ja.json @@ -315,7 +315,6 @@ "Global": "グローバル", "GoToInterp": "{0}に移動", "Grab": "つかむ", - "GrabID": "IDを取得", "GrabRelease": "グラブリリース", "GrabReleaseMessageText": "Radarrは、このリリースの対象となる映画を特定できませんでした。 Radarrは、このリリースを自動的にインポートできない場合があります。 '{0}'を取得しますか?", "GrabSelected": "選択したグラブ", diff --git a/src/NzbDrone.Core/Localization/Core/ko.json b/src/NzbDrone.Core/Localization/Core/ko.json index dd2594ddd..0a7ac55a6 100644 --- a/src/NzbDrone.Core/Localization/Core/ko.json +++ b/src/NzbDrone.Core/Localization/Core/ko.json @@ -126,7 +126,6 @@ "Global": "글로벌", "GoToInterp": "{0}로 이동", "Grab": "붙잡다", - "GrabID": "ID 잡아", "GrabRelease": "그랩 릴리스", "GrabReleaseMessageText": "Radarr는이 릴리스의 영화를 확인할 수 없습니다. Radarr는이 릴리스를 자동으로 가져 오지 못할 수 있습니다. '{0}'을 (를) 잡으시겠습니까?", "GrabSelected": "선택한 항목 잡아", diff --git a/src/NzbDrone.Core/Localization/Core/nl.json b/src/NzbDrone.Core/Localization/Core/nl.json index c76093459..92c46de55 100644 --- a/src/NzbDrone.Core/Localization/Core/nl.json +++ b/src/NzbDrone.Core/Localization/Core/nl.json @@ -51,7 +51,7 @@ "DeleteImportListMessageText": "Bent u zeker dat u de lijst '{0}' wilt verwijderen?", "DeleteIndexer": "Verwijder Indexeerder", "DeleteIndexerMessageText": "Bent u zeker dat u de indexeerder '{0}' wilt verwijderen?", - "DeleteMetadataProfileMessageText": "Bent u zeker dat u het kwaliteitsprofiel {0} wilt verwijderen", + "DeleteMetadataProfileMessageText": "Bent u zeker dat u het kwaliteitsprofiel {name} wilt verwijderen?", "DeleteNotification": "Verwijder Notificatie", "DeleteNotificationMessageText": "Bent u zeker dat u de notificatie '{0}' wilt verwijderen?", "DeleteQualityProfile": "Verwijder Kwaliteitsprofiel", @@ -95,7 +95,6 @@ "Global": "Globaal", "GoToInterp": "Ga naar {0}", "Grab": "Ophalen", - "GrabID": "ID Ophalen", "GrabRelease": "Uitgave Ophalen", "GrabReleaseMessageText": "Lidarr was niet in staat om deze uitgave aan een film te koppelen. Lidarr zal waarschijnlijk deze uitgave niet automatisch kunnen importeren. Wilt u '{0}' ophalen?", "Group": "Groep", @@ -369,7 +368,7 @@ "Dates": "Datum en tijd", "DBMigration": "DB Migratie", "DelayProfile": "Uitstel profiel", - "DeleteQualityProfileMessageText": "Bent u zeker dat u het kwaliteitsprofiel {0} wilt verwijderen", + "DeleteQualityProfileMessageText": "Bent u zeker dat u het kwaliteitsprofiel {name} wilt verwijderen?", "DeleteReleaseProfile": "Verwijder Vertragingsprofiel", "DeleteReleaseProfileMessageText": "Weet u zeker dat u dit vertragingsprofiel wilt verwijderen?", "DeleteRootFolderMessageText": "Bent u zeker dat u de indexeerder '{0}' wilt verwijderen?", @@ -600,7 +599,7 @@ "CustomFormatScore": "Eigen Formaat Score", "MetadataProfiles": "Metadata profiel toevoegen", "MinimumCustomFormatScore": "Minimum Eigen Formaat Score", - "AddConnection": "Bewerk collectie", + "AddConnection": "Voeg connectie toe", "ClickToChangeReleaseGroup": "Klik om de releasegroep te wijzigen", "CloneCustomFormat": "Dupliceer Eigen Formaat", "Conditions": "Condities", @@ -760,5 +759,9 @@ "AddNewArtistRootFolderHelpText": "'{0}' submap zal automatisch worden aangemaakt", "NotificationStatusSingleClientHealthCheckMessage": "Applicaties onbeschikbaar door fouten", "NotificationStatusAllClientHealthCheckMessage": "Alle applicaties onbeschikbaar door fouten", - "ErrorLoadingContent": "Er ging iets fout bij het laden van dit item" + "ErrorLoadingContent": "Er ging iets fout bij het laden van dit item", + "AddConditionImplementation": "Voeg voorwaarde toe - {implementationName}", + "AddConnectionImplementation": "Voeg connectie toe - {implementationName}", + "AddDownloadClientImplementation": "Voeg Downloadclient toe - {implementationName}", + "AddIndexerImplementation": "Indexeerder toevoegen - {implementationName}" } diff --git a/src/NzbDrone.Core/Localization/Core/pl.json b/src/NzbDrone.Core/Localization/Core/pl.json index 121f5da3b..1d4fc1736 100644 --- a/src/NzbDrone.Core/Localization/Core/pl.json +++ b/src/NzbDrone.Core/Localization/Core/pl.json @@ -317,7 +317,6 @@ "Global": "Światowy", "GoToInterp": "Idź do {0}", "Grab": "Chwycić", - "GrabID": "Grab ID", "GrabRelease": "Grab Release", "GrabReleaseMessageText": "Lidarr nie był w stanie określić, dla którego filmu jest to wydanie. Lidarr może nie być w stanie automatycznie zaimportować tej wersji. Czy chcesz złapać „{0}”?", "GrabSelected": "Wybierz wybrane", diff --git a/src/NzbDrone.Core/Localization/Core/pt.json b/src/NzbDrone.Core/Localization/Core/pt.json index dda8b28e0..a2cd1be8d 100644 --- a/src/NzbDrone.Core/Localization/Core/pt.json +++ b/src/NzbDrone.Core/Localization/Core/pt.json @@ -464,7 +464,6 @@ "Global": "Global", "GoToInterp": "Ir para {0}", "Grab": "Capturar", - "GrabID": "Capturar ID", "GrabRelease": "Capturar versão", "GrabReleaseMessageText": "O Lidarr não pode determinar a que autor e livro pertence esta versão. O Lidarr pode ser incapaz de importar automaticamente esta versão. Deseja capturar \"{0}\"?", "GrabSelected": "Capturar seleção", diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index b70092406..64ac64762 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -266,7 +266,6 @@ "GoToArtistListing": "Ir para listagem do autor", "GoToInterp": "Ir para {0}", "Grab": "Obter", - "GrabID": "Obter ID", "GrabRelease": "Capturar Versão", "GrabReleaseMessageText": "O Lidarr não conseguiu determinar a qual autor e livro esse lançamento está relacionado. O Lidarr pode não conseguir importar automaticamente este lançamento. Quer obter \"{0}\"?", "GrabSelected": "Obter Selecionado", @@ -531,7 +530,7 @@ "ShowMonitoredHelpText": "Mostrar status de monitoramento sob o pôster", "Size": " Tamanho", "SkipFreeSpaceCheck": "Ignorar verificação de espaço livre", - "SkipFreeSpaceCheckWhenImportingHelpText": "Usar quando o Lidarr não conseguir detectar espaço livre na pasta raiz do filme", + "SkipFreeSpaceCheckWhenImportingHelpText": "Use quando o Lidarr não consegue detectar espaço livre em sua pasta raiz durante a importação do arquivo", "SkipRedownload": "Ignorar o Redownload", "SorryThatAlbumCannotBeFound": "Desculpe, esse filme não pode ser encontrado.", "SorryThatArtistCannotBeFound": "Desculpe, esse autor não pode ser encontrado.", @@ -1082,5 +1081,8 @@ "ImportListRootFolderMissingRootHealthCheckMessage": "Pasta raiz ausente para lista(s) de importação: {0}", "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Múltiplas pastas raiz estão faltando nas listas de importação: {0}", "PreferProtocol": "Preferir {preferredProtocol}", - "HealthMessagesInfoBox": "Você pode encontrar mais informações sobre a causa dessas mensagens de verificação de integridade clicando no link da wiki (ícone do livro) no final da linha ou verificando seus [logs]({link}). Se tiver dificuldade em interpretar essas mensagens, você pode entrar em contato com nosso suporte, nos links abaixo." + "HealthMessagesInfoBox": "Você pode encontrar mais informações sobre a causa dessas mensagens de verificação de integridade clicando no link da wiki (ícone do livro) no final da linha ou verificando seus [logs]({link}). Se tiver dificuldade em interpretar essas mensagens, você pode entrar em contato com nosso suporte, nos links abaixo.", + "DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "O cliente de download {0} está configurado para remover downloads concluídos. Isso pode resultar na remoção dos downloads do seu cliente antes que {1} possa importá-los.", + "InfoUrl": "URL da info", + "GrabId": "Obter ID" } diff --git a/src/NzbDrone.Core/Localization/Core/ro.json b/src/NzbDrone.Core/Localization/Core/ro.json index bfe0f58c7..eecf37660 100644 --- a/src/NzbDrone.Core/Localization/Core/ro.json +++ b/src/NzbDrone.Core/Localization/Core/ro.json @@ -283,7 +283,6 @@ "Global": "Global", "GoToInterp": "Accesați {0}", "Grab": "Apuca", - "GrabID": "Grab ID", "GrabRelease": "Grab Release", "GrabReleaseMessageText": "Lidarr nu a putut stabili pentru ce film a fost lansată această lansare. Este posibil ca Lidarr să nu poată importa automat această versiune. Doriți să luați „{0}”?", "GrabSelected": "Prinderea selectată", diff --git a/src/NzbDrone.Core/Localization/Core/ru.json b/src/NzbDrone.Core/Localization/Core/ru.json index 4d46631f5..13f9701c5 100644 --- a/src/NzbDrone.Core/Localization/Core/ru.json +++ b/src/NzbDrone.Core/Localization/Core/ru.json @@ -331,7 +331,6 @@ "Global": "Глобальный", "GoToInterp": "Перейти {0}", "Grab": "Захватить", - "GrabID": "Захватить ID", "GrabRelease": "Захватить релиз", "GrabReleaseMessageText": "Lidarr не смог определить для какого фильма был релиз. Lidarr не сможет автоматически его импортировать. Хотите захватить '{0}'?", "GrabSelected": "Захватить выбранные", diff --git a/src/NzbDrone.Core/Localization/Core/sv.json b/src/NzbDrone.Core/Localization/Core/sv.json index bfa357e63..e4eb26576 100644 --- a/src/NzbDrone.Core/Localization/Core/sv.json +++ b/src/NzbDrone.Core/Localization/Core/sv.json @@ -25,7 +25,6 @@ "GoToArtistListing": "Gå till artistlista", "GoToInterp": "Gå till {0}", "Grab": "Hämta", - "GrabID": "Hämta ID", "GrabRelease": "Hämta Utågva", "45MinutesFourtyFive": "45 Minuter: {0}", "60MinutesSixty": "60 Minuter: {0}", diff --git a/src/NzbDrone.Core/Localization/Core/th.json b/src/NzbDrone.Core/Localization/Core/th.json index c840ed005..d556dfed6 100644 --- a/src/NzbDrone.Core/Localization/Core/th.json +++ b/src/NzbDrone.Core/Localization/Core/th.json @@ -359,7 +359,6 @@ "Global": "ทั่วโลก", "GoToInterp": "ไปที่ {0}", "Grab": "คว้า", - "GrabID": "Grab ID", "GrabRelease": "คว้ารีลีส", "GrabReleaseMessageText": "Lidarr ไม่สามารถระบุได้ว่าภาพยนตร์เรื่องนี้เป็นภาพยนตร์เรื่องใด Lidarr อาจไม่สามารถนำเข้ารุ่นนี้โดยอัตโนมัติได้ คุณต้องการคว้า \"{0}\" ไหม", "GrabSelected": "Grab Selected", diff --git a/src/NzbDrone.Core/Localization/Core/tr.json b/src/NzbDrone.Core/Localization/Core/tr.json index 71b71d6c5..e1e3b967e 100644 --- a/src/NzbDrone.Core/Localization/Core/tr.json +++ b/src/NzbDrone.Core/Localization/Core/tr.json @@ -185,7 +185,6 @@ "Global": "Küresel", "GoToInterp": "{0} adresine gidin", "Grab": "Kapmak", - "GrabID": "Grab ID", "GrabRelease": "Bırakma", "GrabReleaseMessageText": "Lidarr, bu sürümün hangi film için olduğunu belirleyemedi. Lidarr bu sürümü otomatik olarak içe aktaramayabilir. '{0}' almak istiyor musunuz?", "GrabSelected": "Seçilenleri Kap", diff --git a/src/NzbDrone.Core/Localization/Core/uk.json b/src/NzbDrone.Core/Localization/Core/uk.json index debbe4b6e..a1124680b 100644 --- a/src/NzbDrone.Core/Localization/Core/uk.json +++ b/src/NzbDrone.Core/Localization/Core/uk.json @@ -469,7 +469,6 @@ "OnGrab": "При захопленні", "OnGrabHelpText": "При захопленні", "Grabbed": "Захоплений", - "GrabID": "Захопити ID", "GrabRelease": "Захопити реліз", "GrabSelected": "Захопити вибране", "Metadata": "Метадані", diff --git a/src/NzbDrone.Core/Localization/Core/vi.json b/src/NzbDrone.Core/Localization/Core/vi.json index 10ebb68f5..f1c04a9a3 100644 --- a/src/NzbDrone.Core/Localization/Core/vi.json +++ b/src/NzbDrone.Core/Localization/Core/vi.json @@ -369,7 +369,6 @@ "Global": "Toàn cầu", "GoToInterp": "Đi tới {0}", "Grab": "Vồ lấy", - "GrabID": "Lấy ID", "GrabRelease": "Lấy bản phát hành", "GrabReleaseMessageText": "Lidarr không thể xác định bộ phim này được phát hành. Lidarr có thể không tự động nhập bản phát hành này. Bạn có muốn lấy '{0}' không?", "GrabSelected": "Lấy đã chọn", diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json index bc2e602fb..4752d5874 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_CN.json +++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json @@ -314,7 +314,7 @@ "ShowUnknownArtistItems": "显示未知歌手条目", "Size": " 文件大小", "SkipFreeSpaceCheck": "跳过剩余空间检查", - "SkipFreeSpaceCheckWhenImportingHelpText": "当 Lidarr 无法从您的艺术家根文件夹中检测到空闲空间时使用", + "SkipFreeSpaceCheckWhenImportingHelpText": "当Lidarr在文件导入过程中无法检测到根文件夹的可用空间时使用", "SorryThatAlbumCannotBeFound": "对不起,未找到专辑。", "SorryThatArtistCannotBeFound": "对不起,未找到歌手。", "SourcePath": "来源路径", @@ -420,7 +420,6 @@ "Global": "全局", "GoToInterp": "跳转到 {0}", "Grab": "抓取", - "GrabID": "抓取ID", "GrabRelease": "抓取版本", "GrabReleaseMessageText": "Radarr无法确定这个发布版本是哪部电影,Radarr可能无法自动导入此版本,你想要获取“{0}”吗?", "Group": "组", @@ -1082,5 +1081,8 @@ "AddNewAlbum": "添加新专辑", "AddNewArtist": "添加新艺术家", "DeleteSelected": "删除所选", - "FilterArtistPlaceholder": "过滤艺术家" + "FilterArtistPlaceholder": "过滤艺术家", + "DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "下载客户端{0}设置为删除已完成的下载。这可能导致在{1}可以导入下载之前从您的客户端删除下载。", + "InfoUrl": "信息 URL", + "GrabId": "抓取ID" } From d7597755f9d49c997042caa8aa4e87e4767dfdd1 Mon Sep 17 00:00:00 2001 From: Weblate Date: Mon, 2 Oct 2023 18:12:25 +0000 Subject: [PATCH 030/820] Multiple Translations updated by Weblate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ignore-downstream Co-authored-by: Anonymous Co-authored-by: Dimitri Co-authored-by: Havok Dan Co-authored-by: Jaspils Co-authored-by: JoroBo123 Co-authored-by: RudyBzh Co-authored-by: SKAL Co-authored-by: Stevie Robinson Co-authored-by: Weblate Co-authored-by: mr cmuc Co-authored-by: 宿命 <331874545@qq.com> Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/bg/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/de/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/fr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/it/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/nl/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pt_BR/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/zh_CN/ Translation: Servarr/Lidarr --- src/NzbDrone.Core/Localization/Core/fr.json | 65 ++++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index e295b1f97..c284197b3 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -112,7 +112,7 @@ "Downloading": "Téléchargement", "DownloadPropersAndRepacksHelpTexts1": "S'il faut ou non mettre à niveau automatiquement vers Propres/Repacks", "DownloadWarningCheckDownloadClientForMoreDetails": "Avertissement téléchargement : voir le client de téléchargement pour plus de détails", - "Edit": "Éditer", + "Edit": "Modifier", "Enable": "Activer", "EnableAutomaticSearch": "Activer la recherche automatique", "EnableColorImpairedMode": "Activer le mode daltonien", @@ -148,13 +148,13 @@ "GrabReleaseMessageText": "Lidarr n'a pas été en mesure de déterminer à quel film cette version était destinée. Lidarr peut être incapable d'importer automatiquement cette version. Voulez-vous récupérer '{0}' ?", "GrabSelected": "Saisir la sélection", "Group": "Groupe", - "HasPendingChangesNoChanges": "Aucun changement", + "HasPendingChangesNoChanges": "Aucune modification", "HasPendingChangesSaveChanges": "Sauvegarder les modifications", "History": "Historique", "HostHelpText": "Le même hôte spécifié dans le Client de téléchargement à distance", "Hostname": "Nom d'hôte", "ICalFeed": "Flux iCal", - "ICalHttpUrlHelpText": "Copiez cette URL dans votre client ou cliquez pour souscrire si votre navigateur est compatible avec webcal", + "ICalHttpUrlHelpText": "Copiez cette URL dans votre/vos client(s) ou cliquez pour abonner si votre navigateur est compatible avec webcal", "ICalLink": "Lien iCal", "IconForCutoffUnmet": "Icône pour limite non atteinte", "IgnoredAddresses": "Adresses ignorées", @@ -166,7 +166,7 @@ "Importing": "Importation", "IncludeHealthWarningsHelpText": "Inclure avertissements santé", "IncludeUnknownArtistItemsHelpText": "Afficher les éléments sans film dans la file d'attente. Cela peut inclure des films supprimés ou tout autre élément de la catégorie de Lidarr", - "IncludeUnmonitored": "Inclure non surveillé", + "IncludeUnmonitored": "Inclure les non surveillés", "Indexer": "Indexeur", "IndexerPriority": "Priorité de l'indexeur", "Indexers": "Indexeurs", @@ -195,7 +195,7 @@ "MarkAsFailedMessageText": "Voulez-vous vraiment marquer '{0}' comme échoué ?", "MaximumLimits": "Limites maximales", "MaximumSize": "Taille maximum", - "MaximumSizeHelpText": "Taille maximale d'une version à saisir en Mo. Mettre à zéro pour définir sur illimité", + "MaximumSizeHelpText": "Taille maximale d'une release à récupérer en Mo. Mettre à zéro pour définir sur illimité.", "Mechanism": "Mécanisme", "MediaInfo": "Média Info", "MediaManagementSettings": "Paramètres de gestion des médias", @@ -214,7 +214,7 @@ "NamingSettings": "Paramètres dénomination", "New": "Nouveau", "NoBackupsAreAvailable": "Aucune sauvegarde n'est disponible", - "NoHistory": "Pas d'historique", + "NoHistory": "Aucun historique.", "NoLeaveIt": "Non, laisse-le", "NoLimitForAnyRuntime": "Aucune limite pour aucune durée", "NoLogFiles": "Aucun fichier journal", @@ -265,7 +265,7 @@ "RecyclingBin": "Corbeille", "RecyclingBinCleanup": "Nettoyage de la Corbeille", "Redownload": "Télécharger à nouveau", - "Refresh": "Rafraîchir", + "Refresh": "Actualiser", "RefreshInformationAndScanDisk": "Actualiser les informations et analyser le disque", "RefreshScan": "Actualiser et analyser", "ReleaseDate": "Date de sortie", @@ -291,7 +291,7 @@ "RenameTracksHelpText": "Lidarr utilisera le nom de fichier existant si le changement de nom est désactivé", "Reorder": "Réorganiser", "ReplaceIllegalCharacters": "Remplacer les caractères illégaux", - "ReplaceIllegalCharactersHelpText": "Remplacer les caractères illégaux. Si elle n'est pas cochée, Lidarr les supprimera à la place", + "ReplaceIllegalCharactersHelpText": "Remplacer les caractères illégaux. Si non coché, Lidarr les supprimera", "RequiredHelpText": "La version doit contenir au moins un de ces termes (insensible à la casse)", "RequiredPlaceHolder": "Ajouter une nouvelle restriction", "RequiresRestartToTakeEffect": "Nécessite un redémarrage pour prendre effet", @@ -328,7 +328,7 @@ "SSLPort": "Port SSL", "SslPortHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "StandardTrackFormat": "Format de film standard", - "StartTypingOrSelectAPathBelow": "Commencer à taper ou sélectionner un chemin ci-dessous", + "StartTypingOrSelectAPathBelow": "Commencer à écrire ou sélectionner un chemin ci-dessous", "Status": "État", "Style": "Style", "SuccessMyWorkIsDoneNoFilesToRename": "Victoire ! Mon travail est terminé, aucun fichier à renommer.", @@ -347,8 +347,8 @@ "ThisWillApplyToAllIndexersPleaseFollowTheRulesSetForthByThem": "Cela s'appliquera à tous les indexeurs, veuillez suivre les règles définies par eux", "Time": "Heure", "TimeFormat": "Format de l'heure", - "TorrentDelay": "Torrent Délai", - "TorrentDelayHelpText": "Délia en minutes avant de récupérer un torrent", + "TorrentDelay": "Retard du torrent", + "TorrentDelayHelpText": "Délai en minutes avant de récupérer un torrent", "Torrents": "Torrents", "TotalFileSize": "Taille totale du fichier", "Track": "Trace", @@ -365,7 +365,7 @@ "UnableToAddANewQualityProfilePleaseTryAgain": "Impossible d'ajouter un nouveau profil de qualité, veuillez réessayer.", "UnableToAddANewRemotePathMappingPleaseTryAgain": "Impossible d'ajouter un nouveau mappage de chemin distant, veuillez réessayer.", "UnableToAddANewRootFolderPleaseTryAgain": "Impossible d'ajouter un nouveau format personnalisé, veuillez réessayer.", - "UnableToLoadDelayProfiles": "Impossible de charger les profils de délai", + "UnableToLoadDelayProfiles": "Impossible de charger les profils de retard", "UnableToLoadDownloadClientOptions": "Impossible de charger les options du client de téléchargement", "UnableToLoadDownloadClients": "Impossible de charger les clients de téléchargement", "UnableToLoadGeneralSettings": "Impossible de charger les paramètres généraux", @@ -404,15 +404,15 @@ "UpdateAutomaticallyHelpText": "Télécharger et installer automatiquement les mises à jour. Vous pourrez toujours installer à partir de System : Updates", "UpdateMechanismHelpText": "Utiliser le programme de mise à jour intégré de Lidarr ou un script", "UpdateScriptPathHelpText": "Chemin vers un script personnalisé qui prend un package de mise à jour extraite et gère le reste du processus de mise à jour", - "UpgradeAllowedHelpText": "Si désactivé, la qualité ne sera pas améliorée", + "UpgradeAllowedHelpText": "Si désactivé, les qualités ne seront pas améliorées", "Uptime": "Durée de fonctionnent", "URLBase": "Base URL", "UrlBaseHelpText": "Pour la prise en charge du proxy inverse, la valeur par défaut est vide", "UrlBaseHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "UseHardlinksInsteadOfCopy": "Utiliser des liens physiques au lieu de copier", "Usenet": "Usenet", - "UsenetDelay": "Délai Usenet", - "UsenetDelayHelpText": "Délai en minutes avant de récupérer une version Usenet", + "UsenetDelay": "Retard Usenet", + "UsenetDelayHelpText": "Délai en minutes avant de récupérer une release de Usenet", "UseProxy": "Utiliser un proxy", "UserAgentProvidedByTheAppThatCalledTheAPI": "User-Agent fourni par l'application qui a appelé l'API", "UsingExternalUpdateMechanismBranchUsedByExternalUpdateMechanism": "Branche utilisée par le mécanisme de mise à jour extérieur", @@ -431,7 +431,7 @@ "AnalyticsEnabledHelpText": "Envoyer des informations anonymes sur l'utilisation et les erreurs vers les serveurs de Lidarr. Cela inclut des informations sur votre navigateur, quelle page Lidarr WebUI vous utilisez, les rapports d'erreur ainsi que le système d'exploitation et sa version. Nous utiliserons ces informations pour prioriser les nouvelles fonctionnalités et les corrections de bugs.", "AnalyticsEnabledHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "ArtistAlbumClickToChangeTrack": "Cliquer pour changer le film", - "AuthenticationMethodHelpText": "Exiger un identifiant et un mot de passe pour accéder à Lidarr", + "AuthenticationMethodHelpText": "Exiger un nom d'utilisateur et un mot de passe pour accéder à Lidarr", "AutoRedownloadFailedHelpText": "Recherche automatique et tentative de téléchargement d'une version différente", "BackupFolderHelpText": "Les chemins relatifs pointeront sous le repertoire AppData de Lidarr", "BindAddressHelpTextWarning": "Nécessite un redémarrage pour prendre effet", @@ -439,9 +439,9 @@ "Scheduled": "Programmé", "ScriptPath": "Chemin du script", "Search": "Chercher", - "SearchAll": "Rechercher tout", + "SearchAll": "Tout rechercher", "SearchForMissing": "Recherche les manquants", - "SearchSelected": "Recherche sélectionnée", + "SearchSelected": "Rechercher la sélection", "Season": "Saison", "Security": "Sécurité", "SendAnonymousUsageData": "Envoyer des données d'utilisation anonymes", @@ -529,7 +529,7 @@ "Add": "Ajouter", "AddIndexer": "Ajouter un indexeur", "AddMetadataProfile": "profil de métadonnées", - "AddNew": "Ajouter un nouveau", + "AddNew": "Ajouter une nouvelle", "AddQualityProfile": "Ajouter un profil de qualité", "AddRemotePathMapping": "Ajouter un mappage des chemins d'accès", "AddRootFolder": "Ajouter un dossier racine", @@ -568,7 +568,7 @@ "Genres": "Genres", "Grabbed": "Récupéré", "HardlinkCopyFiles": "Lier/copier les fichiers", - "HideAdvanced": "Masquer avancé", + "HideAdvanced": "Masquer param. av.", "Ignored": "Ignoré", "IndexerDownloadClientHelpText": "Spécifiez quel client de téléchargement est utilisé pour cet indexeur", "IndexerTagHelpText": "Utiliser seulement cet indexeur pour les films avec au moins un tag correspondant. Laissez vide pour l'utiliser avec tous les films.", @@ -610,7 +610,7 @@ "Select...": "Sélectionner...", "SelectFolder": "Sélectionner le dossier", "SelectQuality": "Sélectionnez la qualité", - "ShowAdvanced": "Afficher avancés", + "ShowAdvanced": "Afficher param. av.", "SizeLimit": "Limite de taille", "SizeOnDisk": "Taille sur le disque", "SourceTitle": "Titre de la source", @@ -635,7 +635,7 @@ "Events": "Événements", "Never": "Jamais", "NextExecution": "Prochaine exécution", - "QualityLimitsHelpText": "Les limites sont automatiquement ajustées pour l'exécution du film.", + "QualityLimitsHelpText": "Les limites sont automatiquement ajustées en fonction de la durée de l'album.", "Import": "Importer", "NoTagsHaveBeenAddedYet": "Aucune étiquette n'a encore été ajoutée", "Ok": "OK", @@ -647,7 +647,7 @@ "LastDuration": "Dernière durée", "QualitiesHelpText": "Les qualités placées en haut de la liste sont privilégiées même si elles ne sont pas cochées. Les qualités d'un même groupe sont égales. Seules les qualités cochées sont recherchées", "Database": "Base de données", - "DoneEditingGroups": "Finir de modifier les groupes", + "DoneEditingGroups": "Terminer la modification des groupes", "EditGroups": "Modifier les groupes", "ChooseImportMethod": "Choisir une méthode d'importation", "BypassIfAboveCustomFormatScore": "Contourner si au-dessus du score du format personnalisé", @@ -688,7 +688,7 @@ "DeleteFormatMessageText": "Êtes-vous sûr de vouloir supprimer le tag {0} ?", "DownloadPropersAndRepacksHelpTextWarning": "Utiliser les mots préférés pour les mises à niveau automatiques des propres/repacks", "IncludeCustomFormatWhenRenamingHelpText": "Inclus dans {Custom Formats} renommer le format", - "ItsEasyToAddANewArtistJustStartTypingTheNameOfTheArtistYouWantToAdd": "Il est facile d'ajouter un nouveau film, commencez simplement à taper le nom du film que vous souhaitez ajouter", + "ItsEasyToAddANewArtistJustStartTypingTheNameOfTheArtistYouWantToAdd": "C'est facile d'ajouter un nouvel artiste, commencez simplement à saisir le nom de l'artiste que vous souhaitez ajouter.", "MinFormatScoreHelpText": "Score de format personnalisé minimum autorisé à télécharger", "Monitor": "Surveiller", "PreferTorrent": "Préférez Torrent", @@ -787,7 +787,7 @@ "SetTags": "Définir les étiquettes", "NoEventsFound": "Aucun événement trouvé", "QueueIsEmpty": "La file d'attente est vide", - "ResetQualityDefinitionsMessageText": "Êtes-vous sûr de vouloir réinitialiser les définitions de qualité ?", + "ResetQualityDefinitionsMessageText": "Voulez-vous vraiment réinitialiser les définitions de qualité ?", "ApplyTagsHelpTextRemove": "Supprimer : supprime les étiquettes renseignées", "ApplyTagsHelpTextReplace": "Remplacer : remplace les étiquettes par les étiquettes renseignées (ne pas renseigner d'étiquette pour toutes les effacer)", "DownloadClientTagHelpText": "Utiliser seulement cet indexeur pour les films avec au moins un tag correspondant. Laissez vide pour l'utiliser avec tous les films.", @@ -795,8 +795,8 @@ "RemovingTag": "Suppression du tag", "ResetTitlesHelpText": "Réinitialiser les titres des définitions ainsi que les valeurs", "RemoveFromDownloadClientHelpTextWarning": "La suppression supprimera le téléchargement et le(s) fichier(s) du client de téléchargement.", - "RemoveSelectedItemQueueMessageText": "Êtes-vous sûr de vouloir désinstaller {0} objet{1} de la file d'attente ?", - "RemoveSelectedItemsQueueMessageText": "Êtes-vous sûr de vouloir supprimer {0} objet(s) de la file d'attente ?", + "RemoveSelectedItemQueueMessageText": "Voulez-vous vraiment supprimer 1 élément de la file d'attente ?", + "RemoveSelectedItemsQueueMessageText": "Voulez-vous vraiment supprimer {0} éléments de la file d'attente ?", "Yes": "Oui", "ApplyTagsHelpTextHowToApplyArtists": "Comment appliquer des étiquettes aux indexeurs sélectionnés", "ApplyTagsHelpTextHowToApplyDownloadClients": "Comment appliquer des étiquettes aux clients de téléchargement sélectionnés", @@ -818,7 +818,7 @@ "ImportListRootFolderMissingRootHealthCheckMessage": "Le dossier racine est manquant pour importer la/les listes : {0}", "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Plusieurs dossiers racines sont manquants pour importer les listes : {0}", "ConnectionLost": "Connexion perdue", - "ConnectionLostReconnect": "{appName} essaiera de se connecter automatiquement, ou vous pouvez cliquer sur \"Recharger\" en bas.", + "ConnectionLostReconnect": "{appName} essaiera de se connecter automatiquement, ou vous pouvez cliquer sur « Recharger » en bas.", "ConnectionLostToBackend": "{appName} a perdu sa connexion au backend et devra être rechargé pour fonctionner à nouveau.", "RecentChanges": "Changements récents", "NotificationStatusSingleClientHealthCheckMessage": "Notifications indisponibles en raison de dysfonctionnements : {0}", @@ -851,5 +851,12 @@ "EditDownloadClientImplementation": "Modifier le client de téléchargement - {implementationName}", "ListWillRefreshEveryInterp": "La liste se rafraîchira tous/toutes la/les {0}", "DeleteRootFolder": "Supprimer le dossier racine", - "NoIndexersFound": "Aucun indexeur n'a été trouvé" + "NoIndexersFound": "Aucun indexeur n'a été trouvé", + "SmartReplace": "Replacement intelligent", + "PreferProtocol": "{preferredProtocol} préféré", + "DeleteCondition": "Supprimer la condition", + "IsShowingMonitoredUnmonitorSelected": "Arrêter de surveiller la sélection", + "ExtraFileExtensionsHelpTexts2": "« Exemples : ».sub", + "NoMissingItems": "Aucun élément manquant", + "DashOrSpaceDashDependingOnName": "Tiret ou espace puis tiret selon le nom" } From b2b087d4276bb88c97101180e99a9f18330ddb0e Mon Sep 17 00:00:00 2001 From: Weblate Date: Mon, 2 Oct 2023 16:09:50 +0000 Subject: [PATCH 031/820] Multiple Translations updated by Weblate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ignore-downstream Co-authored-by: Anonymous Co-authored-by: Dimitri Co-authored-by: Havok Dan Co-authored-by: Jaspils Co-authored-by: JoroBo123 Co-authored-by: RudyBzh Co-authored-by: SKAL Co-authored-by: Stevie Robinson Co-authored-by: Weblate Co-authored-by: mr cmuc Co-authored-by: 宿命 <331874545@qq.com> Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/bg/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/de/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/fr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/it/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/nl/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pt_BR/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/zh_CN/ Translation: Servarr/Lidarr --- src/NzbDrone.Core/Localization/Core/fr.json | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index c284197b3..9ccfb7efc 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -328,7 +328,7 @@ "SSLPort": "Port SSL", "SslPortHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "StandardTrackFormat": "Format de film standard", - "StartTypingOrSelectAPathBelow": "Commencer à écrire ou sélectionner un chemin ci-dessous", + "StartTypingOrSelectAPathBelow": "Commencer à taper ou sélectionner un chemin ci-dessous", "Status": "État", "Style": "Style", "SuccessMyWorkIsDoneNoFilesToRename": "Victoire ! Mon travail est terminé, aucun fichier à renommer.", @@ -431,7 +431,7 @@ "AnalyticsEnabledHelpText": "Envoyer des informations anonymes sur l'utilisation et les erreurs vers les serveurs de Lidarr. Cela inclut des informations sur votre navigateur, quelle page Lidarr WebUI vous utilisez, les rapports d'erreur ainsi que le système d'exploitation et sa version. Nous utiliserons ces informations pour prioriser les nouvelles fonctionnalités et les corrections de bugs.", "AnalyticsEnabledHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "ArtistAlbumClickToChangeTrack": "Cliquer pour changer le film", - "AuthenticationMethodHelpText": "Exiger un nom d'utilisateur et un mot de passe pour accéder à Lidarr", + "AuthenticationMethodHelpText": "Exiger un identifiant et un mot de passe pour accéder à Lidarr", "AutoRedownloadFailedHelpText": "Recherche automatique et tentative de téléchargement d'une version différente", "BackupFolderHelpText": "Les chemins relatifs pointeront sous le repertoire AppData de Lidarr", "BindAddressHelpTextWarning": "Nécessite un redémarrage pour prendre effet", @@ -787,7 +787,7 @@ "SetTags": "Définir les étiquettes", "NoEventsFound": "Aucun événement trouvé", "QueueIsEmpty": "La file d'attente est vide", - "ResetQualityDefinitionsMessageText": "Voulez-vous vraiment réinitialiser les définitions de qualité ?", + "ResetQualityDefinitionsMessageText": "Êtes-vous sûr de vouloir réinitialiser les définitions de qualité ?", "ApplyTagsHelpTextRemove": "Supprimer : supprime les étiquettes renseignées", "ApplyTagsHelpTextReplace": "Remplacer : remplace les étiquettes par les étiquettes renseignées (ne pas renseigner d'étiquette pour toutes les effacer)", "DownloadClientTagHelpText": "Utiliser seulement cet indexeur pour les films avec au moins un tag correspondant. Laissez vide pour l'utiliser avec tous les films.", @@ -818,7 +818,7 @@ "ImportListRootFolderMissingRootHealthCheckMessage": "Le dossier racine est manquant pour importer la/les listes : {0}", "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Plusieurs dossiers racines sont manquants pour importer les listes : {0}", "ConnectionLost": "Connexion perdue", - "ConnectionLostReconnect": "{appName} essaiera de se connecter automatiquement, ou vous pouvez cliquer sur « Recharger » en bas.", + "ConnectionLostReconnect": "{appName} essaiera de se connecter automatiquement, ou vous pouvez cliquer sur \"Recharger\" en bas.", "ConnectionLostToBackend": "{appName} a perdu sa connexion au backend et devra être rechargé pour fonctionner à nouveau.", "RecentChanges": "Changements récents", "NotificationStatusSingleClientHealthCheckMessage": "Notifications indisponibles en raison de dysfonctionnements : {0}", @@ -851,12 +851,5 @@ "EditDownloadClientImplementation": "Modifier le client de téléchargement - {implementationName}", "ListWillRefreshEveryInterp": "La liste se rafraîchira tous/toutes la/les {0}", "DeleteRootFolder": "Supprimer le dossier racine", - "NoIndexersFound": "Aucun indexeur n'a été trouvé", - "SmartReplace": "Replacement intelligent", - "PreferProtocol": "{preferredProtocol} préféré", - "DeleteCondition": "Supprimer la condition", - "IsShowingMonitoredUnmonitorSelected": "Arrêter de surveiller la sélection", - "ExtraFileExtensionsHelpTexts2": "« Exemples : ».sub", - "NoMissingItems": "Aucun élément manquant", - "DashOrSpaceDashDependingOnName": "Tiret ou espace puis tiret selon le nom" + "NoIndexersFound": "Aucun indexeur n'a été trouvé" } From c8b786fd90d5684f44c75b0a24984fa03391c7fe Mon Sep 17 00:00:00 2001 From: Weblate Date: Mon, 2 Oct 2023 18:12:25 +0000 Subject: [PATCH 032/820] Multiple Translations updated by Weblate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ignore-downstream Co-authored-by: Anonymous Co-authored-by: Dimitri Co-authored-by: Havok Dan Co-authored-by: Jaspils Co-authored-by: JoroBo123 Co-authored-by: RudyBzh Co-authored-by: SKAL Co-authored-by: Stevie Robinson Co-authored-by: Weblate Co-authored-by: mr cmuc Co-authored-by: 宿命 <331874545@qq.com> Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/bg/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/de/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/fr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/it/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/nl/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pt_BR/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/zh_CN/ Translation: Servarr/Lidarr --- src/NzbDrone.Core/Localization/Core/fr.json | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index 9ccfb7efc..c284197b3 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -328,7 +328,7 @@ "SSLPort": "Port SSL", "SslPortHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "StandardTrackFormat": "Format de film standard", - "StartTypingOrSelectAPathBelow": "Commencer à taper ou sélectionner un chemin ci-dessous", + "StartTypingOrSelectAPathBelow": "Commencer à écrire ou sélectionner un chemin ci-dessous", "Status": "État", "Style": "Style", "SuccessMyWorkIsDoneNoFilesToRename": "Victoire ! Mon travail est terminé, aucun fichier à renommer.", @@ -431,7 +431,7 @@ "AnalyticsEnabledHelpText": "Envoyer des informations anonymes sur l'utilisation et les erreurs vers les serveurs de Lidarr. Cela inclut des informations sur votre navigateur, quelle page Lidarr WebUI vous utilisez, les rapports d'erreur ainsi que le système d'exploitation et sa version. Nous utiliserons ces informations pour prioriser les nouvelles fonctionnalités et les corrections de bugs.", "AnalyticsEnabledHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "ArtistAlbumClickToChangeTrack": "Cliquer pour changer le film", - "AuthenticationMethodHelpText": "Exiger un identifiant et un mot de passe pour accéder à Lidarr", + "AuthenticationMethodHelpText": "Exiger un nom d'utilisateur et un mot de passe pour accéder à Lidarr", "AutoRedownloadFailedHelpText": "Recherche automatique et tentative de téléchargement d'une version différente", "BackupFolderHelpText": "Les chemins relatifs pointeront sous le repertoire AppData de Lidarr", "BindAddressHelpTextWarning": "Nécessite un redémarrage pour prendre effet", @@ -787,7 +787,7 @@ "SetTags": "Définir les étiquettes", "NoEventsFound": "Aucun événement trouvé", "QueueIsEmpty": "La file d'attente est vide", - "ResetQualityDefinitionsMessageText": "Êtes-vous sûr de vouloir réinitialiser les définitions de qualité ?", + "ResetQualityDefinitionsMessageText": "Voulez-vous vraiment réinitialiser les définitions de qualité ?", "ApplyTagsHelpTextRemove": "Supprimer : supprime les étiquettes renseignées", "ApplyTagsHelpTextReplace": "Remplacer : remplace les étiquettes par les étiquettes renseignées (ne pas renseigner d'étiquette pour toutes les effacer)", "DownloadClientTagHelpText": "Utiliser seulement cet indexeur pour les films avec au moins un tag correspondant. Laissez vide pour l'utiliser avec tous les films.", @@ -818,7 +818,7 @@ "ImportListRootFolderMissingRootHealthCheckMessage": "Le dossier racine est manquant pour importer la/les listes : {0}", "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Plusieurs dossiers racines sont manquants pour importer les listes : {0}", "ConnectionLost": "Connexion perdue", - "ConnectionLostReconnect": "{appName} essaiera de se connecter automatiquement, ou vous pouvez cliquer sur \"Recharger\" en bas.", + "ConnectionLostReconnect": "{appName} essaiera de se connecter automatiquement, ou vous pouvez cliquer sur « Recharger » en bas.", "ConnectionLostToBackend": "{appName} a perdu sa connexion au backend et devra être rechargé pour fonctionner à nouveau.", "RecentChanges": "Changements récents", "NotificationStatusSingleClientHealthCheckMessage": "Notifications indisponibles en raison de dysfonctionnements : {0}", @@ -851,5 +851,12 @@ "EditDownloadClientImplementation": "Modifier le client de téléchargement - {implementationName}", "ListWillRefreshEveryInterp": "La liste se rafraîchira tous/toutes la/les {0}", "DeleteRootFolder": "Supprimer le dossier racine", - "NoIndexersFound": "Aucun indexeur n'a été trouvé" + "NoIndexersFound": "Aucun indexeur n'a été trouvé", + "SmartReplace": "Replacement intelligent", + "PreferProtocol": "{preferredProtocol} préféré", + "DeleteCondition": "Supprimer la condition", + "IsShowingMonitoredUnmonitorSelected": "Arrêter de surveiller la sélection", + "ExtraFileExtensionsHelpTexts2": "« Exemples : ».sub", + "NoMissingItems": "Aucun élément manquant", + "DashOrSpaceDashDependingOnName": "Tiret ou espace puis tiret selon le nom" } From 3108b3943143b9410bb17a855b217842522ac5c4 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sat, 7 Oct 2023 22:28:22 +0300 Subject: [PATCH 033/820] Bump version to 1.5.0 --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 5793ee2f0..2273e6381 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,7 +9,7 @@ variables: testsFolder: './_tests' yarnCacheFolder: $(Pipeline.Workspace)/.yarn nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages - majorVersion: '1.4.5' + majorVersion: '1.5.0' minorVersion: $[counter('minorVersion', 1076)] lidarrVersion: '$(majorVersion).$(minorVersion)' buildName: '$(Build.SourceBranchName).$(lidarrVersion)' From 5a1b31a641e54062a88345b0c151229a93f0568b Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sat, 7 Oct 2023 22:57:53 +0300 Subject: [PATCH 034/820] Log Notifiarr errors as warnings --- src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs b/src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs index ec89c48d0..e5f1af56b 100644 --- a/src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs +++ b/src/NzbDrone.Core/Notifications/Notifiarr/NotifiarrProxy.cs @@ -50,17 +50,17 @@ namespace NzbDrone.Core.Notifications.Notifiarr switch ((int)responseCode) { case 401: - _logger.Error("HTTP 401 - API key is invalid"); + _logger.Warn("HTTP 401 - API key is invalid"); throw new NotifiarrException("API key is invalid"); case 400: // 400 responses shouldn't be treated as an actual error because it's a misconfiguration // between Lidarr and Notifiarr for a specific event, but shouldn't stop all events. - _logger.Error("HTTP 400 - Unable to send notification. Ensure Lidarr Integration is enabled & assigned a channel on Notifiarr"); + _logger.Warn("HTTP 400 - Unable to send notification. Ensure Lidarr Integration is enabled & assigned a channel on Notifiarr"); break; case 502: case 503: case 504: - _logger.Error("Unable to send notification. Service Unavailable"); + _logger.Warn("Unable to send notification. Service Unavailable"); throw new NotifiarrException("Unable to send notification. Service Unavailable", ex); case 520: case 521: From d8f35f0943a5bd868dba9ee9c05568361129c85a Mon Sep 17 00:00:00 2001 From: Weblate Date: Sat, 7 Oct 2023 19:28:33 +0000 Subject: [PATCH 035/820] Multiple Translations updated by Weblate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ignore-downstream Co-authored-by: Havok Dan Co-authored-by: Weblate Co-authored-by: 宿命 <331874545@qq.com> Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pt_BR/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/zh_CN/ Translation: Servarr/Lidarr --- src/NzbDrone.Core/Localization/Core/pt_BR.json | 3 ++- src/NzbDrone.Core/Localization/Core/zh_CN.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index 64ac64762..23e665507 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -1084,5 +1084,6 @@ "HealthMessagesInfoBox": "Você pode encontrar mais informações sobre a causa dessas mensagens de verificação de integridade clicando no link da wiki (ícone do livro) no final da linha ou verificando seus [logs]({link}). Se tiver dificuldade em interpretar essas mensagens, você pode entrar em contato com nosso suporte, nos links abaixo.", "DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "O cliente de download {0} está configurado para remover downloads concluídos. Isso pode resultar na remoção dos downloads do seu cliente antes que {1} possa importá-los.", "InfoUrl": "URL da info", - "GrabId": "Obter ID" + "GrabId": "Obter ID", + "InvalidUILanguage": "Sua IU está configurada com um idioma inválido, corrija-a e salve suas configurações" } diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json index 4752d5874..785f20eed 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_CN.json +++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json @@ -1084,5 +1084,6 @@ "FilterArtistPlaceholder": "过滤艺术家", "DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "下载客户端{0}设置为删除已完成的下载。这可能导致在{1}可以导入下载之前从您的客户端删除下载。", "InfoUrl": "信息 URL", - "GrabId": "抓取ID" + "GrabId": "抓取ID", + "InvalidUILanguage": "您的UI设置的语言无效,请纠正它并保存设置" } From 99dade32006b51816a9f7fd25bfaa3af2624dcea Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 2 Oct 2023 18:20:35 +0300 Subject: [PATCH 036/820] Add status test all button for IndexerLongTermStatusCheck (cherry picked from commit 4ffa1816bd2305550abee20cea27e1296a99ddf6) --- frontend/src/System/Status/Health/Health.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/System/Status/Health/Health.js b/frontend/src/System/Status/Health/Health.js index c7b005ea4..7d4eaf58e 100644 --- a/frontend/src/System/Status/Health/Health.js +++ b/frontend/src/System/Status/Health/Health.js @@ -73,6 +73,7 @@ function getInternalLink(source) { function getTestLink(source, props) { switch (source) { case 'IndexerStatusCheck': + case 'IndexerLongTermStatusCheck': return ( Date: Thu, 5 Oct 2023 02:42:18 +0300 Subject: [PATCH 037/820] Prevent NullRef for cases when media covers have nullable urls (cherry picked from commit a26df9e9afa8d925c2ad62c126d4edebec7e4e54) --- src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs b/src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs index b8b0cc3a0..fa489171b 100644 --- a/src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs +++ b/src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; @@ -31,6 +32,11 @@ namespace NzbDrone.Core.MediaCover public string RegisterUrl(string url) { + if (url.IsNullOrWhiteSpace()) + { + return null; + } + var hash = url.SHA256Hash(); _cache.Set(hash, url, TimeSpan.FromHours(24)); From aca8b82ae66ade8d07a29926b241298eca197a2c Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sun, 8 Oct 2023 22:58:42 +0300 Subject: [PATCH 038/820] Fixed: Avoid logging evaluations when not using any Remote Path Mappings (cherry picked from commit 44eb729ccc13237f4439006159bd616e8bdb5750) --- .../RemotePathMappingService.cs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingService.cs b/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingService.cs index 5e033b582..b757db3f3 100644 --- a/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingService.cs +++ b/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingService.cs @@ -127,8 +127,16 @@ namespace NzbDrone.Core.RemotePathMappings return remotePath; } + var mappings = All(); + + if (mappings.Empty()) + { + return remotePath; + } + _logger.Trace("Evaluating remote path remote mappings for match to host [{0}] and remote path [{1}]", host, remotePath.FullPath); - foreach (var mapping in All()) + + foreach (var mapping in mappings) { _logger.Trace("Checking configured remote path mapping: {0} - {1}", mapping.Host, mapping.RemotePath); if (host.Equals(mapping.Host, StringComparison.InvariantCultureIgnoreCase) && new OsPath(mapping.RemotePath).Contains(remotePath)) @@ -150,8 +158,16 @@ namespace NzbDrone.Core.RemotePathMappings return localPath; } + var mappings = All(); + + if (mappings.Empty()) + { + return localPath; + } + _logger.Trace("Evaluating remote path local mappings for match to host [{0}] and local path [{1}]", host, localPath.FullPath); - foreach (var mapping in All()) + + foreach (var mapping in mappings) { _logger.Trace("Checking configured remote path mapping {0} - {1}", mapping.Host, mapping.RemotePath); if (host.Equals(mapping.Host, StringComparison.InvariantCultureIgnoreCase) && new OsPath(mapping.LocalPath).Contains(localPath)) From cebdeec9d64a95da5e76b27e3a76d6f4363154c5 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 9 Oct 2023 21:00:05 -0700 Subject: [PATCH 039/820] Log executing health check Towards #6076 (cherry picked from commit 78b39bd2fecda60e04a1fef17ae17f62bd2b6914) --- .../HealthCheck/HealthCheckService.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs index 03b872676..a9a89dc48 100644 --- a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs +++ b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Messaging; @@ -28,6 +29,7 @@ namespace NzbDrone.Core.HealthCheck private readonly IProvideHealthCheck[] _scheduledHealthChecks; private readonly Dictionary _eventDrivenHealthChecks; private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; private readonly ICached _healthCheckResults; private readonly HashSet _pendingHealthChecks; @@ -40,10 +42,12 @@ namespace NzbDrone.Core.HealthCheck IEventAggregator eventAggregator, ICacheManager cacheManager, IDebounceManager debounceManager, - IRuntimeInfo runtimeInfo) + IRuntimeInfo runtimeInfo, + Logger logger) { _healthChecks = healthChecks.ToArray(); _eventAggregator = eventAggregator; + _logger = logger; _healthCheckResults = cacheManager.GetCache(GetType()); _pendingHealthChecks = new HashSet(); @@ -88,7 +92,14 @@ namespace NzbDrone.Core.HealthCheck try { - var results = healthChecks.Select(c => c.Check()) + var results = healthChecks.Select(c => + { + _logger.Trace("Check health -> {0}", c.GetType().Name); + var result = c.Check(); + _logger.Trace("Check health <- {0}", c.GetType().Name); + + return result; + }) .ToList(); foreach (var result in results) From 92af481d59875923599daf526194d24c199e8412 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 10 Oct 2023 11:26:37 +0300 Subject: [PATCH 040/820] Replace support-requests with label-actions --- .github/label-actions.yml | 16 +++++++++++++++ .github/workflows/label-actions.yml | 17 +++++++++++++++ .github/workflows/support.yml | 32 ----------------------------- 3 files changed, 33 insertions(+), 32 deletions(-) create mode 100644 .github/label-actions.yml create mode 100644 .github/workflows/label-actions.yml delete mode 100644 .github/workflows/support.yml diff --git a/.github/label-actions.yml b/.github/label-actions.yml new file mode 100644 index 000000000..3979401b1 --- /dev/null +++ b/.github/label-actions.yml @@ -0,0 +1,16 @@ +# Configuration for Label Actions - https://github.com/dessant/label-actions + +'Type: Support': + comment: > + :wave: @{issue-author}, we use the issue tracker exclusively + for bug reports and feature requests. However, this issue appears + to be a support request. Please hop over onto our [Discord](https://lidarr.audio/discord). + close: true + close-reason: 'not planned' + +'Status: Logs Needed': + comment: > + :wave: @{issue-author}, In order to help you further we'll need to see logs. + You'll need to enable trace logging and replicate the problem that you encountered. + Guidance on how to enable trace logging can be found in + our [troubleshooting guide](https://wiki.servarr.com/lidarr/troubleshooting#logging-and-log-files). diff --git a/.github/workflows/label-actions.yml b/.github/workflows/label-actions.yml new file mode 100644 index 000000000..a7fc89446 --- /dev/null +++ b/.github/workflows/label-actions.yml @@ -0,0 +1,17 @@ +name: 'Label Actions' + +on: + issues: + types: [labeled, unlabeled] + +permissions: + contents: read + issues: write + +jobs: + action: + runs-on: ubuntu-latest + steps: + - uses: dessant/label-actions@v3 + with: + process-only: 'issues' diff --git a/.github/workflows/support.yml b/.github/workflows/support.yml deleted file mode 100644 index cdc757378..000000000 --- a/.github/workflows/support.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: 'Support requests' - -on: - issues: - types: [labeled, unlabeled, reopened] - -jobs: - support: - runs-on: ubuntu-latest - steps: - - uses: dessant/support-requests@v3 - with: - github-token: ${{ github.token }} - support-label: 'Type: Support' - issue-comment: > - :wave: @{issue-author}, we use the issue tracker exclusively - for bug reports and feature requests. However, this issue appears - to be a support request. Please hop over onto our [Discord](https://lidarr.audio/discord). - close-issue: true - close-reason: 'not planned' - lock-issue: false - - uses: dessant/support-requests@v3 - with: - github-token: ${{ github.token }} - support-label: 'Status: Logs Needed' - issue-comment: > - :wave: @{issue-author}, In order to help you further we'll need to see logs. - You'll need to enable trace logging and replicate the problem that you encountered. - Guidance on how to enable trace logging can be found in - our [troubleshooting guide](https://wiki.servarr.com/lidarr/troubleshooting#logging-and-log-files). - close-issue: false - lock-issue: false From c94549feabdc639b81d3ca2130d98203ecde22f0 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sun, 15 Oct 2023 07:52:00 +0300 Subject: [PATCH 041/820] Bump version to 1.5.1 --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 2273e6381..16b7cfb08 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,7 +9,7 @@ variables: testsFolder: './_tests' yarnCacheFolder: $(Pipeline.Workspace)/.yarn nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages - majorVersion: '1.5.0' + majorVersion: '1.5.1' minorVersion: $[counter('minorVersion', 1076)] lidarrVersion: '$(majorVersion).$(minorVersion)' buildName: '$(Build.SourceBranchName).$(lidarrVersion)' From b388af320edf27c1e3870a872832c6c77fc3c958 Mon Sep 17 00:00:00 2001 From: Weblate Date: Sat, 14 Oct 2023 19:53:48 +0000 Subject: [PATCH 042/820] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: DavidHenryThoreau Co-authored-by: Dlgeri123 Co-authored-by: He Zhu Co-authored-by: Timo Co-authored-by: Weblate Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/de/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/fr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/hu/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/zh_CN/ Translation: Servarr/Lidarr --- src/NzbDrone.Core/Localization/Core/de.json | 4 +- src/NzbDrone.Core/Localization/Core/fr.json | 567 ++++++++++++------ src/NzbDrone.Core/Localization/Core/hu.json | 15 +- .../Localization/Core/zh_CN.json | 2 +- 4 files changed, 408 insertions(+), 180 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/de.json b/src/NzbDrone.Core/Localization/Core/de.json index d609cb213..7183b2cfe 100644 --- a/src/NzbDrone.Core/Localization/Core/de.json +++ b/src/NzbDrone.Core/Localization/Core/de.json @@ -447,7 +447,7 @@ "45MinutesFourtyFive": "45 Minuten: {0}", "60MinutesSixty": "60 Minuten: {0}", "APIKey": "API-Schlüssel", - "Absolute": "Absolut", + "Absolute": "Exakte", "AddMissing": "Fehlendes hinzufügen", "AddNewItem": "Neues Item hinzufügen", "AlbumIsDownloadingInterp": "Film lädt herunter - {0}% {1}", @@ -782,7 +782,7 @@ "Ok": "OK", "RestoreBackupAdditionalInfo": "Hinweis: Während der wiederherstellung wird Lidarr automatisch neugestartet und die Oberfläche neugelade.", "SelectFolder": "Ordner auswählen", - "AddConnection": "Sammlung bearbeiten", + "AddConnection": "Verbindung hinzufügen", "EditMetadataProfile": "Metadaten Profil", "Database": "Datenbank", "EditReleaseProfile": "Release Profile bearbeiten", diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index c284197b3..e93bee0cf 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -20,19 +20,19 @@ "CertificateValidationHelpText": "Modifier le niveau de rigueur de la validation de la certification HTTPS. Ne pas modifier si vous ne maîtrisez pas les risques.", "Actions": "Actions", "IllRestartLater": "Je redémarrerai plus tard", - "ImportExtraFiles": "Importer les fichiers extra", - "DelayProfile": "Profil de Délai", + "ImportExtraFiles": "Importer des fichiers supplémentaires", + "DelayProfile": "Profil de retard", "Docker": "Docker", "EnableAutomaticAdd": "Activer l'ajout automatique", "Host": "Hôte", - "LogFiles": "Fichiers Log", + "LogFiles": "Fichiers journaux", "ManualImport": "Importation manuelle", - "MarkAsFailed": "Marquer comme échoué", - "MetadataSettings": "Paramètres métadonnées", + "MarkAsFailed": "Marquer comme échec", + "MetadataSettings": "Paramètres des métadonnées", "MIA": "MIA", "MinimumAge": "Âge minimum", "Mode": "Mode", - "NotificationTriggers": "Déclencheurs de notification", + "NotificationTriggers": "Déclencheurs de notifications", "StartupDirectory": "Répertoire de démarrage", "Tracks": "Trace", "Type": "Type", @@ -60,7 +60,7 @@ "CloneIndexer": "Dupliqué l'indexeur", "CloneProfile": "Dupliqué le profil", "Columns": "Colonnes", - "CompletedDownloadHandling": "Gestion des téléchargements terminés", + "CompletedDownloadHandling": "Traitement du téléchargement terminé", "Component": "Composant", "Connections": "Connexions", "ConnectSettings": "Paramètres de connexion", @@ -70,43 +70,43 @@ "CreateEmptyArtistFoldersHelpText": "Créer les dossiers films manquants pendant le scan du disque", "CreateGroup": "Créer un groupe", "CutoffHelpText": "Quand cette qualité est atteinte, Lidarr ne téléchargera plus de films", - "CutoffUnmet": "Limite non satisfaite", + "CutoffUnmet": "Seuil non atteint", "Dates": "Dates", "DBMigration": "Migration de la base de données", "DelayProfiles": "Profils de retard", "Delete": "Supprimer", "DeleteBackup": "Supprimer la sauvegarde", "DeleteBackupMessageText": "Voulez-vous supprimer la sauvegarde « {name} » ?", - "DeleteDelayProfile": "Supprimer le profil de délai", - "DeleteDelayProfileMessageText": "Êtes vous sûr de vouloir effacer ce profil de délai ?", + "DeleteDelayProfile": "Supprimer le profil de retard", + "DeleteDelayProfileMessageText": "Êtes-vous sûr de vouloir supprimer ce profil de retard ?", "DeleteDownloadClient": "Supprimer le client de téléchargement", "DeleteDownloadClientMessageText": "Voulez-vous supprimer le client de téléchargement « {name} » ?", "DeleteEmptyFolders": "Supprimer les dossiers vides", - "DeleteImportListExclusion": "Supprimer les exclusions de liste d'imports", - "DeleteImportListExclusionMessageText": "Êtes vous sûr de vouloir effacer cette exclusion de liste d'imports ?", - "DeleteImportListMessageText": "Voulez-vous vraiment supprimer la liste '{0}' ?", + "DeleteImportListExclusion": "Supprimer l'exclusion de la liste d'importation", + "DeleteImportListExclusionMessageText": "Êtes-vous sûr de vouloir supprimer cette exclusion de la liste d'importation ?", + "DeleteImportListMessageText": "Êtes-vous sûr de vouloir supprimer cette exclusion de la liste d'importation ?", "DeleteIndexer": "Supprimer l'indexeur", "DeleteIndexerMessageText": "Voulez-vous vraiment supprimer l'indexeur « {name} » ?", - "DeleteMetadataProfileMessageText": "Voulez-vous vraiment supprimer le profil de qualité {0}", + "DeleteMetadataProfileMessageText": "Êtes-vous sûr de vouloir supprimer le profil de métadonnées « {name} » ?", "DeleteNotification": "Supprimer la notification", "DeleteNotificationMessageText": "Voulez-vous supprimer la notification « {name} » ?", - "DeleteQualityProfile": "Supprimer le profil qualité", - "DeleteQualityProfileMessageText": "Voulez-vous vraiment supprimer le profil de qualité {0}", - "DeleteReleaseProfile": "Supprimer le profil de délai", - "DeleteReleaseProfileMessageText": "Êtes vous sûr de vouloir effacer ce profil de délai ?", - "DeleteRootFolderMessageText": "Voulez-vous vraiment supprimer l'indexeur '{0}' ?", + "DeleteQualityProfile": "Supprimer le profil de qualité", + "DeleteQualityProfileMessageText": "Êtes-vous sûr de vouloir supprimer le profil de qualité « {name} » ?", + "DeleteReleaseProfile": "Supprimer le profil de version", + "DeleteReleaseProfileMessageText": "Êtes-vous sûr de vouloir supprimer ce profil de version ?", + "DeleteRootFolderMessageText": "Êtes-vous sûr de vouloir supprimer le dossier racine « {name} » ?", "DeleteSelectedTrackFiles": "Supprimer les fichiers film sélectionnés", "DeleteSelectedTrackFilesMessageText": "Voulez-vous vraiment supprimer les fichiers vidéo sélectionnés ?", "DeleteTag": "Supprimer le tag", "DeleteTagMessageText": "Voulez-vous vraiment supprimer la balise '{0}' ?", "DeleteTrackFileMessageText": "Voulez-vous vraiment supprimer {0} ?", - "DestinationPath": "Chemin de Destination", + "DestinationPath": "Chemin de destination", "DetailedProgressBar": "Barre de progression détaillée", "DetailedProgressBarHelpText": "Afficher le texte sur la barre de progression", "DiskSpace": "Espace disque", "DownloadClient": "Client de téléchargement", - "DownloadClients": "Clients de télécharg.", - "DownloadClientSettings": "Réglages Clients de téléchargement", + "DownloadClients": "Clients de téléchargements", + "DownloadClientSettings": "Télécharger les paramètres client", "DownloadFailedCheckDownloadClientForMoreDetails": "Téléchargement échoué : voir le client de téléchargement pour plus de détails", "DownloadFailedInterp": "Échec du téléchargement : {0}", "Downloading": "Téléchargement", @@ -115,10 +115,10 @@ "Edit": "Modifier", "Enable": "Activer", "EnableAutomaticSearch": "Activer la recherche automatique", - "EnableColorImpairedMode": "Activer le mode daltonien", - "EnableColorImpairedModeHelpText": "Style modifié pour permettre aux utilisateurs daltoniens de distinguer les codes couleurs", - "EnableCompletedDownloadHandlingHelpText": "Importer automatiquement les téléchargements terminés depuis le client de téléchargement", - "EnableHelpText": "Activer la création d'un fichier de métadonnées pour ce type de métadonnée", + "EnableColorImpairedMode": "Activer le mode de couleurs altérées", + "EnableColorImpairedModeHelpText": "Style modifié pour permettre aux utilisateurs ayant des difficultés de couleur de mieux distinguer les informations codées par couleur", + "EnableCompletedDownloadHandlingHelpText": "Importer automatiquement les téléchargements terminés à partir du client de téléchargement", + "EnableHelpText": "Activer la création de fichiers de métadonnées pour ce type de métadonnées", "EnableInteractiveSearch": "Activer la recherche interactive", "EnableRSS": "Activer le RSS", "EnableSSL": "Activer le SSL", @@ -134,17 +134,17 @@ "FileNames": "Noms de fichier", "Files": "Fichiers", "FirstDayOfWeek": "Premier jour de la semaine", - "Fixed": "Corrigé", + "Fixed": "Fixé", "Folder": "Dossier", "Folders": "Dossiers", - "ForMoreInformationOnTheIndividualDownloadClientsClickOnTheInfoButtons": "Pour plus d'informations sur les clients de téléchargement individuels, cliquez sur les boutons d'information.", + "ForMoreInformationOnTheIndividualDownloadClientsClickOnTheInfoButtons": "Pour plus d'informations sur les clients de téléchargement individuels, cliquez sur les boutons pour plus d'informations.", "ForMoreInformationOnTheIndividualIndexersClickOnTheInfoButtons": "Pour plus d'informations sur les indexeurs individuels, cliquez sur les boutons info.", "ForMoreInformationOnTheIndividualListsClickOnTheInfoButtons": "Pour plus d'informations sur les listes d'importation individuelles, cliquez sur les boutons d'information.", - "GeneralSettings": "Réglages Généraux", + "GeneralSettings": "Réglages généraux", "Global": "Global", "GoToInterp": "Aller à {0}", - "Grab": "Attraper", - "GrabRelease": "Télécharger la version", + "Grab": "Saisir", + "GrabRelease": "Saisir Release", "GrabReleaseMessageText": "Lidarr n'a pas été en mesure de déterminer à quel film cette version était destinée. Lidarr peut être incapable d'importer automatiquement cette version. Voulez-vous récupérer '{0}' ?", "GrabSelected": "Saisir la sélection", "Group": "Groupe", @@ -152,11 +152,11 @@ "HasPendingChangesSaveChanges": "Sauvegarder les modifications", "History": "Historique", "HostHelpText": "Le même hôte spécifié dans le Client de téléchargement à distance", - "Hostname": "Nom d'hôte", + "Hostname": "Hostname", "ICalFeed": "Flux iCal", "ICalHttpUrlHelpText": "Copiez cette URL dans votre/vos client(s) ou cliquez pour abonner si votre navigateur est compatible avec webcal", "ICalLink": "Lien iCal", - "IconForCutoffUnmet": "Icône pour limite non atteinte", + "IconForCutoffUnmet": "Icône pour la date limite non respectée", "IgnoredAddresses": "Adresses ignorées", "IgnoredHelpText": "La version sera rejetée si elle contient au moins l'un de ces termes (insensible à la casse)", "IgnoredPlaceHolder": "Ajouter une nouvelle restriction", @@ -183,25 +183,25 @@ "LidarrSupportsAnyIndexerThatUsesTheNewznabStandardAsWellAsOtherIndexersListedBelow": "Lidarr prend en charge tout indexeur qui utilise le standard Newznab, ainsi que d'autres indexeurs répertoriés ci-dessous.", "LidarrTags": "Lidarr Tags", "LoadingTrackFilesFailed": "Le chargement des fichiers vidéo a échoué", - "Local": "Local", + "Local": "Locale", "LocalPath": "Chemin local", "LocalPathHelpText": "Chemin local que Lidarr doit utiliser pour accéder au chemin distant", "Logging": "Enregistrement", - "LogLevel": "Niveau du journal", + "LogLevel": "Niveau de journalisation", "LogLevelvalueTraceTraceLoggingShouldOnlyBeEnabledTemporarily": "La journalisation des traces ne doit être activée que temporairement", "Logs": "Journaux", - "LongDateFormat": "Format de date long", - "MaintenanceRelease": "Version de maintenance", + "LongDateFormat": "Format de date longue", + "MaintenanceRelease": "Version de maintenance : corrections de bugs et autres améliorations. Voir l'historique des validations Github pour plus de détails", "MarkAsFailedMessageText": "Voulez-vous vraiment marquer '{0}' comme échoué ?", "MaximumLimits": "Limites maximales", "MaximumSize": "Taille maximum", "MaximumSizeHelpText": "Taille maximale d'une release à récupérer en Mo. Mettre à zéro pour définir sur illimité.", "Mechanism": "Mécanisme", - "MediaInfo": "Média Info", + "MediaInfo": "Informations médias", "MediaManagementSettings": "Paramètres de gestion des médias", "Medium": "Moyen", "Message": "Message", - "MinimumAgeHelpText": "Usenet uniquement: âge minimum en minutes des NZB avant qu'ils ne soient saisis. Utiliser ceci pour donner aux nouvelles versions le temps de se propager à votre fournisseur usenet.", + "MinimumAgeHelpText": "Usenet uniquement : âge minimum en minutes des NZB avant leur saisie. Utilisez-le pour donner aux nouvelles versions le temps de se propager à votre fournisseur Usenet.", "MinimumFreeSpace": "Espace libre minimum", "MinimumFreeSpaceWhenImportingHelpText": "Empêcher l'importation si elle laisse moins d'espace disque disponible que cette quantité", "MinimumLimits": "Limites minimales", @@ -211,14 +211,14 @@ "MustContain": "Doit contenir", "MustNotContain": "Ne doit pas contenir", "Name": "Nom", - "NamingSettings": "Paramètres dénomination", + "NamingSettings": "Paramètres de dénomination", "New": "Nouveau", "NoBackupsAreAvailable": "Aucune sauvegarde n'est disponible", "NoHistory": "Aucun historique.", - "NoLeaveIt": "Non, laisse-le", - "NoLimitForAnyRuntime": "Aucune limite pour aucune durée", + "NoLeaveIt": "Non, laisse tomber", + "NoLimitForAnyRuntime": "Aucune limite pour aucune durée d'exécution", "NoLogFiles": "Aucun fichier journal", - "NoMinimumForAnyRuntime": "Aucun minimum pour n'importe quel durée", + "NoMinimumForAnyRuntime": "Aucun minimum pour aucune durée d'exécution", "None": "Aucun", "NoUpdatesAreAvailable": "Aucune mise à jour n'est disponible", "OnGrabHelpText": "À la Récupération", @@ -228,53 +228,53 @@ "OpenBrowserOnStart": "Ouvrir le navigateur au démarrage", "Options": "Options", "Original": "Original", - "PackageVersion": "Version du package", + "PackageVersion": "Version du paquet", "PageSize": "Pagination", "PageSizeHelpText": "Nombre d'éléments à afficher sur chaque page", "Password": "Mot de passe", "Path": "Chemin", - "Permissions": "Autorisations", + "Permissions": "Permissions", "Port": "Port", "PortNumber": "Numéro de port", - "PosterSize": "Taille des affiches", - "PreviewRename": "Aperçu Renommage", - "PriorityHelpText": "Priorité de l'indexeur de 1 (la plus élevée) à 50 (la plus basse). Par défaut: 25.", - "Profiles": "Profils", - "Proper": "Proper", + "PosterSize": "Poster taille", + "PreviewRename": "Aperçu Renommer", + "PriorityHelpText": "Priorité de l'indexeur de 1 (la plus élevée) à 50 (la plus basse). Valeur par défaut : 25. Utilisé lors de la récupération des versions comme départage pour des versions par ailleurs égales, Lidarr utilisera toujours tous les indexeurs activés pour la synchronisation RSS et la recherche.", + "Profiles": "Profiles", + "Proper": "Approprié", "PropersAndRepacks": "Propres et Repacks", "Protocol": "Protocole", - "ProtocolHelpText": "Choisissez le(s) protocole(s) à utiliser et celui qui est préféré lors du choix entre des versions par ailleurs égales", + "ProtocolHelpText": "Choisissez quel(s) protocole(s) utiliser et lequel est préféré lorsque vous choisissez entre des versions par ailleurs égales", "Proxy": "Proxy", - "ProxyBypassFilterHelpText": "Utiliser ',' comme séparateur et '*.' comme caractère générique pour les sous-domaines", - "ProxyPasswordHelpText": "Il vous suffit de saisir un nom d'utilisateur et un mot de passe si vous en avez besoin. Sinon, laissez-les vides.", - "ProxyType": "Type de proxy", - "ProxyUsernameHelpText": "Il vous suffit de saisir un nom d'utilisateur et un mot de passe si vous en avez besoin. Sinon, laissez-les vides.", + "ProxyBypassFilterHelpText": "Utilisez ',' comme séparateur et '*.' comme caractère générique pour les sous-domaines", + "ProxyPasswordHelpText": "Il vous suffit de saisir un nom d'utilisateur et un mot de passe si nécessaire. Sinon, laissez-les vides.", + "ProxyType": "Type de mandataire", + "ProxyUsernameHelpText": "Il vous suffit de saisir un nom d'utilisateur et un mot de passe si nécessaire. Sinon, laissez-les vides.", "PublishedDate": "Date de publication", "Quality": "Qualité", - "QualityDefinitions": "Définitions des qualités", + "QualityDefinitions": "Définitions de la qualité", "QualityProfile": "Profil de qualité", "QualityProfiles": "Profils de qualité", - "QualitySettings": "Paramètres Qualité", + "QualitySettings": "Paramètres de qualité", "Queue": "File d'attente", - "ReadTheWikiForMoreInformation": "Consultez le Wiki pour plus d'informations", + "ReadTheWikiForMoreInformation": "Lisez le wiki pour plus d'informations", "Real": "Réel", "Reason": "Raison", "RecycleBinCleanupDaysHelpText": "Définir sur 0 pour désactiver le nettoyage automatique", "RecycleBinCleanupDaysHelpTextWarning": "Les fichiers dans la corbeille plus anciens que le nombre de jours sélectionné seront nettoyés automatiquement", "RecycleBinHelpText": "Les fichiers vidéo iront ici lorsqu'ils seront supprimés au lieu d'être supprimés définitivement", - "RecyclingBin": "Corbeille", - "RecyclingBinCleanup": "Nettoyage de la Corbeille", + "RecyclingBin": "Poubelle de recyclage", + "RecyclingBinCleanup": "Nettoyage du bac de recyclage", "Redownload": "Télécharger à nouveau", - "Refresh": "Actualiser", + "Refresh": "Rafraîchir", "RefreshInformationAndScanDisk": "Actualiser les informations et analyser le disque", "RefreshScan": "Actualiser et analyser", "ReleaseDate": "Date de sortie", "ReleaseGroup": "Groupe de versions", - "ReleaseRejected": "Version rejetée", + "ReleaseRejected": "Libération rejetée", "ReleaseStatuses": "Statut de la version", "ReleaseWillBeProcessedInterp": "La Version sera traitée {0}", "Reload": "Recharger", - "RemotePath": "Dossier distant", + "RemotePath": "Chemin distant", "RemotePathHelpText": "Chemin racine du dossier auquel le client de téléchargement accède", "RemotePathMappings": "Mappages de chemins distants", "Remove": "Retirer", @@ -285,7 +285,7 @@ "RemoveFromBlocklist": "Supprimer de la liste noire", "RemoveFromDownloadClient": "Supprimer du client de téléchargement", "RemoveFromQueue": "Supprimer de la file d'attente", - "RemoveSelected": "Supprimer la sélection", + "RemoveSelected": "Enlever la sélection", "RemoveTagExistingTag": "Tag existant", "RemoveTagRemovingTag": "Suppression du tag", "RenameTracksHelpText": "Lidarr utilisera le nom de fichier existant si le changement de nom est désactivé", @@ -300,21 +300,21 @@ "RescanArtistFolderAfterRefresh": "Réanalyser le dossier de films après l'actualisation", "Reset": "Réinitialiser", "ResetAPIKey": "Réinitialiser la clé API", - "ResetAPIKeyMessageText": "Voulez-vous réinitialiser votre clé d'API ?", + "ResetAPIKeyMessageText": "Êtes-vous sûr de vouloir réinitialiser votre clé API ?", "Restart": "Redémarrer", - "RetentionHelpText": "Usenet uniquement: définir sur zéro pour une rétention illimitée", + "RetentionHelpText": "Usenet uniquement : définissez-le sur zéro pour définir une rétention illimitée", "RetryingDownloadInterp": "Nouvelle tentative de téléchargement {0} à {1}", - "RootFolder": "Dossier Racine", + "RootFolder": "Dossier racine", "RootFolders": "Dossiers racine", "RSSSync": "Synchro RSS", "RSSSyncInterval": "Intervalle de synchronisation RSS", - "RssSyncIntervalHelpText": "Intervalle en minutes. Mettre à zéro pour désactiver (cela arrêtera tous les téléchargements automatiques)", + "RssSyncIntervalHelpText": "Intervalle en minutes. Réglez sur zéro pour désactiver (cela arrêtera toute capture de libération automatique)", "ShownAboveEachColumnWhenWeekIsTheActiveView": "Affiché au dessus de chaque colonne quand \"Semaine\" est l'affichage actif", "ShowPath": "Afficher le chemin", "ShowQualityProfile": "Afficher le profil de qualité", - "ShowQualityProfileHelpText": "Affiche le profil de qualité sous l'affiche", + "ShowQualityProfileHelpText": "Afficher le profil de qualité sous l'affiche", "ShowRelativeDates": "Afficher les dates relatives", - "ShowRelativeDatesHelpText": "Afficher les dates relatives (Aujourd'hui/ Hier/ etc) ou absolues", + "ShowRelativeDatesHelpText": "Afficher les dates relatives (Aujourd'hui/Hier/etc) ou absolues", "ShowSearch": "Afficher la recherche", "ShowSearchActionHelpText": "Afficher le bouton de recherche au survol du curseur", "ShowSizeOnDisk": "Afficher la taille sur le disque", @@ -323,7 +323,7 @@ "SslCertPasswordHelpText": "Mot de passe pour le fichier pfx", "SslCertPasswordHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "SSLCertPath": "Chemin du certificat SSL", - "SslCertPathHelpText": "Chemin vers le fichier pfx", + "SslCertPathHelpText": "Chemin d'accès au fichier pfx", "SslCertPathHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "SSLPort": "Port SSL", "SslPortHelpTextWarning": "Nécessite un redémarrage pour prendre effet", @@ -338,11 +338,11 @@ "SupportsSearchvalueWillBeUsedWhenAutomaticSearchesArePerformedViaTheUIOrByLidarr": "Sera utilisé lorsque les recherches automatiques sont effectuées via l'interface utilisateur ou par Lidarr", "SupportsSearchvalueWillBeUsedWhenInteractiveSearchIsUsed": "Sera utilisé lorsque la recherche interactive est utilisée", "TagIsNotUsedAndCanBeDeleted": "L'étiquette n'est pas utilisée et peut être supprimée", - "Tags": "Étiquettes", + "Tags": "Tags", "Tasks": "Tâches", "TestAll": "Tout tester", "TestAllClients": "Tester tous les clients", - "TestAllIndexers": "Tester tous les indexeurs", + "TestAllIndexers": "Testez tous les indexeurs", "TestAllLists": "Tester toutes les listes", "ThisWillApplyToAllIndexersPleaseFollowTheRulesSetForthByThem": "Cela s'appliquera à tous les indexeurs, veuillez suivre les règles définies par eux", "Time": "Heure", @@ -350,7 +350,7 @@ "TorrentDelay": "Retard du torrent", "TorrentDelayHelpText": "Délai en minutes avant de récupérer un torrent", "Torrents": "Torrents", - "TotalFileSize": "Taille totale du fichier", + "TotalFileSize": "Taille totale des fichiers", "Track": "Trace", "UISettings": "Paramètres UI", "RestartLidarr": "Redémarrer Lidarr", @@ -401,22 +401,22 @@ "Ungroup": "Dissocier", "UnmonitoredHelpText": "Inclure les films non surveillés dans le flux iCal", "UpdateAll": "Tout actualiser", - "UpdateAutomaticallyHelpText": "Télécharger et installer automatiquement les mises à jour. Vous pourrez toujours installer à partir de System : Updates", + "UpdateAutomaticallyHelpText": "Téléchargez et installez automatiquement les mises à jour. Vous pourrez toujours installer à partir du système : mises à jour", "UpdateMechanismHelpText": "Utiliser le programme de mise à jour intégré de Lidarr ou un script", - "UpdateScriptPathHelpText": "Chemin vers un script personnalisé qui prend un package de mise à jour extraite et gère le reste du processus de mise à jour", + "UpdateScriptPathHelpText": "Chemin d'accès à un script personnalisé qui prend un package de mise à jour extrait et gère le reste du processus de mise à jour", "UpgradeAllowedHelpText": "Si désactivé, les qualités ne seront pas améliorées", - "Uptime": "Durée de fonctionnent", + "Uptime": "Disponibilité", "URLBase": "Base URL", "UrlBaseHelpText": "Pour la prise en charge du proxy inverse, la valeur par défaut est vide", "UrlBaseHelpTextWarning": "Nécessite un redémarrage pour prendre effet", - "UseHardlinksInsteadOfCopy": "Utiliser des liens physiques au lieu de copier", + "UseHardlinksInsteadOfCopy": "Utiliser les liens durs au lieu de copier", "Usenet": "Usenet", "UsenetDelay": "Retard Usenet", "UsenetDelayHelpText": "Délai en minutes avant de récupérer une release de Usenet", - "UseProxy": "Utiliser un proxy", + "UseProxy": "Utiliser le proxy", "UserAgentProvidedByTheAppThatCalledTheAPI": "User-Agent fourni par l'application qui a appelé l'API", "UsingExternalUpdateMechanismBranchUsedByExternalUpdateMechanism": "Branche utilisée par le mécanisme de mise à jour extérieur", - "WeekColumnHeader": "En-tête de la colonne : Semaine", + "WeekColumnHeader": "En-tête de colonne de la semaine", "Year": "Année", "YesCancel": "Oui, annuler", "20MinutesTwenty": "20 Minutes: {0}", @@ -438,25 +438,25 @@ "DelayingDownloadUntilInterp": "Retarder le téléchargement jusqu'au {0} à {1}", "Scheduled": "Programmé", "ScriptPath": "Chemin du script", - "Search": "Chercher", + "Search": "Rechercher", "SearchAll": "Tout rechercher", - "SearchForMissing": "Recherche les manquants", + "SearchForMissing": "Recherche des manquants", "SearchSelected": "Rechercher la sélection", "Season": "Saison", "Security": "Sécurité", "SendAnonymousUsageData": "Envoyer des données d'utilisation anonymes", "SetPermissions": "Définir les autorisations", - "SetPermissionsLinuxHelpText": "Chmod doit-il être exécuté lorsque les fichiers sont importés/renommés?", - "SetPermissionsLinuxHelpTextWarning": "Si vous ne savez pas ce que font ces paramètres, ne les modifiez pas.", + "SetPermissionsLinuxHelpText": "Chmod doit-il être exécuté lorsque les fichiers sont importés/renommés ?", + "SetPermissionsLinuxHelpTextWarning": "Si vous n'êtes pas sûr de l'utilité de ces paramètres, ne les modifiez pas.", "Settings": "Paramètres", - "ShortDateFormat": "Format de date court", + "ShortDateFormat": "Format de date courte", "ShowCutoffUnmetIconHelpText": "Afficher l'icône des fichiers lorsque la limite n'a pas été atteinte", "ShowDateAdded": "Afficher la date d'ajout", - "ShowMonitored": "Afficher les éléments surveillés", - "ShowMonitoredHelpText": "Affiche le statut surveillé sous l'affiche", + "ShowMonitored": "Afficher le chemin", + "ShowMonitoredHelpText": "Afficher l'état de surveillance sous le poster", "Size": " Taille", "SkipFreeSpaceCheck": "Ignorer la vérification de l'espace libre", - "SkipFreeSpaceCheckWhenImportingHelpText": "À utiliser lorsque Lidarr ne parvient pas à détecter l'espace libre dans le dossier racine de votre film", + "SkipFreeSpaceCheckWhenImportingHelpText": "À utiliser lorsque Lidarr ne parvient pas à détecter l'espace libre de votre dossier racine lors de l'importation de fichiers", "SorryThatAlbumCannotBeFound": "Désolé, ce film est introuvable.", "SorryThatArtistCannotBeFound": "Désolé, ce film est introuvable.", "Source": "Source", @@ -467,9 +467,9 @@ "AllExpandedCollapseAll": "Réduire tout", "AllExpandedExpandAll": "Développer tout", "AllowArtistChangeClickToChangeArtist": "Cliquez pour changer d'artiste", - "AllowFingerprinting": "Autoriser le tracking", + "AllowFingerprinting": "Autoriser le tracking audio", "AllowFingerprintingHelpText": "Utiliser le tracking pour améliorer la suggestion de pistes", - "AllowFingerprintingHelpTextWarning": "Requiert Lidarr de lire des parties de fichier qui ralentiront le scan et peut causer une activité réseau ou disque élevée.", + "AllowFingerprintingHelpTextWarning": "Cela nécessite que Lidarr lise des parties du fichier, ce qui ralentira les analyses et pourrait entraîner une activité élevée du disque ou du réseau.", "AnchorTooltip": "Ce fichier est déjà dans votre bibliothèque pour une version que vous êtes en train d'importer", "AnyReleaseOkHelpText": "Lidarr va automatiquement basculer sur la version qui correspond le mieux aux pistes téléchargées", "Artist": "Artiste", @@ -492,19 +492,19 @@ "AlbumIsDownloading": "Album en cours de téléchargement", "AlbumIsNotMonitored": "L'album n'est pas surveillé", "AlbumStudio": "Album Studio", - "RemoveCompleted": "Supprimer les complétés", - "OnApplicationUpdate": "Lors de la mise à jour de l'app", + "RemoveCompleted": "Supprimer terminé", + "OnApplicationUpdate": "Sur la mise à jour de l'application", "OnApplicationUpdateHelpText": "Lors de la mise à jour de l'app", "Duration": "Durée", - "RemoveFailed": "Echec de la suppression", - "RemoveDownloadsAlert": "Les paramètres de suppression ont été déplacés dans les réglages de chaque client de téléchargement dans le tableau ci-dessus.", + "RemoveFailed": "Échec de la suppression", + "RemoveDownloadsAlert": "Les paramètres de suppression ont été déplacés vers les paramètres individuels du client de téléchargement dans le tableau ci-dessus.", "ThisCannotBeCancelled": "Cela ne peut pas être annulé une fois démarré sans désactiver tous vos indexeurs.", - "OnGrab": "À la Récupération", - "OnHealthIssue": "Lors d'un problème de santé", - "OnRename": "Lors du changement de nom", + "OnGrab": "À saisir", + "OnHealthIssue": "Sur la question de la santé", + "OnRename": "Au renommage", "OnUpgrade": "Lors de la mise à niveau", "ExpandAlbumByDefaultHelpText": "Album", - "Continuing": "Continuant", + "Continuing": "Continuer", "ContinuingAllTracksDownloaded": "Continuation (Tous les livres téléchargés)", "DefaultLidarrTags": "Tags Lidarr par défaut", "DefaultMetadataProfileIdHelpText": "Profil de métadonnées par défaut pour les auteurs détectés dans ce dossier", @@ -545,7 +545,7 @@ "BeforeUpdate": "Avant mise à jour", "Close": "Fermer", "Connect": "Connecter", - "Custom": "Personnalisé", + "Custom": "Customisé", "CustomFilters": "Filtres personnalisés", "Date": "Date", "DefaultDelayProfileHelpText": "Ceci est le profil par défaut. Il est appliqué à tous les films qui n'ont pas de profils spécifiques.", @@ -553,12 +553,12 @@ "Details": "Détails", "Donations": "Dons", "DoNotPrefer": "Ne pas préférer", - "DoNotUpgradeAutomatically": "Ne pas mettre à jour automatiquement", + "DoNotUpgradeAutomatically": "Ne pas mettre à niveau automatiquement", "DownloadFailed": "Échec du téléchargement", - "EditDelayProfile": "Modifier le profil de délai", - "EditImportListExclusion": "Supprimer les exclusions de liste d'imports", - "EditQualityProfile": "Modifier les profils", - "EditRemotePathMapping": "Éditer le chemin distant", + "EditDelayProfile": "Modifier le profil de retard", + "EditImportListExclusion": "Modifier l'exclusion de la liste d'importation", + "EditQualityProfile": "Modifier le profil de qualité", + "EditRemotePathMapping": "Modifier le mappage de chemin distant", "EditRootFolder": "Ajouter un dossier racine", "ErrorRestoringBackup": "Erreur lors de la restauration de la sauvegarde", "EventType": "Type d'événement", @@ -566,15 +566,15 @@ "FreeSpace": "Espace libre", "General": "Général", "Genres": "Genres", - "Grabbed": "Récupéré", - "HardlinkCopyFiles": "Lier/copier les fichiers", + "Grabbed": "Saisie", + "HardlinkCopyFiles": "Lien physique/Copie de fichiers", "HideAdvanced": "Masquer param. av.", "Ignored": "Ignoré", - "IndexerDownloadClientHelpText": "Spécifiez quel client de téléchargement est utilisé pour cet indexeur", + "IndexerDownloadClientHelpText": "Spécifiez quel client de téléchargement est utilisé pour les récupérations à partir de cet indexeur", "IndexerTagHelpText": "Utiliser seulement cet indexeur pour les films avec au moins un tag correspondant. Laissez vide pour l'utiliser avec tous les films.", - "Info": "Info", + "Info": "Information", "InstanceName": "Nom de l'instance", - "InstanceNameHelpText": "Nom de l'instance dans l'onglet du navigateur et pour le nom d'application dans Syslog", + "InstanceNameHelpText": "Nom de l'instance dans l'onglet et pour le nom de l'application Syslog", "InteractiveImport": "Importation interactive", "LastExecution": "Dernière exécution", "LastUsed": "Dernière utilisation", @@ -584,24 +584,24 @@ "Manual": "Manuel", "MassEditor": "Éditer en masse", "MediaManagement": "Gestion des médias", - "Metadata": "Métadonnées", + "Metadata": "Metadonnées", "MonitoredOnly": "Surveillé uniquement", - "MoveAutomatically": "Déplacer automatiquement", - "MoveFiles": "Déplacer les fichiers", - "OnlyTorrent": "Seulement Torrent", - "OnlyUsenet": "Seulement Usenet", + "MoveAutomatically": "Se déplacer automatiquement", + "MoveFiles": "Déplacer des fichiers", + "OnlyTorrent": "Uniquement Torrent", + "OnlyUsenet": "Uniquement Usenet", "Organize": "Organiser", "OutputPath": "Chemin de sortie", - "Peers": "Pairs", - "PreferAndUpgrade": "Préférez et améliorez", + "Peers": "Peers", + "PreferAndUpgrade": "Préférer et mettre à niveau", "PreferredProtocol": "Protocole préféré", "Presets": "Préconfigurations", "Progress": "Progression", "Queued": "En file d'attente", - "Rating": "Note", - "RejectionCount": "Compteur de rejet", + "Rating": "Notation", + "RejectionCount": "Nombre de rejets", "ReleaseTitle": "Titre de la version", - "Renamed": "Renommé", + "Renamed": "Renommer", "Replace": "Remplacer", "RestartRequiredHelpTextWarning": "Nécessite un redémarrage pour prendre effet", "RestoreBackupAdditionalInfo": "Remarque : Lidarr redémarrera et rechargera automatiquement l'interface utilisateur pendant le processus de restauration.", @@ -610,10 +610,10 @@ "Select...": "Sélectionner...", "SelectFolder": "Sélectionner le dossier", "SelectQuality": "Sélectionnez la qualité", - "ShowAdvanced": "Afficher param. av.", + "ShowAdvanced": "Afficher les paramètres avancés", "SizeLimit": "Limite de taille", "SizeOnDisk": "Taille sur le disque", - "SourceTitle": "Titre de la source", + "SourceTitle": "Titre source", "Started": "Démarré", "System": "Système", "Test": "Tester", @@ -622,7 +622,7 @@ "TotalSpace": "Espace total", "UI": "UI", "UnmappedFilesOnly": "Fichiers non mappés uniquement", - "UnmonitoredOnly": "Surveillé uniquement", + "UnmonitoredOnly": "Non surveillé uniquement", "UpgradesAllowed": "Mises à niveau autorisées", "Wanted": "Recherché", "Warn": "Avertissement", @@ -637,13 +637,13 @@ "NextExecution": "Prochaine exécution", "QualityLimitsHelpText": "Les limites sont automatiquement ajustées en fonction de la durée de l'album.", "Import": "Importer", - "NoTagsHaveBeenAddedYet": "Aucune étiquette n'a encore été ajoutée", - "Ok": "OK", + "NoTagsHaveBeenAddedYet": "Aucune identification n'a été ajoutée pour l'instant", + "Ok": "Ok", "AddDelayProfile": "Ajouter un profil de délai", "AddImportListExclusion": "Ajouter une exclusion à la liste des importations", "EditMetadataProfile": "profil de métadonnées", "AddConnection": "Ajouter une connexion", - "ImportListExclusions": "Supprimer les exclusions de liste d'imports", + "ImportListExclusions": "Exclusions de la liste d'importation", "LastDuration": "Dernière durée", "QualitiesHelpText": "Les qualités placées en haut de la liste sont privilégiées même si elles ne sont pas cochées. Les qualités d'un même groupe sont égales. Seules les qualités cochées sont recherchées", "Database": "Base de données", @@ -664,20 +664,20 @@ "ArtistType": "Type d'artiste", "Discography": "discographie", "ClickToChangeReleaseGroup": "Cliquez pour changer de groupe de diffusion", - "Episode": "épisode", - "SelectReleaseGroup": "Sélectionner le groupe de publication", + "Episode": "Épisode", + "SelectReleaseGroup": "Sélectionnez un groupe de versions", "Theme": "Thème", - "ThemeHelpText": "Changez le thème de l'interface de l'application. Le thème \"Auto\" utilisera celui de votre système d'exploitation pour définir le mode clair ou foncé. Inspiré par Theme.Park", - "CustomFormatScore": "Score du format personnalisé", - "EditReleaseProfile": "Ajouter un profil de version", - "MinimumCustomFormatScore": "Score de format personnalisé minimum", + "ThemeHelpText": "Modifiez le thème de l'interface utilisateur de l'application, le thème « Auto » utilisera le thème de votre système d'exploitation pour définir le mode clair ou sombre. Inspiré par Theme.Park", + "CustomFormatScore": "Partition au format personnalisé", + "EditReleaseProfile": "Modifier le profil de version", + "MinimumCustomFormatScore": "Score minimum de format personnalisé", "EnableRssHelpText": "Sera utilisé lorsque Lidarr recherche périodiquement des sorties via la synchronisation RSS", "DownloadedUnableToImportCheckLogsForDetails": "Téléchargé - Impossible d'importer : vérifiez les journaux pour plus de détails", "CloneCustomFormat": "Dupliqué le format personnalisé", "Conditions": "Conditions", "CopyToClipboard": "Copier dans le presse-papier", "CouldntFindAnyResultsForTerm": "Pas de résultats pour '{0}'", - "CustomFormat": "Format Personnalisé", + "CustomFormat": "Format personnalisé", "CustomFormatRequiredHelpText": "Cette {0} condition doit correspondre pour que le format personnalisé s'applique. Sinon, une seule correspondance {1} est suffisante.", "CustomFormatSettings": "Réglages Formats Personnalisés", "CustomFormats": "Formats perso.", @@ -685,31 +685,31 @@ "CutoffFormatScoreHelpText": "Quand ce score de format personnalisé est atteint, Lidarr ne téléchargera plus de films", "DeleteCustomFormat": "Supprimer le format personnalisé", "DeleteCustomFormatMessageText": "Voulez-vous vraiment supprimer le format personnalisé « {name} » ?", - "DeleteFormatMessageText": "Êtes-vous sûr de vouloir supprimer le tag {0} ?", - "DownloadPropersAndRepacksHelpTextWarning": "Utiliser les mots préférés pour les mises à niveau automatiques des propres/repacks", + "DeleteFormatMessageText": "Êtes-vous sûr de vouloir supprimer la balise de format « {name} » ?", + "DownloadPropersAndRepacksHelpTextWarning": "Utilisez des formats personnalisés pour les mises à niveau automatiques vers Propers/Repacks", "IncludeCustomFormatWhenRenamingHelpText": "Inclus dans {Custom Formats} renommer le format", "ItsEasyToAddANewArtistJustStartTypingTheNameOfTheArtistYouWantToAdd": "C'est facile d'ajouter un nouvel artiste, commencez simplement à saisir le nom de l'artiste que vous souhaitez ajouter.", "MinFormatScoreHelpText": "Score de format personnalisé minimum autorisé à télécharger", - "Monitor": "Surveiller", - "PreferTorrent": "Préférez Torrent", - "PreferUsenet": "Préférez Usenet", - "ResetDefinitionTitlesHelpText": "Réinitialiser les titres des définitions ainsi que les valeurs", + "Monitor": "Surveillé", + "PreferTorrent": "Préféré Torrent", + "PreferUsenet": "Préférer Usenet", + "ResetDefinitionTitlesHelpText": "Réinitialiser les titres de définition ainsi que les valeurs", "SpecificMonitoringOptionHelpText": "Surveiller les auteurs, mais seulement surveiller les livres explicitement inclus dans la liste", "UnableToLoadCustomFormats": "Impossible de charger les formats personnalisés", "UnableToLoadInteractiveSearch": "Impossible de charger les résultats de cette recherche de films. Réessayez plus tard", - "ReleaseProfiles": "profil de version", + "ReleaseProfiles": "Profils de version", "TheArtistFolderStrongpathstrongAndAllOfItsContentWillBeDeleted": "Le dossier '{0}' et son contenu vont être supprimés.", - "ExportCustomFormat": "Exporter format personnalisé", + "ExportCustomFormat": "Exporter un format personnalisé", "FailedDownloadHandling": "Gestion des échecs de téléchargement", "FailedLoadingSearchResults": "Échec du chargement des résultats de la recherche, veuillez réessayer.", "Formats": "Formats", "NegateHelpText": "Si coché, le format personnalisé ne s'appliquera pas si cette condition {0} correspond.", "ResetDefinitions": "Réinitialiser les définitions", "ResetTitles": "Réinitialiser les titres", - "HiddenClickToShow": "Caché, cliquez pour afficher", + "HiddenClickToShow": "Masqué, cliquez pour afficher", "RemotePathMappingCheckDownloadPermissions": "Lidarr peut voir mais ne peut accéder au film téléchargé {0}. Il s'agit probablement d'une erreur de permissions.", "RemotePathMappingCheckDockerFolderMissing": "Vous utilisez docker ; {0} enregistre les téléchargements dans {1} mais ce dossier n'est pas présent dans ce conteneur. Vérifiez vos paramètres de dossier distant et les paramètres de votre conteneur docker.", - "ShownClickToHide": "Montré, cliquez pour masquer", + "ShownClickToHide": "Affiché, cliquez pour masquer", "ApiKeyValidationHealthCheckMessage": "Veuillez mettre à jour votre clé API pour qu'elle contienne au moins {0} caractères. Vous pouvez le faire via les paramètres ou le fichier de configuration", "AppDataLocationHealthCheckMessage": "La mise à jour ne sera pas possible afin empêcher la suppression de AppData lors de la mise à jour", "ColonReplacement": "Remplacement pour le « deux-points »", @@ -763,28 +763,28 @@ "UpdateAvailable": "Une nouvelle mise à jour est disponible", "UpdateCheckStartupNotWritableMessage": "Impossible d'installer la mise à jour car le dossier de démarrage '{0}' n'est pas accessible en écriture par l'utilisateur '{1}'.", "UpdateCheckStartupTranslocationMessage": "Impossible d'installer la mise à jour car le dossier de démarrage '{0}' se trouve dans un dossier App Translocation.", - "DeleteRemotePathMapping": "Éditer le chemin distant", - "DeleteRemotePathMappingMessageText": "Êtes-vous sûr de vouloir effacer ce chemin ?", + "DeleteRemotePathMapping": "Supprimer le mappage de chemin distant", + "DeleteRemotePathMappingMessageText": "Êtes-vous sûr de vouloir supprimer ce mappage de chemin distant ?", "ApplyChanges": "Appliquer les modifications", "BlocklistReleaseHelpText": "Empêche Lidarr de récupérer automatiquement cette version", "FailedToLoadQueue": "Erreur lors du chargement de la file", "BlocklistReleases": "Publications de la liste de blocage", - "DeleteConditionMessageText": "Voulez-vous vraiment supprimer la liste '{0}' ?", - "Negated": "Inversé", + "DeleteConditionMessageText": "Êtes-vous sûr de vouloir supprimer la condition « {name} » ?", + "Negated": "Nier", "RemoveSelectedItem": "Supprimer l'élément sélectionné", "ApplyTagsHelpTextAdd": "Ajouter : ajoute les étiquettes à la liste de étiquettes existantes", "DownloadClientSortingCheckMessage": "Le client de téléchargement {0} a activé le tri {1} pour la catégorie de Lidarr. Vous devez désactiver le tri dans votre client de téléchargement pour éviter les problèmes d'importation.", "AutomaticAdd": "Ajout automatique", - "CountIndexersSelected": "{0} indexeur(s) sélectionné(s)", - "DeleteSelectedDownloadClients": "Supprimer le client de téléchargement", - "DeleteSelectedIndexers": "Supprimer l'indexeur", - "ExistingTag": "Tag existant", + "CountIndexersSelected": "{selectedCount} indexeur(s) sélectionné(s)", + "DeleteSelectedDownloadClients": "Supprimer les clients de téléchargement sélectionnés", + "DeleteSelectedIndexers": "Supprimer un ou plusieurs indexeurs", + "ExistingTag": "Balise existante", "No": "Non", "NoChange": "Pas de changement", "RemoveSelectedItems": "Supprimer les éléments sélectionnés", "Required": "Obligatoire", "ResetQualityDefinitions": "Réinitialiser les définitions de qualité", - "SetTags": "Définir les étiquettes", + "SetTags": "Définir des balises", "NoEventsFound": "Aucun événement trouvé", "QueueIsEmpty": "La file d'attente est vide", "ResetQualityDefinitionsMessageText": "Voulez-vous vraiment réinitialiser les définitions de qualité ?", @@ -792,55 +792,55 @@ "ApplyTagsHelpTextReplace": "Remplacer : remplace les étiquettes par les étiquettes renseignées (ne pas renseigner d'étiquette pour toutes les effacer)", "DownloadClientTagHelpText": "Utiliser seulement cet indexeur pour les films avec au moins un tag correspondant. Laissez vide pour l'utiliser avec tous les films.", "RemoveSelectedItemBlocklistMessageText": "Êtes-vous sûr de vouloir supprimer les films sélectionnés de la liste noire ?", - "RemovingTag": "Suppression du tag", + "RemovingTag": "Supprimer la balise", "ResetTitlesHelpText": "Réinitialiser les titres des définitions ainsi que les valeurs", "RemoveFromDownloadClientHelpTextWarning": "La suppression supprimera le téléchargement et le(s) fichier(s) du client de téléchargement.", - "RemoveSelectedItemQueueMessageText": "Voulez-vous vraiment supprimer 1 élément de la file d'attente ?", + "RemoveSelectedItemQueueMessageText": "Êtes-vous sûr de vouloir supprimer 1 élément de la file d'attente ?", "RemoveSelectedItemsQueueMessageText": "Voulez-vous vraiment supprimer {0} éléments de la file d'attente ?", "Yes": "Oui", "ApplyTagsHelpTextHowToApplyArtists": "Comment appliquer des étiquettes aux indexeurs sélectionnés", "ApplyTagsHelpTextHowToApplyDownloadClients": "Comment appliquer des étiquettes aux clients de téléchargement sélectionnés", "DeleteSelectedDownloadClientsMessageText": "Voulez-vous vraiment supprimer {count} client(s) de téléchargement sélectionné(s) ?", - "DeleteSelectedImportListsMessageText": "Voulez-vous vraiment supprimer l'indexeur '{0}' ?", + "DeleteSelectedImportListsMessageText": "Êtes-vous sûr de vouloir supprimer {count} liste(s) d'importation sélectionnée(s) ?", "DeleteSelectedIndexersMessageText": "Voulez-vous vraiment supprimer les {count} indexeur(s) sélectionné(s) ?", "ApplyTagsHelpTextHowToApplyImportLists": "Comment appliquer des étiquettes aux listes d'importation sélectionnées", "ApplyTagsHelpTextHowToApplyIndexers": "Comment appliquer des étiquettes aux indexeurs sélectionnés", - "CountDownloadClientsSelected": "{0} client(s) de téléchargement sélectionné(s)", + "CountDownloadClientsSelected": "{selectedCount} client(s) de téléchargement sélectionné(s)", "EditSelectedDownloadClients": "Modifier les clients de téléchargement sélectionnés", "EditSelectedIndexers": "Modifier les indexeurs sélectionnés", - "SomeResultsAreHiddenByTheAppliedFilter": "Tous les résultats ont été dissimulés par le filtre actuellement appliqué", + "SomeResultsAreHiddenByTheAppliedFilter": "Certains résultats sont masqués par le filtre appliqué", "SuggestTranslationChange": "Suggérer un changement de traduction", - "UpdateSelected": "Mettre à jour la sélection", + "UpdateSelected": "Mise à jour sélectionnée", "AllResultsAreHiddenByTheAppliedFilter": "Tous les résultats sont masqués par le filtre appliqué", "NoResultsFound": "Aucun résultat trouvé", "AddConditionImplementation": "Ajouter une condition - {implementationName}", "AddConnectionImplementation": "Ajouter une connexion - {implementationName}", - "ImportListRootFolderMissingRootHealthCheckMessage": "Le dossier racine est manquant pour importer la/les listes : {0}", - "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Plusieurs dossiers racines sont manquants pour importer les listes : {0}", + "ImportListRootFolderMissingRootHealthCheckMessage": "Dossier racine manquant pour la ou les listes d'importation : {0}", + "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Plusieurs dossiers racine sont manquants pour les listes d'importation : {0}", "ConnectionLost": "Connexion perdue", "ConnectionLostReconnect": "{appName} essaiera de se connecter automatiquement, ou vous pouvez cliquer sur « Recharger » en bas.", "ConnectionLostToBackend": "{appName} a perdu sa connexion au backend et devra être rechargé pour fonctionner à nouveau.", "RecentChanges": "Changements récents", - "NotificationStatusSingleClientHealthCheckMessage": "Notifications indisponibles en raison de dysfonctionnements : {0}", + "NotificationStatusSingleClientHealthCheckMessage": "Notifications indisponibles en raison d'échecs : {0}", "Priority": "Priorité", "Release": " Sorti", "WhatsNew": "Quoi de neuf ?", - "EditConditionImplementation": "Ajouter une connexion - {implementationName}", + "EditConditionImplementation": "Modifier la condition – {implementationName}", "Enabled": "Activé", - "NotificationStatusAllClientHealthCheckMessage": "Toutes les notifications sont indisponibles en raison de dysfonctionnements", + "NotificationStatusAllClientHealthCheckMessage": "Toutes les notifications ne sont pas disponibles en raison d'échecs", "AddIndexerImplementation": "Ajouter un indexeur - {implementationName}", - "EditConnectionImplementation": "Ajouter une connexion - {implementationName}", - "EditIndexerImplementation": "Ajouter une condition - {implementationName}", + "EditConnectionImplementation": "Modifier la connexion - {implementationName}", + "EditIndexerImplementation": "Modifier l'indexeur - {implementationName}", "AddNewArtistRootFolderHelpText": "'{0}' le sous-dossier sera créé automatiquement", - "ImportLists": "liste d'importation", - "ErrorLoadingContent": "Une erreur s'est produite lors du chargement de cet élément", + "ImportLists": "Importer des listes", + "ErrorLoadingContent": "Une erreur s'est produite lors du chargement de ce contenu", "AppUpdated": "{appName} mis à jour", "AutoAdd": "Ajout automatique", "AutomaticUpdatesDisabledDocker": "Les mises à jour automatiques ne sont pas directement prises en charge lors de l'utilisation du mécanisme de mise à jour de Docker. Vous devrez mettre à jour l'image du conteneur en dehors de {appName} ou utiliser un script", "AddImportList": "Ajouter une liste d'importation", "AddDownloadClientImplementation": "Ajouter un client de téléchargement - {implementationName}", "AddImportListImplementation": "Ajouter une liste d'importation - {implementationName}", - "Implementation": "Implémentation", + "Implementation": "Mise en œuvre", "AppUpdatedVersion": "{appName} a été mis à jour vers la version `{version}`, pour profiter des derniers changements, vous devrez relancer {appName}", "Clone": "Cloner", "NoDownloadClientsFound": "Aucun client de téléchargement n'a été trouvé", @@ -852,11 +852,238 @@ "ListWillRefreshEveryInterp": "La liste se rafraîchira tous/toutes la/les {0}", "DeleteRootFolder": "Supprimer le dossier racine", "NoIndexersFound": "Aucun indexeur n'a été trouvé", - "SmartReplace": "Replacement intelligent", - "PreferProtocol": "{preferredProtocol} préféré", + "SmartReplace": "Remplacement intelligent", + "PreferProtocol": "Préféré {preferredProtocol}", "DeleteCondition": "Supprimer la condition", "IsShowingMonitoredUnmonitorSelected": "Arrêter de surveiller la sélection", "ExtraFileExtensionsHelpTexts2": "« Exemples : ».sub", "NoMissingItems": "Aucun élément manquant", - "DashOrSpaceDashDependingOnName": "Tiret ou espace puis tiret selon le nom" + "DashOrSpaceDashDependingOnName": "Tiret ou espace puis tiret selon le nom", + "MassAlbumsCutoffUnmetWarning": "Êtes-vous sûr de vouloir rechercher tous les albums « {0} » Cutoff Unmet ?", + "ShouldMonitorExisting": "Surveiller les albums existants", + "UnmappedFiles": "Fichiers non mappés", + "DownloadImported": "Télécharger Importé", + "OnArtistDeleteHelpText": "Lors de la suppression de l'artiste", + "FilterArtistPlaceholder": "Filtrer l'artiste", + "FilterAlbumPlaceholder": "Filtrer l'album", + "LatestAlbumData": "Surveillez les derniers albums et les futurs albums", + "OnHealthRestored": "Sur la santé restaurée", + "Other": "Autre", + "EnableProfile": "Activer le profil", + "GoToArtistListing": "Aller à la liste des artistes", + "HideTracks": "Masquer les traces", + "ShowAlbumCount": "Afficher le nombre d'albums", + "DeleteSelectedImportLists": "Supprimer la ou les listes d'importation", + "DeleteSelected": "Supprimer sélectionnée", + "DeleteArtist": "Supprimer l'artiste sélectionné", + "DeleteEmptyFoldersHelpText": "Supprimez les dossiers d'artistes et d'albums vides pendant l'analyse du disque et lorsque les fichiers de piste sont supprimés", + "DownloadPropersAndRepacksHelpTexts2": "Utilisez « Ne pas préférer » pour trier par score de mot préféré par rapport aux propriétés/repacks", + "FirstAlbum": "Premier album", + "ForeignId": "ID étranger", + "ForeignIdHelpText": "L'identifiant Musicbrainz de l'artiste/album à exclure", + "MinimumCustomFormatScoreHelpText": "Score de format personnalisé minimum requis pour contourner le délai pour le protocole préféré", + "MusicBrainzTrackID": "Identifiant de la piste MusicBrainz", + "ShouldSearchHelpText": "Recherchez dans les indexeurs les éléments nouvellement ajoutés. À utiliser avec prudence pour les grandes listes.", + "DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "Le client de téléchargement {0} est configuré pour supprimer les téléchargements terminés. Cela peut entraîner la suppression des téléchargements de votre client avant que {1} puisse les importer.", + "EnableAutomaticAddHelpText": "Ajoutez des artistes/albums à Lidarr lorsque les synchronisations sont effectuées via l'interface utilisateur ou par Lidarr", + "EpisodeDoesNotHaveAnAbsoluteEpisodeNumber": "L'épisode n'a pas de numéro d'épisode absolu", + "ExpandItemsByDefault": "Développer les éléments par défaut", + "FutureDays": "Jours futurs", + "IfYouDontAddAnImportListExclusionAndTheArtistHasAMetadataProfileOtherThanNoneThenThisAlbumMayBeReaddedDuringTheNextArtistRefresh": "Si vous n'ajoutez pas d'exclusion de liste d'importation et que l'artiste a un profil de métadonnées autre que « Aucun », cet album pourra être ajouté à nouveau lors de la prochaine actualisation de l'artiste.", + "Inactive": "Inactif", + "IsInUseCantDeleteAMetadataProfileThatIsAttachedToAnArtistOrImportList": "Impossible de supprimer un profil de métadonnées associé à un artiste ou à une liste d'importation", + "IsShowingMonitoredMonitorSelected": "Surveillance sélectionnée", + "Monitoring": "Surveillance", + "OnArtistDelete": "Lors de la suppression de l'artiste", + "OnImportFailureHelpText": "En cas d'échec de l'importation", + "OnReleaseImport": "Lors de l'importation de la version", + "OnTrackRetagHelpText": "Lors de l’étiquetage de la piste", + "RemotePathMappingsInfo": "Les mappages de chemins distants sont très rarement requis. Si {app} et votre client de téléchargement sont sur le même système, il est préférable de faire correspondre vos chemins. Pour plus d'informations, consultez le [wiki]({wikiLink})", + "Retag": "Réétiqueter", + "RootFolderPath": "Chemin du dossier racine", + "ScrubAudioTagsHelpText": "Supprimez les balises existantes des fichiers, en ne laissant que celles ajoutées par Lidarr.", + "ScrubExistingTags": "Effacer les balises existantes", + "SearchBoxPlaceHolder": "eg. Breaking Benjamin, lidarr:854a1807-025b-42a8-ba8c-2a39717f1d25", + "SearchForAllCutoffUnmetAlbums": "Rechercher tous les albums de Cutoff Unmet", + "SearchForAllMissingAlbums": "Rechercher tous les albums manquants", + "SelectAlbum": "Sélectionner un album", + "SelectAlbumRelease": "Sélectionnez la sortie de l'album", + "ShouldMonitorHelpText": "Surveiller les artistes et les albums ajoutés à partir de cette liste", + "ShowTitleHelpText": "Afficher le nom de l'artiste sous l'affiche", + "TagAudioFilesWithMetadata": "Baliser les fichiers audio avec des métadonnées", + "Total": "Total", + "WriteAudioTagsHelpTextWarning": "La sélection de « Tous les fichiers » modifiera les fichiers existants lors de leur importation.", + "DeleteFilesHelpText": "Supprimez les fichiers de piste et le dossier de l'artiste", + "ReleasesHelpText": "Changer la sortie de cet album", + "AddImportListExclusionAlbumHelpText": "Empêcher l'ajout d'un album à Lidarr par les listes d'importation", + "AddImportListExclusionArtistHelpText": "Empêcher l'ajout d'un artiste à Lidarr par les listes d'importation", + "DeleteMetadataProfile": "Supprimer le profil de métadonnées", + "HasMonitoredAlbumsNoMonitoredAlbumsForThisArtist": "Aucun album surveillé pour cet artiste", + "ImportFailures": "Échecs d’importation", + "ImportFailed": "Échec de l'importation", + "IndexerIdHelpTextWarning": "L'utilisation d'un indexeur spécifique avec les mots préférés peut conduire à la saisie de versions en double", + "LastAlbum": "Dernier album", + "ListRefreshInterval": "Intervalle d'actualisation de la liste", + "LidarrSupportsMultipleListsForImportingAlbumsAndArtistsIntoTheDatabase": "Lidarr prend en charge plusieurs listes pour importer des albums et des artistes dans la base de données.", + "MediumFormat": "Format des médias", + "MonitorNewAlbums": "Surveiller les nouveaux albums", + "MonitorExistingAlbums": "Surveiller les albums existants", + "MonitorNewItemsHelpText": "Quels nouveaux albums doivent être surveillés", + "MusicBrainzReleaseID": "ID de version MusicBrainz", + "OnDownloadFailure": "En cas d'échec de téléchargement", + "OnDownloadFailureHelpText": "En cas d'échec de téléchargement", + "PastDays": "Jours passés", + "PrimaryTypes": "Types principaux", + "RemoveCompletedDownloads": "Supprimer les téléchargements terminés", + "SceneInformation": "Informations sur la scène", + "SkipRedownloadHelpText": "Empêche Lidarr d'essayer de télécharger des versions alternatives pour les éléments supprimés", + "SkipRedownload": "Ignorer le nouveau téléchargement", + "TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp": "{0} pistes au total. {1} pistes avec fichiers.", + "TrackNaming": "Dénomination des pistes", + "TrackProgress": "Progression de piste", + "UnableToLoadMetadataProviderSettings": "Impossible de charger les paramètres du fournisseur de métadonnées", + "WriteMetadataTags": "Écrire des balises de métadonnées", + "ForNewImportsOnly": "Pour les nouvelles importations uniquement", + "FutureDaysHelpText": "Journées pour le flux iCal pour regarder vers l'avenir", + "LatestAlbum": "Dernier album", + "MissingAlbums": "Albums manquants", + "SecondaryTypes": "Types secondaires", + "SelectTracks": "Sélectionner des pistes", + "ShowBanners": "Afficher les bannières", + "ShowBannersHelpText": "Afficher des bannières au lieu de noms", + "TBA": "À déterminer", + "DeleteImportList": "Supprimer la liste d'importation", + "DeleteTrackFile": "Supprimer le fichier de piste", + "EditMetadata": "Modifier les métadonnées", + "EndedOnly": "Terminé seulement", + "EntityName": "Nom de l'entité", + "ManageTracks": "Gérer les pistes", + "DownloadedWaitingToImport": "\"Téléchargé - En attente d'importation\"", + "TrackStatus": "Statut de piste", + "OnTrackRetag": "Lors de l’étiquetage de la piste", + "RefreshArtist": "Actualiser l'artiste", + "MetadataConsumers": "Consommateurs de métadonnées", + "MonitoringOptionsHelpText": "Quels albums doivent être surveillés après l'ajout de l'artiste (ajustement unique)", + "SelectedCountArtistsSelectedInterp": "{selectedCount} Artiste(s) sélectionné(s)", + "WatchLibraryForChangesHelpText": "Réanalyser automatiquement lorsque les fichiers changent dans un dossier racine", + "CountImportListsSelected": "{selectedCount} liste(s) d'importation sélectionnée(s)", + "Disambiguation": "Homonymie", + "DiscCount": "Nombre de disques", + "DownloadedImporting": "'Téléchargement - Importation'", + "GroupInformation": "Informations sur le groupe", + "MissingTracksArtistMonitored": "Pistes manquantes (artiste surveillé)", + "MonitorAlbum": "Album de surveillance", + "MonitorNewItems": "Surveiller les nouveaux albums", + "NoImportListsFound": "Aucune liste d'importation trouvée", + "RenameTracks": "Renommer les pistes", + "SelectArtist": "Sélectionner un artiste", + "TheAlbumsFilesWillBeDeleted": "Les fichiers de l'album seront supprimés.", + "TrackTitle": "Titre de la piste", + "CloneCondition": "État du clone", + "ContinuingOnly": "Continuant uniquement", + "DeleteFormat": "Supprimer le format", + "EnabledHelpText": "Cochez pour activer le profil de version", + "ExpandSingleByDefaultHelpText": "Singles", + "ExistingTagsScrubbed": "Balises existantes supprimées", + "ManageIndexers": "Gérer les indexeurs", + "MetadataProfileIdHelpText": "Les éléments de la liste de profils de métadonnées doivent être ajoutés avec", + "MonitoringOptions": "Options de surveillance", + "MusicBrainzArtistID": "Identifiant d'artiste MusicBrainz", + "NewAlbums": "Nouveaux albums", + "NoAlbums": "Aucuns albums", + "PathHelpTextWarning": "Cela doit être différent du répertoire dans lequel votre client de téléchargement place les fichiers", + "Proceed": "Procéder", + "RemoveFailedDownloads": "Supprimer les téléchargements ayant échoué", + "OneAlbum": "1 album", + "Playlist": "Liste de lecture", + "SearchAlbum": "Rechercher un album", + "ShouldMonitorExistingHelpText": "Surveiller automatiquement les albums de cette liste qui sont déjà dans Lidarr", + "ShowNextAlbum": "Afficher l'album suivant", + "ShowName": "Afficher le nom", + "TagsHelpText": "Baliser les fichiers audio avec des métadonnées, les profils de publication s'appliqueront aux artistes avec au moins une balise correspondante. Laisser vide pour postuler à tous les artistes", + "TrackImported": "Piste importée", + "TrackMissingFromDisk": "Piste manquante sur le disque", + "TrackDownloaded": "Piste téléchargée", + "TrackFiles": "Suivre les fichiers", + "TrackCount": "Nombre de pistes", + "TrackNumber": "Numéro de piste", + "SearchMonitored": "Recherche surveillée", + "SearchForMonitoredAlbums": "Rechercher des albums surveillés", + "ImportListSpecificSettings": "Paramètres spécifiques à la liste d'importation", + "MissingTracks": "Pistes manquantes", + "MassAlbumsSearchWarning": "Êtes-vous sûr de vouloir rechercher tous les albums manquants « {0} » ?", + "MonitoredHelpText": "Téléchargez les albums surveillés de cet artiste", + "ContinuingMoreAlbumsAreExpected": "D'autres albums sont attendus", + "MusicBrainzRecordingID": "Identifiant d'enregistrement MusicBrainz", + "ReplaceExistingFiles": "Remplacer les fichiers existants", + "SpecificAlbum": "Album spécifique", + "UpdatingIsDisabledInsideADockerContainerUpdateTheContainerImageInstead": "La mise à jour est désactivée dans un conteneur Docker. Mettez plutôt à jour l’image du conteneur.", + "EditArtist": "Modifier l'artiste", + "ExistingAlbums": "Albums existants", + "MissingTracksArtistNotMonitored": "Pistes manquantes (artiste non surveillé)", + "MultiDiscTrackFormat": "Format de piste multi-disque", + "MusicBrainzAlbumID": "Identifiant de l'album MusicBrainz", + "OnAlbumDeleteHelpText": "Lors de la suppression de l'album", + "PreviewRetag": "Aperçu du réétiquetage", + "PrimaryAlbumTypes": "Types d'albums principaux", + "CountAlbums": "{albumCount} albums", + "DateAdded": "date ajoutée", + "DiscNumber": "Numéro de disque", + "CombineWithExistingFiles": "Combiner avec des fichiers existants", + "ManualDownload": "Téléchargement manuel", + "EditSelectedImportLists": "Modifier les listes d'importation sélectionnées", + "EndedAllTracksDownloaded": "Terminé (Toutes les pistes téléchargées)", + "HideAlbums": "Masquer les albums", + "MediaCount": "Nombre de médias", + "MonitorAlbumExistingOnlyWarning": "Il s’agit d’un ajustement unique du paramètre surveillé pour chaque album. Utilisez l'option sous Artiste/Modifier pour contrôler ce qui se passe pour les albums nouvellement ajoutés", + "Retagged": "Re-étiqueté", + "RootFolderPathHelpText": "Les éléments de la liste du dossier racine seront ajoutés à", + "SecondaryAlbumTypes": "Types d'albums secondaires", + "ShowNextAlbumHelpText": "Afficher l'album suivant sous l'affiche", + "BypassIfHighestQualityHelpText": "Délai de contournement lorsque la version a la qualité activée la plus élevée dans le profil de qualité", + "ExpandOtherByDefaultHelpText": "Autre", + "FutureAlbums": "Futurs albums", + "MusicbrainzId": "Identifiant Musicbrainz", + "NextAlbum": "Album suivant", + "IsInUseCantDeleteAQualityProfileThatIsAttachedToAnArtistOrImportList": "Impossible de supprimer un profil de qualité associé à un artiste ou à une liste d'importation", + "AlbumCount": "Nombre d'albums", + "EditImportListImplementation": "Modifier la liste d'importation - {implementationName}", + "ManageImportLists": "Gérer les listes d'importation", + "ManageLists": "Gérer les listes", + "LoadingAlbumsFailed": "Le chargement des albums a échoué", + "NoCutoffUnmetItems": "Aucun élément non satisfait", + "NoneMonitoringOptionHelpText": "Ne pas surveillez les artistes ou les albums", + "NotDiscography": "Pas de discographie", + "OnAlbumDelete": "Lors de la suppression de l'album", + "OnHealthRestoredHelpText": "Sur la santé restaurée", + "OnImportFailure": "En cas d'échec de l'importation", + "OnReleaseImportHelpText": "Lors de l'importation de la version", + "PastDaysHelpText": "Jours pour le flux iCal pour se pencher sur le passé", + "PathHelpText": "Dossier racine contenant votre bibliothèque musicale", + "QualityProfileIdHelpText": "Les éléments de la liste du profil de qualité doivent être ajoutés avec", + "ShowLastAlbum": "Afficher le dernier album", + "TrackFilesCountMessage": "Aucun fichier de piste", + "AddNewAlbum": "Ajouter un nouvel album", + "AddNewArtist": "Ajouter un nouvel artiste", + "WatchRootFoldersForFileChanges": "Surveillez les dossiers racine pour les modifications de fichiers", + "ExpandBroadcastByDefaultHelpText": "Diffuser", + "ExpandEPByDefaultHelpText": "EPs", + "CountArtistsSelected": "{selectedCount} artiste(s) sélectionné(s)", + "ImportListSettings": "Paramètres généraux de la liste d'importation", + "IndexerIdHelpText": "Spécifiez à quel indexeur le profil s'applique", + "IsExpandedHideAlbums": "Masquer les albums", + "IsExpandedHideFileInfo": "Masquer les informations sur le fichier", + "IsExpandedHideTracks": "Masquer les traces", + "GrabId": "Saisir ID", + "InfoUrl": "URL d'informations", + "IsExpandedShowAlbums": "Afficher les albums", + "IsExpandedShowFileInfo": "Afficher les informations sur le fichier", + "IsExpandedShowTracks": "Afficher les pistes", + "InvalidUILanguage": "Votre interface utilisateur est définie sur une langue non valide, corrigez-la et enregistrez vos paramètres", + "OrganizeArtist": "Organiser l'artiste sélectionné", + "SceneNumberHasntBeenVerifiedYet": "Le numéro de scène n'a pas encore été vérifié", + "ShouldSearch": "Rechercher de nouveaux éléments", + "HealthMessagesInfoBox": "Vous pouvez trouver plus d'informations sur la cause de ces messages de contrôle de santé en cliquant sur le lien wiki (icône de livre) à la fin de la ligne, ou en vérifiant vos [journaux]({link}). Si vous rencontrez des difficultés pour interpréter ces messages, vous pouvez contacter notre support, via les liens ci-dessous.", + "MonitorArtist": "Artiste moniteur", + "TrackArtist": "Artiste de piste" } diff --git a/src/NzbDrone.Core/Localization/Core/hu.json b/src/NzbDrone.Core/Localization/Core/hu.json index e3525bec1..fa1a2a322 100644 --- a/src/NzbDrone.Core/Localization/Core/hu.json +++ b/src/NzbDrone.Core/Localization/Core/hu.json @@ -15,10 +15,10 @@ "Automatic": "Automatikus", "BackupNow": "Biztonsági Mentés Most", "BackupRetentionHelpText": "A megőrzési időnél régebbi automatikus biztonsági másolatok automatikusan törlésre kerülnek", - "Backups": "Biztonsági Mentés", + "Backups": "Biztonsági mentések", "BindAddress": "Kapcsolási Cím", "BindAddressHelpText": "Érvényes IP-cím, localhost vagy '*' minden interfészhez", - "Cancel": "Vissza", + "Cancel": "Mégse", "CertificateValidation": "Tanúsítvány érvényesítése", "ChangeHasNotBeenSavedYet": "A változások még nem lettek elmentve", "Clear": "Törölni", @@ -679,16 +679,16 @@ "AddIndexer": "Indexer hozzáadása", "AddMetadataProfile": "Metaadat-profil", "AddNew": "Új hozzáadása", - "AddQualityProfile": "Minőségi profil hozzáadása", + "AddQualityProfile": "Minőségi Profil hozzáadása", "AddRemotePathMapping": "Adj Meg Egy Távoli Elérési Útvonalat", "AddRootFolder": "Gyökérmappa hozzáadása", "AfterManualRefresh": "A kézi frissítés után", - "Age": "Kora", + "Age": "Kor", "Albums": "Albumok", "All": "Összes", "AllFiles": "Összes fájl", "AllMonitoringOptionHelpText": "Az importálási listán lévő szerzők összes könyvének monitorozása", - "ApplicationURL": "Alkalmazás URL-je", + "ApplicationURL": "Alkalmazás URL", "ApplicationUrlHelpText": "Az alkalmazás külső URL-címe, beleértve a http(s)://-t, a portot és az URL-alapot", "Apply": "Alkalmazás", "AudioInfo": "Hang Infó", @@ -782,7 +782,7 @@ "Title": "Cím", "TotalSpace": "Összes szabad hely", "UnmappedFilesOnly": "Kizárólag fel nem térképezett fájlokat", - "UnmonitoredOnly": "Csak a Megfigyelt", + "UnmonitoredOnly": "Csak a nem felügyelt", "UpgradesAllowed": "Frissítések Engedélyezve", "Wanted": "Keresett", "Warn": "Figyelmeztet", @@ -987,5 +987,6 @@ "RemoveCompletedDownloads": "Befejezett letöltések eltávolítása", "RemoveFailedDownloads": "Sikertelen letöltések eltávolítása", "ApplyTagsHelpTextHowToApplyImportLists": "Hogyan adjunk hozzá címkéket a kiválasztott filmhez", - "ApplyTagsHelpTextHowToApplyIndexers": "Hogyan adjunk hozzá címkéket a kiválasztott filmhez" + "ApplyTagsHelpTextHowToApplyIndexers": "Hogyan adjunk hozzá címkéket a kiválasztott filmhez", + "AutoAdd": "Automatikus hozzáadás" } diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json index 785f20eed..c5ba6151d 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_CN.json +++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json @@ -1036,7 +1036,7 @@ "AddImportList": "添加导入列表", "AddImportListImplementation": "添加导入列表 - {implementationName}", "ErrorLoadingContent": "加载此内容时出错", - "HealthMessagesInfoBox": "您可以通过单击行尾的wiki链接(图书图标)或检查[logs]({link})来查找有关这些运行状况检查消息原因的更多信息。如果你在理解这些信息方面有困难,你可以通过下面的链接联系我们的支持。", + "HealthMessagesInfoBox": "您可以通过单击行尾的wiki链接(图书图标)或检查[日志]({link})来查找有关这些运行状况检查消息原因的更多信息。如果你在理解这些信息方面有困难,你可以通过下面的链接联系我们的支持。", "ImportListRootFolderMissingRootHealthCheckMessage": "在导入列表中缺少根目录文件夹:{0}", "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "导入列表中缺失多个根目录文件夹", "NoCutoffUnmetItems": "没有未达截止条件的项目", From a60f1f128c99e6368a3005c96c8532740c0ffa7d Mon Sep 17 00:00:00 2001 From: Qstick Date: Sun, 15 Oct 2023 18:47:30 -0500 Subject: [PATCH 043/820] Fixed: Incorrectly parsing RePACKPOST as Group Closes #2294 --- src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs | 1 + src/NzbDrone.Core/Parser/Parser.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs index 18638833c..deadf024c 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs @@ -50,6 +50,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Olafur.Arnalds-Remember-WEB-2018-DIMENSION-1", "DIMENSION")] [TestCase("Olafur.Arnalds-Remember-WEB-2018-EVL-Scrambled", "EVL")] [TestCase("Olafur.Arnalds-Remember-WEB-2018-EVL-AlteZachen", "EVL")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-HarrHD-RePACKPOST", "HarrHD")] public void should_not_include_repost_in_release_group(string title, string expected) { Parser.Parser.ParseReleaseGroup(title).Should().Be(expected); diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 9f75eda48..1b33d613d 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -188,7 +188,7 @@ namespace NzbDrone.Core.Parser private static readonly Regex SixDigitAirDateRegex = new Regex(@"(?<=[_.-])(?(?[1-9]\d{1})(?[0-1][0-9])(?[0-3][0-9]))(?=[_.-])", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly RegexReplace CleanReleaseGroupRegex = new RegexReplace(@"^(.*?[-._ ])|(-(RP|1|NZBGeek|Obfuscated|Scrambled|sample|Pre|postbot|xpost|Rakuv[a-z0-9]*|WhiteRev|BUYMORE|AsRequested|AlternativeToRequested|GEROV|Z0iDS3N|Chamele0n|4P|4Planet|AlteZachen))+$", + private static readonly RegexReplace CleanReleaseGroupRegex = new RegexReplace(@"^(.*?[-._ ])|(-(RP|1|NZBGeek|Obfuscated|Scrambled|sample|Pre|postbot|xpost|Rakuv[a-z0-9]*|WhiteRev|BUYMORE|AsRequested|AlternativeToRequested|GEROV|Z0iDS3N|Chamele0n|4P|4Planet|AlteZachen|RePACKPOST))+$", string.Empty, RegexOptions.IgnoreCase | RegexOptions.Compiled); From efa0a53f1cb11b14fb4b099934332dbf748a55e9 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 1 Aug 2021 16:45:23 -0700 Subject: [PATCH 044/820] Fixed: Prevent conflicts with reserved device names Closes #2346 Closes #2349 (cherry picked from commit dc7f46027aebf33b77d258a63c2ae973788cedd0) --- .../ReservedDeviceNameFixture.cs | 97 +++++++++++++++++++ .../Organizer/FileNameBuilder.cs | 10 ++ 2 files changed, 107 insertions(+) create mode 100644 src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/ReservedDeviceNameFixture.cs diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/ReservedDeviceNameFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/ReservedDeviceNameFixture.cs new file mode 100644 index 000000000..eceb0bf2a --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/ReservedDeviceNameFixture.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Music; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests +{ + [TestFixture] + + public class ReservedDeviceNameFixture : CoreTest + { + private Artist _artist; + private Album _album; + private Track _track1; + private Medium _medium; + private AlbumRelease _release; + private TrackFile _trackFile; + private NamingConfig _namingConfig; + + [SetUp] + public void Setup() + { + _artist = Builder + .CreateNew() + .With(s => s.Name = "Tim Park") + .Build(); + + _medium = Builder + .CreateNew() + .With(m => m.Number = 3) + .Build(); + + _release = Builder + .CreateNew() + .With(s => s.Media = new List { _medium }) + .With(s => s.Monitored = true) + .Build(); + + _album = Builder + .CreateNew() + .With(s => s.Title = "Hybrid Theory") + .With(s => s.AlbumType = "Album") + .With(s => s.Disambiguation = "The Best Album") + .With(s => s.Genres = new List { "Rock" }) + .With(s => s.ForeignAlbumId = Guid.NewGuid().ToString()) + .Build(); + + _namingConfig = NamingConfig.Default; + _namingConfig.RenameTracks = true; + + Mocker.GetMock() + .Setup(c => c.GetConfig()).Returns(_namingConfig); + + _track1 = Builder.CreateNew() + .With(e => e.Title = "City Sushi") + .With(e => e.TrackNumber = "6") + .With(e => e.AbsoluteTrackNumber = 6) + .With(e => e.AlbumRelease = _release) + .With(e => e.MediumNumber = _medium.Number) + .With(e => e.ArtistMetadata = _artist.Metadata) + .Build(); + + _trackFile = new TrackFile { Quality = new QualityModel(Quality.FLAC), ReleaseGroup = "SonarrTest" }; + + Mocker.GetMock() + .Setup(v => v.Get(Moq.It.IsAny())) + .Returns(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); + } + + [TestCase("Con Game", "Con_Game")] + [TestCase("Com1 Sat", "Com1_Sat")] + public void should_replace_reserved_device_name_in_artist_folder(string title, string expected) + { + _artist.Name = title; + _namingConfig.ArtistFolderFormat = "{Artist.Name}"; + + Subject.GetArtistFolder(_artist).Should().Be(expected); + } + + [TestCase("Con Game", "Con_Game")] + [TestCase("Com1 Sat", "Com1_Sat")] + public void should_replace_reserved_device_name_in_file_name(string title, string expected) + { + _artist.Name = title; + _namingConfig.StandardTrackFormat = "{Artist.Name} - {Track Title}"; + + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile).Should().Be($"{expected} - City Sushi"); + } + } +} diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 2f0202674..e2c7e6b2d 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -69,6 +69,8 @@ namespace NzbDrone.Core.Organizer private static readonly Regex TitlePrefixRegex = new Regex(@"^(The|An|A) (.*?)((?: *\([^)]+\))*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex ReservedDeviceNamesRegex = new Regex(@"^(?:aux|com[1-9]|con|lpt[1-9]|nul|prn)\.", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public FileNameBuilder(INamingConfigService namingConfigService, IQualityDefinitionService qualityDefinitionService, ICacheManager cacheManager, @@ -144,6 +146,7 @@ namespace NzbDrone.Core.Organizer component = FileNameCleanupRegex.Replace(component, match => match.Captures[0].Value[0].ToString()); component = TrimSeparatorsRegex.Replace(component, string.Empty); component = component.Replace("{ellipsis}", "..."); + component = ReplaceReservedDeviceNames(component); if (component.IsNotNullOrWhiteSpace()) { @@ -236,6 +239,7 @@ namespace NzbDrone.Core.Organizer var component = ReplaceTokens(splitPattern, tokenHandlers, namingConfig); component = CleanFolderName(component); + component = ReplaceReservedDeviceNames(component); if (component.IsNotNullOrWhiteSpace()) { @@ -644,6 +648,12 @@ namespace NzbDrone.Core.Organizer return result.GetByteCount(); } + private string ReplaceReservedDeviceNames(string input) + { + // Replace reserved windows device names with an alternative + return ReservedDeviceNamesRegex.Replace(input, match => match.Value.Replace(".", "_")); + } + private static string CleanFileName(string name, NamingConfig namingConfig) { var result = name; From 5107fe73a1d74564d924d76dfbc041a2126820ce Mon Sep 17 00:00:00 2001 From: Qstick Date: Sun, 15 Oct 2023 19:10:35 -0500 Subject: [PATCH 045/820] Clarify delay profile bypass only applies to preferred protocol Closes #2264 Co-Authored-By: Taloth --- src/NzbDrone.Core/Localization/Core/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 40125a77e..6f13b6d72 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -129,7 +129,7 @@ "BypassIfAboveCustomFormatScore": "Bypass if Above Custom Format Score", "BypassIfAboveCustomFormatScoreHelpText": "Enable bypass when release has a score higher than the configured minimum custom format score", "BypassIfHighestQuality": "Bypass if Highest Quality", - "BypassIfHighestQualityHelpText": "Bypass delay when release has the highest enabled quality in the quality profile", + "BypassIfHighestQualityHelpText": "Bypass delay when release has the highest enabled quality in the quality profile with the preferred protocol", "BypassProxyForLocalAddresses": "Bypass Proxy for Local Addresses", "Calendar": "Calendar", "CalendarWeekColumnHeaderHelpText": "Shown above each column when week is the active view", From 3d7d43bb464d41b977d134ae3109b6e9dee721cf Mon Sep 17 00:00:00 2001 From: Qstick Date: Sun, 15 Oct 2023 19:37:51 -0500 Subject: [PATCH 046/820] Fixed: Enable parsing of repacks with revision Closes #3320 (cherry picked from commit e29470d8cb9dd3d4d75a3abe1b9b7e8df4f2df4a) --- .../ParserTests/QualityParserFixture.cs | 13 ++++---- src/NzbDrone.Core/Parser/QualityParser.cs | 30 +++++++++++-------- src/NzbDrone.Core/Qualities/QualityModel.cs | 3 ++ 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs index c6ae83354..92986c379 100644 --- a/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs @@ -307,13 +307,16 @@ namespace NzbDrone.Core.Test.ParserTests QualityParser.ParseCodec(null, null).Should().Be(Codec.Unknown); } - [TestCase("Artist Title - Album Title 2017 REPACK FLAC aAF", true)] - [TestCase("Artist Title - Album Title 2017 RERIP FLAC aAF", true)] - [TestCase("Artist Title - Album Title 2017 PROPER FLAC aAF", false)] - public void should_be_able_to_parse_repack(string title, bool isRepack) + [TestCase("Artist Title - Album Title 2017 REPACK FLAC aAF", true, 2)] + [TestCase("Artist.Title-Album.Title.2017.REPACK.FLAC-aAF", true, 2)] + [TestCase("Artist.Title-Album.Title.2017.REPACK2.FLAC-aAF", true, 3)] + [TestCase("Artist Title - Album Title 2017 RERIP FLAC aAF", true, 2)] + [TestCase("Artist Title - Album Title 2017 RERIP2 FLAC aAF", true, 3)] + [TestCase("Artist Title - Album Title 2017 PROPER FLAC aAF", false, 2)] + public void should_be_able_to_parse_repack(string title, bool isRepack, int version) { var result = QualityParser.ParseQuality(title, null, 0); - result.Revision.Version.Should().Be(2); + result.Revision.Version.Should().Be(version); result.Revision.IsRepack.Should().Be(isRepack); } diff --git a/src/NzbDrone.Core/Parser/QualityParser.cs b/src/NzbDrone.Core/Parser/QualityParser.cs index c2d3aa3c6..1ca788a83 100644 --- a/src/NzbDrone.Core/Parser/QualityParser.cs +++ b/src/NzbDrone.Core/Parser/QualityParser.cs @@ -15,10 +15,10 @@ namespace NzbDrone.Core.Parser private static readonly Regex ProperRegex = new Regex(@"\b(?proper)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex RepackRegex = new Regex(@"\b(?repack|rerip)\b", + private static readonly Regex RepackRegex = new Regex(@"\b(?repack\d?|rerip\d?)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex VersionRegex = new Regex(@"\d[-._ ]?v(?\d)[-._ ]|\[v(?\d)\]", + private static readonly Regex VersionRegex = new Regex(@"\d[-._ ]?v(?\d)[-._ ]|\[v(?\d)\]|repack(?\d)|rerip(?\d)", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex RealRegex = new Regex(@"\b(?REAL)\b", @@ -616,22 +616,25 @@ namespace NzbDrone.Core.Parser { var result = new QualityModel { Quality = Quality.Unknown }; - if (ProperRegex.IsMatch(normalizedName)) - { - result.Revision.Version = 2; - } - - if (RepackRegex.IsMatch(normalizedName)) - { - result.Revision.Version = 2; - result.Revision.IsRepack = true; - } - var versionRegexResult = VersionRegex.Match(normalizedName); if (versionRegexResult.Success) { result.Revision.Version = Convert.ToInt32(versionRegexResult.Groups["version"].Value); + result.RevisionDetectionSource = QualityDetectionSource.Name; + } + + if (ProperRegex.IsMatch(normalizedName)) + { + result.Revision.Version = versionRegexResult.Success ? Convert.ToInt32(versionRegexResult.Groups["version"].Value) + 1 : 2; + result.RevisionDetectionSource = QualityDetectionSource.Name; + } + + if (RepackRegex.IsMatch(normalizedName)) + { + result.Revision.Version = versionRegexResult.Success ? Convert.ToInt32(versionRegexResult.Groups["version"].Value) + 1 : 2; + result.Revision.IsRepack = true; + result.RevisionDetectionSource = QualityDetectionSource.Name; } // TODO: re-enable this when we have a reliable way to determine real @@ -640,6 +643,7 @@ namespace NzbDrone.Core.Parser if (realRegexResult.Count > 0) { result.Revision.Real = realRegexResult.Count; + result.RevisionDetectionSource = QualityDetectionSource.Name; } return result; diff --git a/src/NzbDrone.Core/Qualities/QualityModel.cs b/src/NzbDrone.Core/Qualities/QualityModel.cs index 566c98080..3a1499f60 100644 --- a/src/NzbDrone.Core/Qualities/QualityModel.cs +++ b/src/NzbDrone.Core/Qualities/QualityModel.cs @@ -13,6 +13,9 @@ namespace NzbDrone.Core.Qualities [JsonIgnore] public QualityDetectionSource QualityDetectionSource { get; set; } + [JsonIgnore] + public QualityDetectionSource RevisionDetectionSource { get; set; } + public QualityModel() : this(Quality.Unknown, new Revision()) { From 09dcbf2618a9df453aaa43e914f2bebab1dffb31 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Wed, 19 Apr 2023 16:00:05 -0700 Subject: [PATCH 047/820] Fixed: Tag filtering on iCal feed Closes #3547 (cherry picked from commit a989c84260923485b4baa81ba47929979cfe9aa5) --- src/Lidarr.Api.V1/Calendar/CalendarFeedController.cs | 10 +++++----- src/NzbDrone.Core/Parser/Model/ParsedAlbumInfo.cs | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Lidarr.Api.V1/Calendar/CalendarFeedController.cs b/src/Lidarr.Api.V1/Calendar/CalendarFeedController.cs index 7c3a03afe..d779ecc51 100644 --- a/src/Lidarr.Api.V1/Calendar/CalendarFeedController.cs +++ b/src/Lidarr.Api.V1/Calendar/CalendarFeedController.cs @@ -28,15 +28,15 @@ namespace Lidarr.Api.V1.Calendar } [HttpGet("Lidarr.ics")] - public IActionResult GetCalendarFeed(int pastDays = 7, int futureDays = 28, string tagList = "", bool unmonitored = false) + public IActionResult GetCalendarFeed(int pastDays = 7, int futureDays = 28, string tags = "", bool unmonitored = false) { var start = DateTime.Today.AddDays(-pastDays); var end = DateTime.Today.AddDays(futureDays); - var tags = new List(); + var parsedTags = new List(); - if (tagList.IsNotNullOrWhiteSpace()) + if (tags.IsNotNullOrWhiteSpace()) { - tags.AddRange(tagList.Split(',').Select(_tagService.GetTag).Select(t => t.Id)); + parsedTags.AddRange(tags.Split(',').Select(_tagService.GetTag).Select(t => t.Id)); } var albums = _albumService.AlbumsBetweenDates(start, end, unmonitored); @@ -53,7 +53,7 @@ namespace Lidarr.Api.V1.Calendar { var artist = _artistService.GetArtist(album.ArtistId); // Temp fix TODO: Figure out why Album.Artist is not populated during AlbumsBetweenDates Query - if (tags.Any() && tags.None(artist.Tags.Contains)) + if (parsedTags.Any() && parsedTags.None(artist.Tags.Contains)) { continue; } diff --git a/src/NzbDrone.Core/Parser/Model/ParsedAlbumInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedAlbumInfo.cs index 0fb1b700e..be30e257f 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedAlbumInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedAlbumInfo.cs @@ -8,6 +8,7 @@ namespace NzbDrone.Core.Parser.Model { public string AlbumTitle { get; set; } public string ArtistName { get; set; } + public string AlbumType { get; set; } public ArtistTitleInfo ArtistTitleInfo { get; set; } public QualityModel Quality { get; set; } public string ReleaseDate { get; set; } From b9a5ff55ad5cceb235701a3645394047691668cf Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 16 Oct 2023 12:37:45 +0300 Subject: [PATCH 048/820] New: Added search support to Nyaa --- src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs | 2 +- .../Indexers/Nyaa/NyaaRequestGenerator.cs | 24 +++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs b/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs index d2d63cbf0..89ecaadb3 100644 --- a/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs +++ b/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs @@ -10,7 +10,7 @@ namespace NzbDrone.Core.Indexers.Nyaa public override string Name => "Nyaa"; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; - public override int PageSize => 100; + public override int PageSize => 75; public Nyaa(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger) : base(httpClient, indexerStatusService, configService, parsingService, logger) diff --git a/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs index 8157e5fc0..0b2d15ceb 100644 --- a/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using NzbDrone.Common.Http; using NzbDrone.Core.IndexerSearch.Definitions; @@ -13,8 +14,8 @@ namespace NzbDrone.Core.Indexers.Nyaa public NyaaRequestGenerator() { - MaxPages = 30; - PageSize = 100; + MaxPages = 3; + PageSize = 75; } public virtual IndexerPageableRequestChain GetRecentRequests() @@ -28,12 +29,25 @@ namespace NzbDrone.Core.Indexers.Nyaa public virtual IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) { - throw new System.NotImplementedException(); + var pageableRequests = new IndexerPageableRequestChain(); + + var artistQuery = searchCriteria.CleanArtistQuery.Replace("+", " ").Trim(); + var albumQuery = searchCriteria.CleanAlbumQuery.Replace("+", " ").Trim(); + + pageableRequests.Add(GetPagedRequests(MaxPages, PrepareQuery($"{artistQuery} {albumQuery}"))); + + return pageableRequests; } public virtual IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) { - throw new System.NotImplementedException(); + var pageableRequests = new IndexerPageableRequestChain(); + + var artistQuery = searchCriteria.CleanArtistQuery.Replace("+", " ").Trim(); + + pageableRequests.Add(GetPagedRequests(MaxPages, PrepareQuery(artistQuery))); + + return pageableRequests; } private IEnumerable GetPagedRequests(int maxPages, string term) @@ -62,7 +76,7 @@ namespace NzbDrone.Core.Indexers.Nyaa private string PrepareQuery(string query) { - return query.Replace(' ', '+'); + return Uri.EscapeDataString(query); } } } From 16c27b2d00a98c74826e1773e3ca3fc668850863 Mon Sep 17 00:00:00 2001 From: Servarr Date: Mon, 16 Oct 2023 00:59:15 +0000 Subject: [PATCH 049/820] Automated API Docs update --- src/Lidarr.Api.V1/openapi.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Lidarr.Api.V1/openapi.json b/src/Lidarr.Api.V1/openapi.json index 50d119870..9d470cd9c 100644 --- a/src/Lidarr.Api.V1/openapi.json +++ b/src/Lidarr.Api.V1/openapi.json @@ -1061,7 +1061,7 @@ } }, { - "name": "tagList", + "name": "tags", "in": "query", "schema": { "type": "string", @@ -11246,6 +11246,10 @@ "type": "string", "nullable": true }, + "albumType": { + "type": "string", + "nullable": true + }, "artistTitleInfo": { "$ref": "#/components/schemas/ArtistTitleInfo" }, From 0ec7d030bc1f6bedb0f840555d80ba5013293a42 Mon Sep 17 00:00:00 2001 From: Weblate Date: Mon, 16 Oct 2023 09:41:10 +0000 Subject: [PATCH 050/820] Multiple Translations updated by Weblate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ignore-downstream Co-authored-by: Weblate Co-authored-by: jianl Co-authored-by: 宿命 <331874545@qq.com> Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/zh_CN/ Translation: Servarr/Lidarr --- .../Localization/Core/zh_CN.json | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json index c5ba6151d..8bdd6cf75 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_CN.json +++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json @@ -7,7 +7,7 @@ "Authentication": "认证", "Dates": "日期", "DeleteBackupMessageText": "您确定要删除备份“{name}”吗?", - "DeleteNotificationMessageText": "您确定要删除消息推送 “{name}” 吗?", + "DeleteNotificationMessageText": "您确定要删除通知“{name}”吗?", "Docker": "Docker", "DownloadClient": "下载客户端", "DownloadClients": "下载客户端", @@ -62,7 +62,7 @@ "DeleteDownloadClient": "删除下载客户端", "DeleteDownloadClientMessageText": "你确定要删除下载客户端 “{name}” 吗?", "DeleteIndexer": "删除索引器", - "DeleteIndexerMessageText": "您确定要删除索引器 “{name}” 吗?", + "DeleteIndexerMessageText": "您确定要删除索引器“{name}”吗?", "DeleteNotification": "删除消息推送", "DeleteTag": "删除标签", "DeleteTagMessageText": "您确定要删除标签 '{0}' 吗?", @@ -76,7 +76,7 @@ "EnableInteractiveSearch": "启用手动搜索", "EnableSSL": "启用SSL", "EnableSslHelpText": " 重启生效", - "ErrorLoadingContents": "读取内容错误", + "ErrorLoadingContents": "加载内容出错", "Exception": "例外", "Filename": "文件名", "Files": "文件", @@ -126,7 +126,7 @@ "Refresh": "刷新", "Reload": "重新加载", "RemovedFromTaskQueue": "从任务队列中移除", - "RemoveFilter": "移除过滤条件", + "RemoveFilter": "移除过滤器", "Reset": "重置", "Restart": "重启", "RestartNow": "马上重启", @@ -230,7 +230,7 @@ "Blocklist": "黑名单", "BlocklistRelease": "黑名单版本", "Calendar": "日历", - "ClickToChangeQuality": "点击修改质量", + "ClickToChangeQuality": "点击更改质量", "CancelMessageText": "您确定要取消这个挂起的任务吗?", "ChangeFileDate": "修改文件日期", "ChmodFolder": "修改文件夹权限", @@ -272,7 +272,7 @@ "RefreshScan": "刷新&扫描", "ReleaseDate": "发布日期", "ReleaseGroup": "发布组", - "ReleaseRejected": "版本被拒绝", + "ReleaseRejected": "发布被拒绝", "ReleaseStatuses": "发布状态", "ReleaseWillBeProcessedInterp": "发布将被处理{0}", "RemotePathHelpText": "下载客户端访问的目录的根路径", @@ -392,7 +392,7 @@ "DeleteImportListMessageText": "您确定要删除列表 “{name}” 吗?", "DeleteMetadataProfileMessageText": "您确定要删除元数据配置文件“{name}”吗?", "DeleteQualityProfile": "删除质量配置", - "DeleteQualityProfileMessageText": "你确定要删除质量配置 “{name}” 吗?", + "DeleteQualityProfileMessageText": "您确定要删除质量配置“{name}”吗?", "DeleteReleaseProfile": "删除发布组配置", "DeleteReleaseProfileMessageText": "你确定你要删除这个发行版配置文件?", "DeleteRootFolderMessageText": "您确定要删除根文件夹“{name}”吗?", @@ -584,7 +584,7 @@ "ContinuingAllTracksDownloaded": "仍在继续(所有书籍已下载)", "ContinuingNoAdditionalAlbumsAreExpected": "预计不会有其他书籍", "Country": "国家", - "CustomFilters": "自定义过滤", + "CustomFilters": "自定义过滤器", "Date": "日期", "DefaultDelayProfileHelpText": "这是默认配置档案,它适用于所有没有明确配置档案的歌手。", "Deleted": "已删除", @@ -683,7 +683,7 @@ "Select...": "'选择...", "SelectedCountArtistsSelectedInterp": "{selectedCount}艺术家已选中", "SelectFolder": "选择文件夹", - "SelectQuality": "选择质量", + "SelectQuality": "选择品质", "ShouldMonitorExistingHelpText": "自动监控此列表中已经在Readarr中的书籍", "ShouldSearch": "搜索新项目", "ShouldSearchHelpText": "在索引器中搜索新添加的项目, 小心使用长列表。", @@ -703,7 +703,7 @@ "TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp": "共{0}本书籍 . {1} 有文件.", "TrackTitle": "跟踪标题", "UI": "UI界面", - "UnmappedFilesOnly": "未映射的文件", + "UnmappedFilesOnly": "仅限未映射的文件", "UnmonitoredOnly": "监控中", "UpgradesAllowed": "允许升级", "Wanted": "想要的", @@ -845,7 +845,7 @@ "ThemeHelpText": "改变应用界面主题,选择“自动”主题会通过操作系统主题来自适应白天黑夜模式。(受Theme.Park启发)", "MassAlbumsCutoffUnmetWarning": "你确定要搜索所有 '{0}' 个缺失专辑么?", "ChooseImportMethod": "选择导入模式", - "ClickToChangeReleaseGroup": "点击修改发布组", + "ClickToChangeReleaseGroup": "单击更改发布组", "BypassIfHighestQuality": "如果质量最高,则绕过", "CustomFormatScore": "自定义格式分数", "MinimumCustomFormatScore": "最小自定义格式分数", @@ -1026,16 +1026,16 @@ "NotificationStatusAllClientHealthCheckMessage": "由于故障,所有通知都不可用", "NotificationStatusSingleClientHealthCheckMessage": "由于失败导致通知不可用:{0}", "RecentChanges": "最近修改", - "AddConnectionImplementation": "添加集合 - {implementationName}", - "AddDownloadClientImplementation": "添加下载客户端 - {implementationName}", + "AddConnectionImplementation": "添加连接- {implementationName}", + "AddDownloadClientImplementation": "添加下载客户端- {implementationName}", "AddIndexerImplementation": "添加索引器 - {implementationName}", "AutomaticUpdatesDisabledDocker": "不支持在使用 Docker 容器时直接升级。你需要升级 {appName} 容器镜像或使用脚本(script)", "Clone": "复制", "AppUpdated": "{appName} 升级", "AddConditionImplementation": "添加条件 - {implementationName}", "AddImportList": "添加导入列表", - "AddImportListImplementation": "添加导入列表 - {implementationName}", - "ErrorLoadingContent": "加载此内容时出错", + "AddImportListImplementation": "添加导入列表- {implementationName}", + "ErrorLoadingContent": "加载此内容时出现错误", "HealthMessagesInfoBox": "您可以通过单击行尾的wiki链接(图书图标)或检查[日志]({link})来查找有关这些运行状况检查消息原因的更多信息。如果你在理解这些信息方面有困难,你可以通过下面的链接联系我们的支持。", "ImportListRootFolderMissingRootHealthCheckMessage": "在导入列表中缺少根目录文件夹:{0}", "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "导入列表中缺失多个根目录文件夹", @@ -1048,11 +1048,11 @@ "DeleteFormat": "删除格式", "CloneCondition": "克隆条件", "DashOrSpaceDashDependingOnName": "破折号或空格破折号取决于名字", - "EditConditionImplementation": "编辑条件 - {implementationName}", - "EditConnectionImplementation": "编辑连接 - {implementationName}", - "EditDownloadClientImplementation": "编辑下载客户端 - {implementationName}", - "EditImportListImplementation": "编辑导入列表 - {implementationName}", - "EditIndexerImplementation": "编辑索引器 - {implementationName}", + "EditConditionImplementation": "编辑条件- {implementationName}", + "EditConnectionImplementation": "编辑连接- {implementationName}", + "EditDownloadClientImplementation": "编辑下载客户端- {implementationName}", + "EditImportListImplementation": "编辑导入列表- {implementationName}", + "EditIndexerImplementation": "编辑索引器- {implementationName}", "Enabled": "已启用", "NoMissingItems": "没有缺失的项目", "Priority": "优先级", From f35173e670fa33a68f05ae1d8d3f63bc427e51fd Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Tue, 5 Apr 2022 19:10:26 -0500 Subject: [PATCH 051/820] New: Support for new Nyaa RSS Feed format (cherry picked from commit 40ecdbc12de8b320a4d650aea65a36e8edea77d8) Closes #2738 --- .../Files/Indexers/Nyaa/Nyaa2021.xml | 66 +++++++++++++++++ .../IndexerTests/NyaaTests/NyaaFixture.cs | 43 +++++++++-- .../Indexers/EzrssTorrentRssParser.cs | 52 ++------------ src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs | 2 +- .../Indexers/TorrentRssParser.cs | 71 +++++++++++++++++-- 5 files changed, 172 insertions(+), 62 deletions(-) create mode 100644 src/NzbDrone.Core.Test/Files/Indexers/Nyaa/Nyaa2021.xml diff --git a/src/NzbDrone.Core.Test/Files/Indexers/Nyaa/Nyaa2021.xml b/src/NzbDrone.Core.Test/Files/Indexers/Nyaa/Nyaa2021.xml new file mode 100644 index 000000000..0d63ce046 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Indexers/Nyaa/Nyaa2021.xml @@ -0,0 +1,66 @@ + + + Nyaa - Home - Torrent File RSS + RSS Feed for Home + https://nyaa.si/ + + + [Foxy-Subs] Mahouka Koukou no Yuutousei - 08 [720p] [3194D881].mkv + https://nyaa.si/download/1424896.torrent + https://nyaa.si/view/1424896 + Tue, 24 Aug 2021 22:18:46 -0000 + 4 + 3 + 2 + e8ca5e20eca876339f41c3d9e95ea66c1d7caaee + 1_3 + Anime - Non-English-translated + 609.6 MiB + 0 + No + No + + #1424896 | [Foxy-Subs] Mahouka Koukou no Yuutousei - 08 [720p] [3194D881].mkv | 609.6 MiB | Anime - Non-English-translated | E8CA5E20ECA876339F41C3D9E95EA66C1D7CAAEE ]]> + + + + Macross Zero (BDRip 1920x1080p x265 HEVC TrueHD, FLAC 5.1+2.0)[sxales] + https://nyaa.si/download/1424895.torrent + https://nyaa.si/view/1424895 + Tue, 24 Aug 2021 22:03:11 -0000 + 23 + 32 + 17 + 26f37f26d5b3475b41a98dc575fabfa6f8d32a76 + 1_2 + Anime - English-translated + 5.7 GiB + 2 + No + No + + #1424895 | Macross Zero (BDRip 1920x1080p x265 HEVC TrueHD, FLAC 5.1+2.0)[sxales] | 5.7 GiB | Anime - English-translated | 26F37F26D5B3475B41A98DC575FABFA6F8D32A76 ]]> + + + + Fumetsu no Anata e - 19 [WEBDL 1080p] Ukr DVO + https://nyaa.si/download/1424887.torrent + https://nyaa.si/view/1424887 + Tue, 24 Aug 2021 21:23:06 -0000 + 5 + 4 + 4 + 3e4300e24b39983802162877755aab4380bd137a + 1_3 + Anime - Non-English-translated + 1.4 GiB + 0 + No + No + + #1424887 | Fumetsu no Anata e - 19 [WEBDL 1080p] Ukr DVO | 1.4 GiB | Anime - Non-English-translated | 3E4300E24B39983802162877755AAB4380BD137A ]]> + + + + \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs index c98c9126d..5f42b5012 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs @@ -26,16 +26,18 @@ namespace NzbDrone.Core.Test.IndexerTests.NyaaTests }; } - [Test] - public async Task should_parse_recent_feed_from_Nyaa() +/* [Test] + // Legacy Nyaa feed test + + public void should_parse_recent_feed_from_Nyaa() { var recentFeed = ReadAllText(@"Files/Indexers/Nyaa/Nyaa.xml"); Mocker.GetMock() - .Setup(o => o.ExecuteAsync(It.Is(v => v.Method == HttpMethod.Get))) - .Returns(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), recentFeed))); + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.Get))) + .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); - var releases = await Subject.FetchRecent(); + var releases = Subject.FetchRecent(); releases.Should().HaveCount(4); releases.First().Should().BeOfType(); @@ -49,11 +51,40 @@ namespace NzbDrone.Core.Test.IndexerTests.NyaaTests torrentInfo.CommentUrl.Should().BeNullOrEmpty(); torrentInfo.Indexer.Should().Be(Subject.Definition.Name); torrentInfo.PublishDate.Should().Be(DateTime.Parse("2014/08/14 18:10:36")); - torrentInfo.Size.Should().Be(2523293286); // 2.35 GiB + torrentInfo.Size.Should().Be(2523293286); //2.35 GiB torrentInfo.InfoHash.Should().Be(null); torrentInfo.MagnetUrl.Should().Be(null); torrentInfo.Peers.Should().Be(2 + 1); torrentInfo.Seeders.Should().Be(1); + }*/ + + [Test] + public async Task should_parse_2021_recent_feed_from_Nyaa() + { + var recentFeed = ReadAllText(@"Files/Indexers/Nyaa/Nyaa2021.xml"); + + Mocker.GetMock() + .Setup(o => o.ExecuteAsync(It.Is(v => v.Method == HttpMethod.Get))) + .Returns(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), recentFeed))); + + var releases = await Subject.FetchRecent(); + + releases.Should().HaveCount(3); + releases.First().Should().BeOfType(); + + var torrentInfo = releases.First() as TorrentInfo; + + torrentInfo.Title.Should().Be("[Foxy-Subs] Mahouka Koukou no Yuutousei - 08 [720p] [3194D881].mkv"); + torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + torrentInfo.DownloadUrl.Should().Be("https://nyaa.si/download/1424896.torrent"); + torrentInfo.InfoUrl.Should().Be("https://nyaa.si/view/1424896"); + torrentInfo.CommentUrl.Should().BeNullOrEmpty(); + torrentInfo.Indexer.Should().Be(Subject.Definition.Name); + torrentInfo.PublishDate.Should().Be(DateTime.Parse("Tue, 24 Aug 2021 22:18:46")); + torrentInfo.Size.Should().Be(639211930); // 609.6 MiB + torrentInfo.MagnetUrl.Should().Be(null); + torrentInfo.Seeders.Should().Be(4); + torrentInfo.Peers.Should().Be(3 + 4); } } } diff --git a/src/NzbDrone.Core/Indexers/EzrssTorrentRssParser.cs b/src/NzbDrone.Core/Indexers/EzrssTorrentRssParser.cs index dd5a2b3e2..b174b8573 100644 --- a/src/NzbDrone.Core/Indexers/EzrssTorrentRssParser.cs +++ b/src/NzbDrone.Core/Indexers/EzrssTorrentRssParser.cs @@ -1,6 +1,4 @@ using System.Linq; -using System.Xml.Linq; -using NzbDrone.Common.Extensions; using NzbDrone.Core.Indexers.Exceptions; namespace NzbDrone.Core.Indexers @@ -12,6 +10,10 @@ namespace NzbDrone.Core.Indexers UseGuidInfoUrl = true; UseEnclosureLength = false; UseEnclosureUrl = true; + SeedsElementName = "seeds"; + InfoHashElementName = "infoHash"; + SizeElementName = "contentLength"; + MagnetElementName = "magnetURI"; } protected override bool PreProcess(IndexerResponse indexerResponse) @@ -28,51 +30,5 @@ namespace NzbDrone.Core.Indexers return true; } - - protected override long GetSize(XElement item) - { - var contentLength = item.FindDecendants("contentLength").SingleOrDefault(); - - if (contentLength != null) - { - return (long)contentLength; - } - - return base.GetSize(item); - } - - protected override string GetInfoHash(XElement item) - { - var infoHash = item.FindDecendants("infoHash").SingleOrDefault(); - return (string)infoHash; - } - - protected override string GetMagnetUrl(XElement item) - { - var magnetURI = item.FindDecendants("magnetURI").SingleOrDefault(); - return (string)magnetURI; - } - - protected override int? GetSeeders(XElement item) - { - var seeds = item.FindDecendants("seeds").SingleOrDefault(); - if (seeds != null) - { - return (int)seeds; - } - - return base.GetSeeders(item); - } - - protected override int? GetPeers(XElement item) - { - var peers = item.FindDecendants("peers").SingleOrDefault(); - if (peers != null) - { - return (int)peers; - } - - return base.GetPeers(item); - } } } diff --git a/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs b/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs index 89ecaadb3..670baecc9 100644 --- a/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs +++ b/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs @@ -24,7 +24,7 @@ namespace NzbDrone.Core.Indexers.Nyaa public override IParseIndexerResponse GetParser() { - return new TorrentRssParser() { UseGuidInfoUrl = true, ParseSizeInDescription = true, ParseSeedersInDescription = true }; + return new TorrentRssParser() { UseGuidInfoUrl = true, SizeElementName = "size", InfoHashElementName = "infoHash", PeersElementName = "leechers", CalculatePeersAsSum = true, SeedsElementName = "seeders" }; } } } diff --git a/src/NzbDrone.Core/Indexers/TorrentRssParser.cs b/src/NzbDrone.Core/Indexers/TorrentRssParser.cs index 5efe5fd64..712c23d39 100644 --- a/src/NzbDrone.Core/Indexers/TorrentRssParser.cs +++ b/src/NzbDrone.Core/Indexers/TorrentRssParser.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using System.Xml.Linq; using MonoTorrent; @@ -9,12 +10,27 @@ namespace NzbDrone.Core.Indexers { public class TorrentRssParser : RssParser { + // Use to sum/calculate Peers as Leechers+Seeders + public bool CalculatePeersAsSum { get; set; } + + // Use the specified element name to determine the Infohash + public string InfoHashElementName { get; set; } + // Parse various seeder/leecher/peers formats in the description element to determine number of seeders. public bool ParseSeedersInDescription { get; set; } - // Use the specified element name to determine the size + // Use the specified element name to determine the Peers + public string PeersElementName { get; set; } + + // Use the specified element name to determine the Seeds + public string SeedsElementName { get; set; } + + // Use the specified element name to determine the Size public string SizeElementName { get; set; } + // Use the specified element name to determine the Magnet link + public string MagnetElementName { get; set; } + public TorrentRssParser() { PreferredEnclosureMimeTypes = TorrentEnclosureMimeTypes; @@ -40,14 +56,28 @@ namespace NzbDrone.Core.Indexers result.InfoHash = GetInfoHash(item); result.MagnetUrl = GetMagnetUrl(item); result.Seeders = GetSeeders(item); - result.Peers = GetPeers(item); + + if (CalculatePeersAsSum) + { + result.Peers = GetPeers(item) + result.Seeders; + } + else + { + result.Peers = GetPeers(item); + } return result; } protected virtual string GetInfoHash(XElement item) { + if (InfoHashElementName.IsNotNullOrWhiteSpace()) + { + return item.FindDecendants(InfoHashElementName).FirstOrDefault().Value; + } + var magnetUrl = GetMagnetUrl(item); + if (magnetUrl.IsNotNullOrWhiteSpace()) { try @@ -64,10 +94,21 @@ namespace NzbDrone.Core.Indexers protected virtual string GetMagnetUrl(XElement item) { - var downloadUrl = GetDownloadUrl(item); - if (downloadUrl.IsNotNullOrWhiteSpace() && downloadUrl.StartsWith("magnet:")) + if (MagnetElementName.IsNotNullOrWhiteSpace()) { - return downloadUrl; + var magnetURL = item.FindDecendants(MagnetElementName).FirstOrDefault().Value; + if (magnetURL.IsNotNullOrWhiteSpace() && magnetURL.StartsWith("magnet:")) + { + return magnetURL; + } + } + else + { + var downloadUrl = GetDownloadUrl(item); + if (downloadUrl.IsNotNullOrWhiteSpace() && downloadUrl.StartsWith("magnet:")) + { + return downloadUrl; + } } return null; @@ -75,6 +116,9 @@ namespace NzbDrone.Core.Indexers protected virtual int? GetSeeders(XElement item) { + // safe to always use the element if it's present (and valid) + // fall back to description if ParseSeedersInDescription is enabled + if (ParseSeedersInDescription && item.Element("description") != null) { var matchSeeders = ParseSeedersRegex.Match(item.Element("description").Value); @@ -93,6 +137,12 @@ namespace NzbDrone.Core.Indexers } } + var seeds = item.FindDecendants(SeedsElementName).SingleOrDefault(); + if (seeds != null) + { + return (int)seeds; + } + return null; } @@ -116,6 +166,12 @@ namespace NzbDrone.Core.Indexers } } + if (PeersElementName.IsNotNullOrWhiteSpace()) + { + var itempeers = item.FindDecendants(PeersElementName).SingleOrDefault(); + return int.Parse(itempeers.Value); + } + return null; } @@ -124,9 +180,10 @@ namespace NzbDrone.Core.Indexers var size = base.GetSize(item); if (size == 0 && SizeElementName.IsNotNullOrWhiteSpace()) { - if (item.Element(SizeElementName) != null) + var itemsize = item.FindDecendants(SizeElementName).SingleOrDefault(); + if (itemsize != null) { - size = ParseSize(item.Element(SizeElementName).Value, true); + size = ParseSize(itemsize.Value, true); } } From 6907b43c0f6ae1370e959468dda358ea6dd1c4c9 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 17 Oct 2023 09:50:23 +0300 Subject: [PATCH 052/820] Fixed: Ignore case when cleansing announce URLs (cherry picked from commit 41ed300899e8d7de82b1113d13ac6f6cf28cec17) --- .../CleanseLogMessageFixture.cs | 18 +++++++++--------- .../Instrumentation/CleanseLogMessage.cs | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs index 3bb8ee67f..0a646670e 100644 --- a/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs +++ b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs @@ -82,15 +82,15 @@ namespace NzbDrone.Common.Test.InstrumentationTests [TestCase(@"[Info] MigrationController: *** Migrating Database=lidarr-main;Host=postgres14;Username=mySecret;Password=mySecret;Port=5432;token=mySecret;Enlist=False&username=mySecret;mypassword=mySecret;mypass=shouldkeep1;test_token=mySecret;password=123%@%_@!#^#@;use_password=mySecret;get_token=shouldkeep2;usetoken=shouldkeep3;passwrd=mySecret;")] // Announce URLs (passkeys) Magnet & Tracker - [TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2f9pr04sg601233210imaveql2tyu8xyui%2fannounce""}")] - [TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2ftracker.php%2f9pr04sg601233210imaveql2tyu8xyui%2fannounce""}")] - [TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2fannounce%2f9pr04sg601233210imaveql2tyu8xyui""}")] - [TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2fannounce.php%3fpasskey%3d9pr04sg601233210imaveql2tyu8xyui""}")] - [TestCase(@"tracker"":""https://xxx.yyy/9pr04sg601233210imaveql2tyu8xyui/announce""}")] - [TestCase(@"tracker"":""https://xxx.yyy/tracker.php/9pr04sg601233210imaveql2tyu8xyui/announce""}")] - [TestCase(@"tracker"":""https://xxx.yyy/announce/9pr04sg601233210imaveql2tyu8xyui""}")] - [TestCase(@"tracker"":""https://xxx.yyy/announce.php?passkey=9pr04sg601233210imaveql2tyu8xyui""}")] - [TestCase(@"tracker"":""http://xxx.yyy/announce.php?passkey=9pr04sg601233210imaveql2tyu8xyui"",""info"":""http://xxx.yyy/info?a=b""")] + [TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2f9pr04sg601233210IMAveQL2tyu8xyui%2fannounce""}")] + [TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2ftracker.php%2f9pr04sg601233210IMAveQL2tyu8xyui%2fannounce""}")] + [TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2fannounce%2f9pr04sg601233210IMAveQL2tyu8xyui""}")] + [TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2fannounce.php%3fpasskey%3d9pr04sg601233210IMAveQL2tyu8xyui""}")] + [TestCase(@"tracker"":""https://xxx.yyy/9pr04sg601233210IMAveQL2tyu8xyui/announce""}")] + [TestCase(@"tracker"":""https://xxx.yyy/tracker.php/9pr04sg601233210IMAveQL2tyu8xyui/announce""}")] + [TestCase(@"tracker"":""https://xxx.yyy/announce/9pr04sg601233210IMAveQL2tyu8xyui""}")] + [TestCase(@"tracker"":""https://xxx.yyy/announce.php?passkey=9pr04sg601233210IMAveQL2tyu8xyui""}")] + [TestCase(@"tracker"":""http://xxx.yyy/announce.php?passkey=9pr04sg601233210IMAveQL2tyu8xyui"",""info"":""http://xxx.yyy/info?a=b""")] // Webhooks - Notifiarr [TestCase(@"https://xxx.yyy/api/v1/notification/lidarr/mySecret")] diff --git a/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs b/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs index 92179f061..10cc8b5a3 100644 --- a/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs +++ b/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs @@ -20,7 +20,7 @@ namespace NzbDrone.Common.Instrumentation new (@"\b(\w*)?(_?(?[^&=]+?)(?= |&|$|;)", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Trackers Announce Keys; Designed for Qbit Json; should work for all in theory - new (@"announce(\.php)?(/|%2f|%3fpasskey%3d)(?[a-z0-9]{16,})|(?[a-z0-9]{16,})(/|%2f)announce"), + new (@"announce(\.php)?(/|%2f|%3fpasskey%3d)(?[a-z0-9]{16,})|(?[a-z0-9]{16,})(/|%2f)announce", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Path new (@"C:\\Users\\(?[^\""]+?)(\\|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), From b3fbf0cb7e060bd951ac61fae6631c0f709192b1 Mon Sep 17 00:00:00 2001 From: Weblate Date: Wed, 18 Oct 2023 23:50:36 +0000 Subject: [PATCH 053/820] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: Havok Dan Co-authored-by: Lizandra Candido da Silva Co-authored-by: Weblate Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pt_BR/ Translation: Servarr/Lidarr --- .../Localization/Core/pt_BR.json | 186 +++++++++--------- 1 file changed, 93 insertions(+), 93 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index 23e665507..9db5df748 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -27,7 +27,7 @@ "AppDataDirectory": "Diretório AppData", "AddMissing": "Adicionar ausentes", "AddNewItem": "Adicionar Novo Item", - "AgeWhenGrabbed": "Idade (quando baixado)", + "AgeWhenGrabbed": "Tempo de vida (quando obtido)", "Album": "Álbum", "ApplyTags": "Aplicar Tags", "AlbumIsDownloadingInterp": "O álbum está baixando: {0}% {1}", @@ -46,7 +46,7 @@ "AlternateTitles": "Títulos alternativos", "AlternateTitleslength1Title": "Título", "AlternateTitleslength1Titles": "Títulos", - "Analytics": "Analítica", + "Analytics": "Análises", "AnalyticsEnabledHelpText": "Envie informações anônimas de uso e erro para os servidores do Lidarr. Isso inclui informações sobre seu navegador, quais páginas da interface Web do Lidarr você usa, relatórios de erros, e a versão do sistema operacional e do tempo de execução. Usaremos essas informações para priorizar recursos e correções de bugs.", "AnalyticsEnabledHelpTextWarning": "Requer reinício para ter efeito", "AnchorTooltip": "Este arquivo já está na sua biblioteca em um lançamento que está sendo importado no momento", @@ -54,7 +54,7 @@ "ApiKeyHelpTextWarning": "Requer reinício para ter efeito", "ArtistClickToChangeAlbum": "Clique para mudar o álbum", "APIKey": "Chave API", - "AutoRedownloadFailedHelpText": "Procurar automaticamente e tente baixar uma versão diferente", + "AutoRedownloadFailedHelpText": "Procurar e tentar baixar automaticamente uma versão diferente", "BackupFolderHelpText": "Os caminhos relativos estarão no diretório AppData do Lidarr", "BackupIntervalHelpText": "Intervalo para fazer backup do banco de dados e configurações do Lidarr", "Automatic": "Automático", @@ -64,7 +64,7 @@ "CertificateValidationHelpText": "Altere a rigidez da validação da certificação HTTPS. Não mude a menos que você entenda os riscos.", "BackupRetentionHelpText": "Backups automáticos anteriores ao período de retenção serão limpos automaticamente", "Backups": "Backups", - "BindAddress": "Fixar Endereço", + "BindAddress": "Fixar endereço", "BindAddressHelpText": "Endereço IP válido, localhost ou '*' para todas as interfaces", "BindAddressHelpTextWarning": "Requer reiniciar para ter efeito", "BlocklistRelease": "Lançamento na lista de bloqueio", @@ -75,22 +75,22 @@ "Cancel": "Cancelar", "CancelMessageText": "Tem certeza que deseja cancelar esta tarefa pendente?", "CatalogNumber": "Número do Catálogo", - "CertificateValidation": "Validação de Certificado", - "ChangeFileDate": "Alterar Data do Arquivo", + "CertificateValidation": "Validação de certificado", + "ChangeFileDate": "Alterar data do arquivo", "ChangeHasNotBeenSavedYet": "Mudar o que não foi salvo ainda", - "ChmodFolder": "chmod Pasta", + "ChmodFolder": "Fazer chmod de pasta", "UILanguageHelpTextWarning": "É necessário recarregar o navegador", "UISettings": "Configurações da interface", - "UnableToAddANewDownloadClientPleaseTryAgain": "Não foi possível adicionar um novo cliente de download, tente novamente.", - "UnableToAddANewImportListExclusionPleaseTryAgain": "Não foi possível adicionar uma nova exclusão da lista de importação, tente novamente.", - "UnableToAddANewIndexerPleaseTryAgain": "Não foi possível adicionar um novo indexador, tente novamente.", - "UnableToAddANewListPleaseTryAgain": "Não foi possível adicionar uma nova lista, tente novamente.", + "UnableToAddANewDownloadClientPleaseTryAgain": "Não foi possível adicionar um novo cliente de download. Tente novamente.", + "UnableToAddANewImportListExclusionPleaseTryAgain": "Não foi possível adicionar uma nova exclusão da lista de importação. Tente novamente.", + "UnableToAddANewIndexerPleaseTryAgain": "Não foi possível adicionar um novo indexador. Tente novamente.", + "UnableToAddANewListPleaseTryAgain": "Não foi possível adicionar uma nova lista. Tente novamente.", "UnableToAddANewMetadataProfilePleaseTryAgain": "Não foi possível adicionar um novo perfil de metadados, tente novamente.", - "UnableToAddANewNotificationPleaseTryAgain": "Não foi possível adicionar uma nova notificação, tente novamente.", - "UnableToAddANewQualityProfilePleaseTryAgain": "Não foi possível adicionar um novo perfil de qualidade, tente novamente.", - "UnableToAddANewRemotePathMappingPleaseTryAgain": "Não foi possível adicionar um novo mapeamento de caminho remoto, tente novamente.", + "UnableToAddANewNotificationPleaseTryAgain": "Não foi possível adicionar uma nova notificação. Tente novamente.", + "UnableToAddANewQualityProfilePleaseTryAgain": "Não foi possível adicionar um novo perfil de qualidade. Tente novamente.", + "UnableToAddANewRemotePathMappingPleaseTryAgain": "Não foi possível adicionar um novo mapeamento de caminho remoto. Tente novamente.", "UnableToAddANewRootFolderPleaseTryAgain": "Não foi possível adicionar uma nova pasta raiz, tente novamente.", - "UnableToLoadBackups": "Não é possível carregar backups", + "UnableToLoadBackups": "Não foi possível carregar os backups", "UnableToLoadBlocklist": "Não foi possível carregar a lista de bloqueio", "UnableToLoadDelayProfiles": "Não foi possível carregar os perfis de atraso", "UnableToLoadDownloadClientOptions": "Não foi possível carregar as opções do cliente de download", @@ -144,23 +144,23 @@ "Year": "Ano", "ChmodFolderHelpText": "Octal, aplicado durante a importação/renomeação de pastas e arquivos de mídia (sem bits de execução)", "ChmodFolderHelpTextWarning": "Isso só funciona se o usuário que está executando o Lidarr for o proprietário do arquivo. É melhor garantir que o cliente de download defina as permissões corretamente.", - "ChownGroup": "chown Grupo", + "ChownGroup": "Fazer chown de grupo", "ChownGroupHelpText": "Nome do grupo ou gid. Use gid para sistemas de arquivos remotos.", "ChownGroupHelpTextWarning": "Isso só funciona se o usuário que está executando o Lidarr for o proprietário do arquivo. É melhor garantir que o cliente de download use o mesmo grupo que o Lidarr.", "Clear": "Limpar", "ClickToChangeQuality": "Clique para alterar a qualidade", - "ClientPriority": "Prioridade do Cliente", - "CloneIndexer": "Clonar Indexador", - "CloneProfile": "Clonar Perfil", + "ClientPriority": "Prioridade do cliente", + "CloneIndexer": "Clonar indexador", + "CloneProfile": "Clonar perfil", "CollapseMultipleAlbumsHelpText": "Recolher vários livros lançados no mesmo dia", "Columns": "Colunas", - "CompletedDownloadHandling": "Gerenciamento de Downloads Completos", + "CompletedDownloadHandling": "Gerenciamento de downloads concluídos", "Connections": "Conexões", - "ConnectSettings": "Configurações de Conexão", + "ConnectSettings": "Configurações de conexão", "Continuing": "Continuando", "ContinuingAllTracksDownloaded": "Continuação (todos os livros baixados)", "ContinuingNoAdditionalAlbumsAreExpected": "Não espera-se mais livros", - "CopyUsingHardlinksHelpText": "Os hardlinks permitem que o Lidarr importe torrents para a pasta da série sem ocupar espaço extra em disco ou copiar todo o conteúdo do arquivo. Hardlinks só funcionarão se a origem e o destino estiverem no mesmo volume", + "CopyUsingHardlinksHelpText": "Os hardlinks permitem que o Lidarr importe torrents de propagação para a pasta da série sem ocupar espaço adicional em disco ou copiar todo o conteúdo do arquivo. Hardlinks só funcionarão se a origem e o destino estiverem no mesmo volume", "CopyUsingHardlinksHelpTextWarning": "Ocasionalmente, os bloqueios de arquivo podem impedir a renomeação de arquivos que estão sendo semeados. Você pode desabilitar temporariamente a semeadura e usar a função de renomeação do Lidarr como uma solução alternativa.", "Country": "País", "CreateEmptyArtistFolders": "Criar pastas de autor vazias", @@ -173,65 +173,65 @@ "DefaultMetadataProfileIdHelpText": "Há um perfil de metadados padrão para autores nesta pasta", "DefaultQualityProfileIdHelpText": "Há um perfil de qualidade padrão para autores nesta pasta", "DefaultTagsHelpText": "Há tags padrão do Lidarr para autores nesta pasta", - "DelayProfile": "Perfil de Atraso", - "DelayProfiles": "Perfis de Atraso", + "DelayProfile": "Perfil de atraso", + "DelayProfiles": "Perfis de atraso", "Delete": "Excluir", "DeleteBackup": "Excluir Backup", "DeleteBackupMessageText": "Tem certeza de que deseja excluir o backup '{name}'?", - "DeleteDelayProfile": "Excluir Perfil de Atraso", + "DeleteDelayProfile": "Excluir perfil de atraso", "DeleteDelayProfileMessageText": "Tem certeza de que deseja excluir este perfil de atraso?", - "DeleteDownloadClient": "Excluir Cliente de Download", + "DeleteDownloadClient": "Excluir cliente de download", "DeleteDownloadClientMessageText": "Tem certeza de que deseja excluir o cliente de download '{name}'?", "DeleteEmptyFolders": "Excluir pastas vazias", "DeleteFilesHelpText": "Excluir arquivos do livro e pasta do autor", - "DeleteImportList": "Excluir Lista de Importação", - "DeleteImportListExclusion": "Excluir Exclusão da Lista de Importação", + "DeleteImportList": "Excluir lista de importação", + "DeleteImportListExclusion": "Excluir exclusão da lista de importação", "DeleteImportListExclusionMessageText": "Tem certeza de que deseja excluir esta exclusão da lista de importação?", "DeleteImportListMessageText": "Tem certeza de que deseja excluir a lista '{name}'?", - "DeleteIndexer": "Excluir Indexador", + "DeleteIndexer": "Excluir indexador", "DeleteIndexerMessageText": "Tem certeza de que deseja excluir o indexador '{name}'?", "DeleteMetadataProfile": "Excluir perfil de metadados", "DeleteMetadataProfileMessageText": "Tem certeza de que deseja excluir o perfil de metadados '{name}'?", - "DeleteNotification": "Excluir Notificação", + "DeleteNotification": "Excluir notificação", "DeleteNotificationMessageText": "Tem certeza de que deseja excluir a notificação '{name}'?", - "DeleteQualityProfile": "Excluir Perfil de Qualidade", + "DeleteQualityProfile": "Excluir perfil de qualidade", "DeleteQualityProfileMessageText": "Tem certeza de que deseja excluir o perfil de qualidade '{name}'?", - "DeleteReleaseProfile": "Excluir Perfil de Lançamento", + "DeleteReleaseProfile": "Excluir perfil de lançamento", "DeleteReleaseProfileMessageText": "Tem certeza de que deseja excluir este perfil de lançamento?", - "DeleteRootFolder": "Excluir Pasta Raiz", + "DeleteRootFolder": "Excluir pasta raiz", "DeleteRootFolderMessageText": "Tem certeza de que deseja excluir a pasta raiz '{name}'?", "DeleteSelectedTrackFiles": "Excluir arquivos do livro selecionado", "DeleteSelectedTrackFilesMessageText": "Tem certeza de que deseja excluir os arquivos do livro selecionado?", "DeleteTag": "Excluir tag", "DeleteTagMessageText": "Tem certeza de que deseja excluir a tag \"{0}\"?", "DeleteTrackFileMessageText": "Tem certeza que deseja excluir {0}?", - "DestinationPath": "Caminho de Destino", + "DestinationPath": "Caminho de destino", "DetailedProgressBar": "Barra de progresso detalhada", "DetailedProgressBarHelpText": "Mostrar texto na barra de progresso", "DiscCount": "Contagem de disco", "DiscNumber": "Número do disco", "DiskSpace": "Espaço em disco", - "DownloadClient": "Cliente de Download", + "DownloadClient": "Cliente de download", "DownloadClients": "Clientes de download", - "DownloadClientSettings": "Configurações do Cliente de Download", + "DownloadClientSettings": "Configurações do cliente de download", "DownloadFailedCheckDownloadClientForMoreDetails": "Falha no download: verifique o cliente de download para saber mais", "DownloadFailedInterp": "Falha no download: {0}", "Downloading": "Baixando", - "DownloadPropersAndRepacksHelpTexts1": "Se deve ou não atualizar automaticamente para Propers/Repacks", + "DownloadPropersAndRepacksHelpTexts1": "Se deve ou não atualizar automaticamente para propers/repacks", "DownloadPropersAndRepacksHelpTexts2": "Use \"Não preferir\" para classificar por pontuação de palavra preferida em relação a Propers/Repacks", "DownloadWarningCheckDownloadClientForMoreDetails": "Aviso de download: verifique o cliente de download para saber mais", "Edit": "Editar", "Enable": "Habilitar", - "EnableAutomaticAdd": "Habilitar Adição Automática", - "EnableAutomaticAddHelpText": "Adicionar autor/livros ao Lidarr ao sincronizar pela interface ou pelo Lidarr", + "EnableAutomaticAdd": "Habilitar adição automática", + "EnableAutomaticAddHelpText": "Adicionar artista/álbum ao Lidarr ao sincronizar pela interface ou pelo Lidarr", "EnableAutomaticSearch": "Ativar a pesquisa automática", - "EnableColorImpairedMode": "Habilitar Modo para Deficientes Visuais", - "EnableColorImpairedModeHelpText": "Estilo alterado para permitir que usuários com deficiência de cor distingam melhor as informações codificadas por cores", + "EnableColorImpairedMode": "Habilitar modo para daltonismo", + "EnableColorImpairedModeHelpText": "Estilo alterado para permitir que usuários com daltonismo distingam melhor as informações codificadas por cores", "EnableCompletedDownloadHandlingHelpText": "Importar automaticamente downloads concluídos do cliente de download", "EnabledHelpText": "Marque para habilitar o perfil de lançamento", "EnableHelpText": "Habilitar criação de arquivo de metadados para este tipo de metadados", "EnableInteractiveSearch": "Ativar pesquisa interativa", - "EnableProfile": "Habilitar Perfil", + "EnableProfile": "Habilitar perfil", "EnableRSS": "Habilitar RSS", "EnableSSL": "Habilitar SSL", "EnableSslHelpText": " Requer a reinicialização com a execução como administrador para fazer efeito", @@ -594,7 +594,7 @@ "RemoveDownloadsAlert": "As configurações de remoção foram movidas para as configurações individuais do cliente de download na tabela acima.", "RemoveFailed": "Falha na remoção", "RenameTracks": "Renomear Faixas", - "DeleteEmptyFoldersHelpText": "Exclua as pastas vazias de artistas e álbuns durante a verificação do disco e quando os arquivos de faixas forem excluídos", + "DeleteEmptyFoldersHelpText": "Excluir as pastas de artistas e álbuns vazias durante a verificação do disco e quando os arquivos de faixas forem excluídos", "DeleteTrackFile": "Excluir Arquivo da Faixa", "Disambiguation": "Desambiguação", "Docker": "Docker", @@ -658,7 +658,7 @@ "OnTrackRetag": "Ao Re-etiquetar Faixa", "OnUpgrade": "Ao Atualizar", "OnDownloadFailure": "Na Falha do Download", - "OnGrab": "Ao Baixar", + "OnGrab": "Ao obter", "OnReleaseImport": "Ao Importar Lançamento", "OnImportFailure": "Ao Falhar na Importação", "OnHealthIssue": "Ao Problema de Saúde", @@ -672,43 +672,43 @@ "MonitoringOptionsHelpText": "Quais álbuns devem ser monitorados após o artista ser adicionado (ajuste único)", "MonitorNewItemsHelpText": "Quais álbuns novos devem ser monitorados", "Add": "Adicionar", - "AddDelayProfile": "Adicionar Perfil de Atraso", + "AddDelayProfile": "Adicionar perfil de atraso", "Added": "Adicionado", - "AddImportListExclusion": "Adicionar Exclusão de Lista de Importação", - "AddIndexer": "Adicionar Indexador", + "AddImportListExclusion": "Adicionar exclusão de lista de importação", + "AddIndexer": "Adicionar indexador", "AddMetadataProfile": "Adicionar perfil de metadados", "AddNew": "Adicionar Novo", - "AddQualityProfile": "Adicionar Perfil de Qualidade", - "AddRemotePathMapping": "Adicionar Mapeamento de Caminho Remoto", - "AddRootFolder": "Adicionar Pasta Raiz", - "AfterManualRefresh": "Depois da Atualização Manual", - "Age": "Idade", + "AddQualityProfile": "Adicionar perfil de qualidade", + "AddRemotePathMapping": "Adicionar mapeamento de caminho remoto", + "AddRootFolder": "Adicionar pasta raiz", + "AfterManualRefresh": "Após a atualização manual", + "Age": "Tempo de vida", "Albums": "Álbum", "All": "Todos", - "AllFiles": "Todos os Arquivos", + "AllFiles": "Todos os arquivos", "AllMonitoringOptionHelpText": "Monitorar artistas e todos os álbuns para cada artista incluído na lista de importação", - "ApplicationURL": "URL do Aplicativo", - "ApplicationUrlHelpText": "A URL externa deste aplicativo, incluindo http(s)://, porta e base da URL", + "ApplicationURL": "URL do aplicativo", + "ApplicationUrlHelpText": "A URL externa deste aplicativo, incluindo http(s)://, porta e URL base", "Apply": "Aplicar", - "AudioInfo": "Info do Áudio", + "AudioInfo": "Informações do áudio", "Backup": "Backup", "BeforeUpdate": "Antes da atualização", "Close": "Fechar", "Connect": "Conectar", - "Custom": "Personalizar", - "CustomFilters": "Filtros Personalizados", + "Custom": "Personalizado", + "CustomFilters": "Filtros personalizados", "Date": "Data", "DefaultDelayProfileHelpText": "Este é o perfil padrão. Ele se aplica a todos os artistas que não possuem um perfil explícito.", "Deleted": "Excluído", "Details": "Detalhes", "Donations": "Doações", "DoNotPrefer": "Não preferir", - "DoNotUpgradeAutomatically": "Não Atualizar Automaticamente", - "DownloadFailed": "Download Falhou", - "EditDelayProfile": "Editar Perfil de Atraso", - "EditImportListExclusion": "Editar Exclusão de Lista de Importação", - "EditQualityProfile": "Editar Perfil de Qualidade", - "EditRemotePathMapping": "Editar Mapeamento do Caminho Remoto", + "DoNotUpgradeAutomatically": "Não atualizar automaticamente", + "DownloadFailed": "Falha no download", + "EditDelayProfile": "Editar perfil de atraso", + "EditImportListExclusion": "Editar exclusão de lista de importação", + "EditQualityProfile": "Editar perfil de qualidade", + "EditRemotePathMapping": "Editar mapeamento de caminho remoto", "EditRootFolder": "Editar pasta raiz", "Error": "Erro", "ErrorRestoringBackup": "Erro ao restaurar o backup", @@ -793,9 +793,9 @@ "Activity": "Atividade", "Always": "Sempre", "Info": "Info", - "AddConnection": "Adicionar Conexão", + "AddConnection": "Adicionar conexão", "EditMetadataProfile": "Editar perfil de metadados", - "AddReleaseProfile": "Adicionar Perfil de Lançamento", + "AddReleaseProfile": "Adicionar perfil de lançamento", "AlbumRelease": "Lançamento do Álbum", "AlbumReleaseDate": "Data do Lançamento do Álbum", "AlbumStatus": "Estado do Álbum", @@ -810,8 +810,8 @@ "DeleteArtist": "Excluir Artista Selecionado", "Discography": "Discografia", "DownloadImported": "Download Importado", - "EditMetadata": "Editar Metadados", - "EditReleaseProfile": "Editar Perfil de Lançamento", + "EditMetadata": "Editar metadados", + "EditReleaseProfile": "Editar perfil de lançamento", "ForNewImportsOnly": "Para novas importações somente", "ImportFailed": "Importação Falhou", "MissingTracks": "Faixas Ausentes", @@ -839,14 +839,14 @@ "EndedOnly": "Terminado Apenas", "MediaCount": "Número de Mídias", "Database": "Banco de dados", - "DoneEditingGroups": "Concluir Edição de Grupos", + "DoneEditingGroups": "Concluir edição de grupos", "QualitiesHelpText": "As qualidades mais altas na lista são mais preferidas, mesmo que não sejam verificadas. As qualidades dentro do mesmo grupo são iguais. Somente qualidades verificadas são desejadas", - "EditGroups": "Editar Grupos", + "EditGroups": "Editar grupos", "OnAlbumDelete": "Ao Excluir Álbum", "OnArtistDelete": "Ao Excluir Artista", "OnArtistDeleteHelpText": "Ao Excluir Artista", "OnAlbumDeleteHelpText": "Ao Excluir Álbum", - "ContinuingOnly": "Apenas continuar", + "ContinuingOnly": "Continuando apenas", "DeleteSelected": "Excluir Selecionado", "SelectReleaseGroup": "Selecionar Grupo do Lançamento", "Inactive": "Inativo", @@ -855,16 +855,16 @@ "EnableRssHelpText": "Será usado quando o Lidarr procurar periodicamente lançamentos via RSS Sync", "BypassIfAboveCustomFormatScore": "Ignorar se estiver acima da pontuação do formato personalizado", "BypassIfAboveCustomFormatScoreHelpText": "Ativar ignorar quando a versão tiver uma pontuação maior que a pontuação mínima configurada do formato personalizado", - "BypassIfHighestQuality": "Ignorar se a qualidade mais alta", - "BypassIfHighestQualityHelpText": "Ignorar o atraso quando o lançamento tiver a qualidade habilitada mais alta no perfil de qualidade", + "BypassIfHighestQuality": "Ignorar se a qualidade é mais alta", + "BypassIfHighestQualityHelpText": "Ignorar atraso quando o lançamento tiver a qualidade mais alta habilitada no perfil de qualidade com o protocolo preferido", "CustomFormatScore": "Pontuação do formato personalizado", "MinimumCustomFormatScore": "Pontuação Mínima de Formato Personalizado", "MinimumCustomFormatScoreHelpText": "Pontuação mínima de formato personalizado necessária para ignorar o atraso do protocolo preferido", "UnableToLoadInteractiveSearch": "Não foi possível carregar os resultados desta pesquisa de álbum. Tente mais tarde", "UnableToLoadCustomFormats": "Não foi possível carregar formatos personalizados", - "CopyToClipboard": "Copiar para área de transferência", + "CopyToClipboard": "Copiar para a área de transferência", "CouldntFindAnyResultsForTerm": "Não foi possível encontrar resultados para \"{0}\"", - "CustomFormat": "Formato Personalizado", + "CustomFormat": "Formato personalizado", "CustomFormatRequiredHelpText": "Esta condição {0} deve ser correspondida para aplicar o formato personalizado. Caso contrário, uma única correspondência de {1} é suficiente.", "CustomFormatSettings": "Configurações do Formato Personalizado", "CustomFormats": "Formatos personalizados", @@ -872,11 +872,11 @@ "DeleteCustomFormat": "Excluir formato personalizado", "DeleteCustomFormatMessageText": "Tem certeza de que deseja excluir o formato personalizado '{name}'?", "DeleteFormatMessageText": "Tem certeza de que deseja excluir a tag de formato '{name}'?", - "DownloadPropersAndRepacksHelpTextWarning": "Use formatos personalizados para atualizações automáticas para Propers/Repacks", + "DownloadPropersAndRepacksHelpTextWarning": "Usar formatos personalizados para atualizações automáticas para propers/repacks", "DownloadedUnableToImportCheckLogsForDetails": "Baixado - Não foi possível importar: verifique os logs para saber mais", "ExportCustomFormat": "Exportar formato personalizado", "FailedDownloadHandling": "Falha no gerenciamento de download", - "FailedLoadingSearchResults": "Falha ao carregar os resultados da pesquisa, tente novamente.", + "FailedLoadingSearchResults": "Falha ao carregar os resultados da pesquisa. Tente novamente.", "ForeignId": "ID Estrangeiro", "Formats": "Formatos", "NegateHelpText": "Se marcado, o formato personalizado não será aplicado se esta condição {0} corresponder.", @@ -972,7 +972,7 @@ "OneAlbum": "1 álbum", "ShowNextAlbum": "Mostrar Próximo Álbum", "DeleteRemotePathMappingMessageText": "Tem certeza de que deseja excluir este mapeamento de caminho remoto?", - "DeleteRemotePathMapping": "Excluir Mapeamento de Caminho Remoto", + "DeleteRemotePathMapping": "Excluir mapeamento de caminho remoto", "BlocklistReleases": "Lançamentos na lista de bloqueio", "DeleteCondition": "Excluir condição", "DeleteConditionMessageText": "Tem certeza de que deseja excluir a condição '{name}'?", @@ -985,7 +985,7 @@ "ResetQualityDefinitions": "Redefinir definições de qualidade", "ResetQualityDefinitionsMessageText": "Tem certeza de que deseja redefinir as definições de qualidade?", "ResetTitlesHelpText": "Redefinir títulos de configuração, bem como valores", - "BlocklistReleaseHelpText": "Impede que o Lidarr pegue automaticamente esses arquivos novamente", + "BlocklistReleaseHelpText": "Impede que o Lidarr obtenha automaticamente esses arquivos novamente", "FailedToLoadQueue": "Falha ao carregar a fila", "QueueIsEmpty": "A fila está vazia", "NoCutoffUnmetItems": "Nenhum item de corte não atendido", @@ -1048,28 +1048,28 @@ "IndexerDownloadClientHealthCheckMessage": "Indexadores com clientes de download inválidos: {0}.", "NoResultsFound": "Nenhum resultado encontrado", "SomeResultsAreHiddenByTheAppliedFilter": "Alguns resultados estão ocultos pelo filtro aplicado", - "AllResultsAreHiddenByTheAppliedFilter": "Todos os resultados são ocultados pelo filtro aplicado", - "AppUpdated": "{appName} Atualizado", + "AllResultsAreHiddenByTheAppliedFilter": "Todos os resultados estão ocultos pelo filtro aplicado", + "AppUpdated": "{appName} atualizado", "AppUpdatedVersion": "{appName} foi atualizado para a versão `{version}`. Para obter as alterações mais recentes, você precisará recarregar {appName}", - "ConnectionLost": "Conexão Perdida", + "ConnectionLost": "Conexão perdida", "ConnectionLostReconnect": "{appName} tentará se conectar automaticamente ou você pode clicar em recarregar abaixo.", "ConnectionLostToBackend": "{appName} perdeu a conexão com o backend e precisará ser recarregado para restaurar a funcionalidade.", "RecentChanges": "Mudanças Recentes", "WhatsNew": "O que há de novo?", "NotificationStatusAllClientHealthCheckMessage": "Todas as notificações estão indisponíveis devido a falhas", "NotificationStatusSingleClientHealthCheckMessage": "Notificações indisponíveis devido a falhas: {0}", - "AddConditionImplementation": "Adicionar Condição - {implementationName}", - "AddConnectionImplementation": "Adicionar Conexão - {implementationName}", - "AddDownloadClientImplementation": "Adicionar Cliente de Download - {implementationName}", - "AddImportList": "Adicionar Lista de Importação", - "AddImportListImplementation": "Adicionar Lista de importação - {implementationName}", - "AddIndexerImplementation": "Adicionar Indexador - {implementationName}", + "AddConditionImplementation": "Adicionar condição - {implementationName}", + "AddConnectionImplementation": "Adicionar conexão - {implementationName}", + "AddDownloadClientImplementation": "Adicionar cliente de download - {implementationName}", + "AddImportList": "Adicionar lista de importação", + "AddImportListImplementation": "Adicionar lista de importação - {implementationName}", + "AddIndexerImplementation": "Adicionar indexador - {implementationName}", "AutomaticUpdatesDisabledDocker": "As atualizações automáticas não têm suporte direto ao usar o mecanismo de atualização do Docker. Você precisará atualizar a imagem do contêiner fora de {appName} ou usar um script", - "EditDownloadClientImplementation": "Editar Cliente de Download - {implementationName}", - "EditConditionImplementation": "Editar Condição - {implementationName}", - "EditConnectionImplementation": "Editar Conexão - {implementationName}", - "EditImportListImplementation": "Editar Lista de Importação - {implementationName}", - "EditIndexerImplementation": "Editar Indexador - {implementationName}", + "EditDownloadClientImplementation": "Editar cliente de download - {implementationName}", + "EditConditionImplementation": "Editar condição - {implementationName}", + "EditConnectionImplementation": "Editar conexão - {implementationName}", + "EditImportListImplementation": "Editar lista de importação - {implementationName}", + "EditIndexerImplementation": "Editar indexador - {implementationName}", "RemotePathMappingsInfo": "Raramente são necessários mapeamentos de caminho remoto, se {app} e seu cliente de download estiverem no mesmo sistema, é melhor combinar seus caminhos. Para mais informações, consulte o [wiki]({wikiLink})", "CloneCondition": "Clonar Condição", "Enabled": "Habilitado", From 77a60141bdce5a8c95350ae4eeeadd232deea490 Mon Sep 17 00:00:00 2001 From: Qstick Date: Mon, 11 Jul 2022 22:08:28 -0500 Subject: [PATCH 054/820] Bump Version to 2 (cherry picked from commit 21afbbc66d294cfeda47b7dacb785a17dae8eb1c) (cherry picked from commit 1e6540a419e1ece4645880126a8993ac28795d30) --- azure-pipelines.yml | 2 +- src/NzbDrone.Common.Test/EnvironmentInfo/BuildInfoFixture.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 16b7cfb08..b33b758fc 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,7 +9,7 @@ variables: testsFolder: './_tests' yarnCacheFolder: $(Pipeline.Workspace)/.yarn nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages - majorVersion: '1.5.1' + majorVersion: '2.0.0' minorVersion: $[counter('minorVersion', 1076)] lidarrVersion: '$(majorVersion).$(minorVersion)' buildName: '$(Build.SourceBranchName).$(lidarrVersion)' diff --git a/src/NzbDrone.Common.Test/EnvironmentInfo/BuildInfoFixture.cs b/src/NzbDrone.Common.Test/EnvironmentInfo/BuildInfoFixture.cs index 9057ff15d..08b89e502 100644 --- a/src/NzbDrone.Common.Test/EnvironmentInfo/BuildInfoFixture.cs +++ b/src/NzbDrone.Common.Test/EnvironmentInfo/BuildInfoFixture.cs @@ -10,7 +10,7 @@ namespace NzbDrone.Common.Test.EnvironmentInfo [Test] public void should_return_version() { - BuildInfo.Version.Major.Should().BeOneOf(1, 10); + BuildInfo.Version.Major.Should().BeOneOf(2, 10); } [Test] From cdd683ae8fb686d7b84a4565ef92c2f11bf02da3 Mon Sep 17 00:00:00 2001 From: Qstick Date: Mon, 11 Jul 2022 22:12:57 -0500 Subject: [PATCH 055/820] New: Rework and Require Authentication Co-Authored-By: Mark McDowall (cherry picked from commit 8911386ed0fcaa5ed0a894e511a81ecc87e58d49) --- frontend/src/Components/Page/Page.js | 7 + frontend/src/Components/Page/PageConnector.js | 6 +- .../FirstRun/AuthenticationRequiredModal.js | 34 ++++ .../AuthenticationRequiredModalContent.css | 5 + ...uthenticationRequiredModalContent.css.d.ts | 7 + .../AuthenticationRequiredModalContent.js | 164 ++++++++++++++++++ ...enticationRequiredModalContentConnector.js | 86 +++++++++ .../src/Settings/General/SecuritySettings.js | 58 ++++++- .../Config/HostConfigResource.cs | 2 + .../AuthenticationBuilderExtensions.cs | 6 + ...leDenyAnonymousAuthorizationRequirement.cs | 8 + .../Authentication/UiAuthorizationHandler.cs | 45 +++++ .../UiAuthorizationPolicyProvider.cs | 3 +- .../AutomationTest.cs | 2 +- .../AuthenticationRequiredType.cs | 8 + .../Authentication/AuthenticationType.cs | 5 +- .../Configuration/ConfigFileProvider.cs | 3 + src/NzbDrone.Host/Startup.cs | 2 + src/NzbDrone.Test.Common/NzbDroneRunner.cs | 8 +- 19 files changed, 444 insertions(+), 15 deletions(-) create mode 100644 frontend/src/FirstRun/AuthenticationRequiredModal.js create mode 100644 frontend/src/FirstRun/AuthenticationRequiredModalContent.css create mode 100644 frontend/src/FirstRun/AuthenticationRequiredModalContent.css.d.ts create mode 100644 frontend/src/FirstRun/AuthenticationRequiredModalContent.js create mode 100644 frontend/src/FirstRun/AuthenticationRequiredModalContentConnector.js create mode 100644 src/Lidarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs create mode 100644 src/Lidarr.Http/Authentication/UiAuthorizationHandler.cs create mode 100644 src/NzbDrone.Core/Authentication/AuthenticationRequiredType.cs diff --git a/frontend/src/Components/Page/Page.js b/frontend/src/Components/Page/Page.js index eac6d709f..4b24a8231 100644 --- a/frontend/src/Components/Page/Page.js +++ b/frontend/src/Components/Page/Page.js @@ -4,6 +4,7 @@ import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector'; import ColorImpairedContext from 'App/ColorImpairedContext'; import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector'; import SignalRConnector from 'Components/SignalRConnector'; +import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal'; import locationShape from 'Helpers/Props/Shapes/locationShape'; import PageHeader from './Header/PageHeader'; import PageSidebar from './Sidebar/PageSidebar'; @@ -75,6 +76,7 @@ class Page extends Component { isSmallScreen, isSidebarVisible, enableColorImpairedMode, + authenticationEnabled, onSidebarToggle, onSidebarVisibleChange } = this.props; @@ -108,6 +110,10 @@ class Page extends Component { isOpen={this.state.isConnectionLostModalOpen} onModalClose={this.onConnectionLostModalClose} /> + + ); @@ -123,6 +129,7 @@ Page.propTypes = { isUpdated: PropTypes.bool.isRequired, isDisconnected: PropTypes.bool.isRequired, enableColorImpairedMode: PropTypes.bool.isRequired, + authenticationEnabled: PropTypes.bool.isRequired, onResize: PropTypes.func.isRequired, onSidebarToggle: PropTypes.func.isRequired, onSidebarVisibleChange: PropTypes.func.isRequired diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index b13695f17..070be9b42 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -10,6 +10,7 @@ import { fetchImportLists, fetchLanguages, fetchMetadataProfiles, fetchQualityPr import { fetchStatus } from 'Store/Actions/systemActions'; import { fetchTags } from 'Store/Actions/tagActions'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; import ErrorPage from './ErrorPage'; import LoadingPage from './LoadingPage'; import Page from './Page'; @@ -132,18 +133,21 @@ function createMapStateToProps() { selectErrors, selectAppProps, createDimensionsSelector(), + createSystemStatusSelector(), ( enableColorImpairedMode, isPopulated, errors, app, - dimensions + dimensions, + systemStatus ) => { return { ...app, ...errors, isPopulated, isSmallScreen: dimensions.isSmallScreen, + authenticationEnabled: systemStatus.authentication !== 'none', enableColorImpairedMode }; } diff --git a/frontend/src/FirstRun/AuthenticationRequiredModal.js b/frontend/src/FirstRun/AuthenticationRequiredModal.js new file mode 100644 index 000000000..caa855cb7 --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import AuthenticationRequiredModalContentConnector from './AuthenticationRequiredModalContentConnector'; + +function onModalClose() { + // No-op +} + +function AuthenticationRequiredModal(props) { + const { + isOpen + } = props; + + return ( + + + + ); +} + +AuthenticationRequiredModal.propTypes = { + isOpen: PropTypes.bool.isRequired +}; + +export default AuthenticationRequiredModal; diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.css b/frontend/src/FirstRun/AuthenticationRequiredModalContent.css new file mode 100644 index 000000000..bbc6704e6 --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.css @@ -0,0 +1,5 @@ +.authRequiredAlert { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 20px; +} diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.css.d.ts b/frontend/src/FirstRun/AuthenticationRequiredModalContent.css.d.ts new file mode 100644 index 000000000..9454d5428 --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'authRequiredAlert': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js new file mode 100644 index 000000000..985186fde --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types'; +import React, { useEffect, useRef } from 'react'; +import Alert from 'Components/Alert'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +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, kinds } from 'Helpers/Props'; +import { authenticationMethodOptions, authenticationRequiredOptions, authenticationRequiredWarning } from 'Settings/General/SecuritySettings'; +import styles from './AuthenticationRequiredModalContent.css'; + +function onModalClose() { + // No-op +} + +function AuthenticationRequiredModalContent(props) { + const { + isPopulated, + error, + isSaving, + settings, + onInputChange, + onSavePress, + dispatchFetchStatus + } = props; + + const { + authenticationMethod, + authenticationRequired, + username, + password + } = settings; + + const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; + + const didMount = useRef(false); + + useEffect(() => { + if (!isSaving && didMount.current) { + dispatchFetchStatus(); + } + + didMount.current = true; + }, [isSaving, dispatchFetchStatus]); + + return ( + + + Authentication Required + + + + + {authenticationRequiredWarning} + + + { + isPopulated && !error ? +
+ + Authentication + + + + + { + authenticationEnabled ? + + Authentication Required + + + : + null + } + + { + authenticationEnabled ? + + Username + + + : + null + } + + { + authenticationEnabled ? + + Password + + + : + null + } +
: + null + } + + { + !isPopulated && !error ? : null + } +
+ + + + Save + + +
+ ); +} + +AuthenticationRequiredModalContent.propTypes = { + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + dispatchFetchStatus: PropTypes.func.isRequired +}; + +export default AuthenticationRequiredModalContent; diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContentConnector.js b/frontend/src/FirstRun/AuthenticationRequiredModalContentConnector.js new file mode 100644 index 000000000..6653a9d34 --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContentConnector.js @@ -0,0 +1,86 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { fetchGeneralSettings, saveGeneralSettings, setGeneralSettingsValue } from 'Store/Actions/settingsActions'; +import { fetchStatus } from 'Store/Actions/systemActions'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import AuthenticationRequiredModalContent from './AuthenticationRequiredModalContent'; + +const SECTION = 'general'; + +function createMapStateToProps() { + return createSelector( + createSettingsSectionSelector(SECTION), + (sectionSettings) => { + return { + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchClearPendingChanges: clearPendingChanges, + dispatchSetGeneralSettingsValue: setGeneralSettingsValue, + dispatchSaveGeneralSettings: saveGeneralSettings, + dispatchFetchGeneralSettings: fetchGeneralSettings, + dispatchFetchStatus: fetchStatus +}; + +class AuthenticationRequiredModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchGeneralSettings(); + } + + componentWillUnmount() { + this.props.dispatchClearPendingChanges({ section: `settings.${SECTION}` }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetGeneralSettingsValue({ name, value }); + }; + + onSavePress = () => { + this.props.dispatchSaveGeneralSettings(); + }; + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchFetchGeneralSettings, + dispatchSetGeneralSettingsValue, + dispatchSaveGeneralSettings, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +AuthenticationRequiredModalContentConnector.propTypes = { + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchFetchGeneralSettings: PropTypes.func.isRequired, + dispatchSetGeneralSettingsValue: PropTypes.func.isRequired, + dispatchSaveGeneralSettings: PropTypes.func.isRequired, + dispatchFetchStatus: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AuthenticationRequiredModalContentConnector); diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js index bb20a9305..2495419ef 100644 --- a/frontend/src/Settings/General/SecuritySettings.js +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -11,10 +11,33 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import { icons, inputTypes, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; +export const authenticationRequiredWarning = 'To prevent remote access without authentication, Radarr now requires authentication to be enabled. You can optionally disable authentication from local addresses.'; + const authenticationMethodOptions = [ - { key: 'none', value: 'None' }, - { key: 'basic', value: 'Basic (Browser Popup)' }, - { key: 'forms', value: 'Forms (Login Page)' } + { + key: 'none', + get value() { + return translate('None'); + }, + isDisabled: true + }, + { + key: 'basic', + get value() { + return translate('AuthBasic'); + } + }, + { + key: 'forms', + get value() { + return translate('AuthForm'); + } + } +]; + +export const authenticationRequiredOptions = [ + { key: 'enabled', value: 'Enabled' }, + { key: 'disabledForLocalAddresses', value: 'Disabled for Local Addresses' } ]; const certificateValidationOptions = [ @@ -68,6 +91,7 @@ class SecuritySettings extends Component { const { authenticationMethod, + authenticationRequired, username, password, apiKey, @@ -88,13 +112,31 @@ class SecuritySettings extends Component { name="authenticationMethod" values={authenticationMethodOptions} helpText={translate('AuthenticationMethodHelpText')} + helpTextWarning={authenticationRequiredWarning} onChange={onInputChange} {...authenticationMethod} /> { - authenticationEnabled && + authenticationEnabled ? + + Authentication Required + + + : + null + } + + { + authenticationEnabled ? {translate('Username')} @@ -106,11 +148,12 @@ class SecuritySettings extends Component { onChange={onInputChange} {...username} /> - + : + null } { - authenticationEnabled && + authenticationEnabled ? {translate('Password')} @@ -122,7 +165,8 @@ class SecuritySettings extends Component { onChange={onInputChange} {...password} /> - + : + null } diff --git a/src/Lidarr.Api.V1/Config/HostConfigResource.cs b/src/Lidarr.Api.V1/Config/HostConfigResource.cs index cf0178018..abdf26c39 100644 --- a/src/Lidarr.Api.V1/Config/HostConfigResource.cs +++ b/src/Lidarr.Api.V1/Config/HostConfigResource.cs @@ -15,6 +15,7 @@ namespace Lidarr.Api.V1.Config public bool EnableSsl { get; set; } public bool LaunchBrowser { get; set; } public AuthenticationType AuthenticationMethod { get; set; } + public AuthenticationRequiredType AuthenticationRequired { get; set; } public bool AnalyticsEnabled { get; set; } public string Username { get; set; } public string Password { get; set; } @@ -57,6 +58,7 @@ namespace Lidarr.Api.V1.Config EnableSsl = model.EnableSsl, LaunchBrowser = model.LaunchBrowser, AuthenticationMethod = model.AuthenticationMethod, + AuthenticationRequired = model.AuthenticationRequired, AnalyticsEnabled = model.AnalyticsEnabled, // Username diff --git a/src/Lidarr.Http/Authentication/AuthenticationBuilderExtensions.cs b/src/Lidarr.Http/Authentication/AuthenticationBuilderExtensions.cs index 30f8a97d9..d336a38c3 100644 --- a/src/Lidarr.Http/Authentication/AuthenticationBuilderExtensions.cs +++ b/src/Lidarr.Http/Authentication/AuthenticationBuilderExtensions.cs @@ -22,10 +22,16 @@ namespace Lidarr.Http.Authentication return authenticationBuilder.AddScheme(name, options => { }); } + public static AuthenticationBuilder AddExternal(this AuthenticationBuilder authenticationBuilder, string name) + { + return authenticationBuilder.AddScheme(name, options => { }); + } + public static AuthenticationBuilder AddAppAuthentication(this IServiceCollection services) { return services.AddAuthentication() .AddNone(AuthenticationType.None.ToString()) + .AddExternal(AuthenticationType.External.ToString()) .AddBasic(AuthenticationType.Basic.ToString()) .AddCookie(AuthenticationType.Forms.ToString(), options => { diff --git a/src/Lidarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs b/src/Lidarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs new file mode 100644 index 000000000..3ad4edcba --- /dev/null +++ b/src/Lidarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace NzbDrone.Http.Authentication +{ + public class BypassableDenyAnonymousAuthorizationRequirement : DenyAnonymousAuthorizationRequirement + { + } +} diff --git a/src/Lidarr.Http/Authentication/UiAuthorizationHandler.cs b/src/Lidarr.Http/Authentication/UiAuthorizationHandler.cs new file mode 100644 index 000000000..a8d212659 --- /dev/null +++ b/src/Lidarr.Http/Authentication/UiAuthorizationHandler.cs @@ -0,0 +1,45 @@ +using System.Net; +using System.Threading.Tasks; +using Lidarr.Http.Extensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Authentication; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Configuration.Events; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Http.Authentication +{ + public class UiAuthorizationHandler : AuthorizationHandler, IAuthorizationRequirement, IHandle + { + private readonly IConfigFileProvider _configService; + private static AuthenticationRequiredType _authenticationRequired; + + public UiAuthorizationHandler(IConfigFileProvider configService) + { + _configService = configService; + _authenticationRequired = configService.AuthenticationRequired; + } + + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, BypassableDenyAnonymousAuthorizationRequirement requirement) + { + if (_authenticationRequired == AuthenticationRequiredType.DisabledForLocalAddresses) + { + if (context.Resource is HttpContext httpContext && + IPAddress.TryParse(httpContext.GetRemoteIP(), out var ipAddress) && + ipAddress.IsLocalAddress()) + { + context.Succeed(requirement); + } + } + + return Task.CompletedTask; + } + + public void Handle(ConfigSavedEvent message) + { + _authenticationRequired = _configService.AuthenticationRequired; + } + } +} diff --git a/src/Lidarr.Http/Authentication/UiAuthorizationPolicyProvider.cs b/src/Lidarr.Http/Authentication/UiAuthorizationPolicyProvider.cs index a5295a99f..50f1c3ada 100644 --- a/src/Lidarr.Http/Authentication/UiAuthorizationPolicyProvider.cs +++ b/src/Lidarr.Http/Authentication/UiAuthorizationPolicyProvider.cs @@ -29,7 +29,8 @@ namespace NzbDrone.Http.Authentication if (policyName.Equals(POLICY_NAME, StringComparison.OrdinalIgnoreCase)) { var policy = new AuthorizationPolicyBuilder(_config.AuthenticationMethod.ToString()) - .RequireAuthenticatedUser(); + .AddRequirements(new BypassableDenyAnonymousAuthorizationRequirement()); + return Task.FromResult(policy.Build()); } diff --git a/src/NzbDrone.Automation.Test/AutomationTest.cs b/src/NzbDrone.Automation.Test/AutomationTest.cs index 4626f6ba8..bcf777431 100644 --- a/src/NzbDrone.Automation.Test/AutomationTest.cs +++ b/src/NzbDrone.Automation.Test/AutomationTest.cs @@ -46,7 +46,7 @@ namespace NzbDrone.Automation.Test _runner = new NzbDroneRunner(LogManager.GetCurrentClassLogger(), null); _runner.KillAll(); - _runner.Start(); + _runner.Start(true); driver.Url = "http://localhost:8686"; diff --git a/src/NzbDrone.Core/Authentication/AuthenticationRequiredType.cs b/src/NzbDrone.Core/Authentication/AuthenticationRequiredType.cs new file mode 100644 index 000000000..dc3c2c770 --- /dev/null +++ b/src/NzbDrone.Core/Authentication/AuthenticationRequiredType.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Authentication +{ + public enum AuthenticationRequiredType + { + Enabled = 0, + DisabledForLocalAddresses = 1 + } +} diff --git a/src/NzbDrone.Core/Authentication/AuthenticationType.cs b/src/NzbDrone.Core/Authentication/AuthenticationType.cs index 9f21b07a7..ca408774b 100644 --- a/src/NzbDrone.Core/Authentication/AuthenticationType.cs +++ b/src/NzbDrone.Core/Authentication/AuthenticationType.cs @@ -1,9 +1,10 @@ -namespace NzbDrone.Core.Authentication +namespace NzbDrone.Core.Authentication { public enum AuthenticationType { None = 0, Basic = 1, - Forms = 2 + Forms = 2, + External = 3 } } diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index f1ac61012..2eec12d00 100644 --- a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs +++ b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs @@ -32,6 +32,7 @@ namespace NzbDrone.Core.Configuration bool EnableSsl { get; } bool LaunchBrowser { get; } AuthenticationType AuthenticationMethod { get; } + AuthenticationRequiredType AuthenticationRequired { get; } bool AnalyticsEnabled { get; } string LogLevel { get; } string ConsoleLogLevel { get; } @@ -189,6 +190,8 @@ namespace NzbDrone.Core.Configuration } } + public AuthenticationRequiredType AuthenticationRequired => GetValueEnum("AuthenticationRequired", AuthenticationRequiredType.Enabled); + public bool AnalyticsEnabled => GetValueBoolean("AnalyticsEnabled", true, persist: false); public string Branch => GetValue("Branch", "master").ToLowerInvariant(); diff --git a/src/NzbDrone.Host/Startup.cs b/src/NzbDrone.Host/Startup.cs index 2f0e13602..9b82a692e 100644 --- a/src/NzbDrone.Host/Startup.cs +++ b/src/NzbDrone.Host/Startup.cs @@ -172,6 +172,8 @@ namespace NzbDrone.Host .PersistKeysToFileSystem(new DirectoryInfo(Configuration["dataProtectionFolder"])); services.AddSingleton(); + services.AddSingleton(); + services.AddAuthorization(options => { options.AddPolicy("SignalR", policy => diff --git a/src/NzbDrone.Test.Common/NzbDroneRunner.cs b/src/NzbDrone.Test.Common/NzbDroneRunner.cs index 73afd6285..a956dff33 100644 --- a/src/NzbDrone.Test.Common/NzbDroneRunner.cs +++ b/src/NzbDrone.Test.Common/NzbDroneRunner.cs @@ -38,12 +38,12 @@ namespace NzbDrone.Test.Common Port = port; } - public void Start() + public void Start(bool enableAuth = false) { AppData = Path.Combine(TestContext.CurrentContext.TestDirectory, "_intg_" + TestBase.GetUID()); Directory.CreateDirectory(AppData); - GenerateConfigFile(); + GenerateConfigFile(enableAuth); string lidarrConsoleExe; if (OsInfo.IsWindows) @@ -177,7 +177,7 @@ namespace NzbDrone.Test.Common } } - private void GenerateConfigFile() + private void GenerateConfigFile(bool enableAuth) { var configFile = Path.Combine(AppData, "config.xml"); @@ -190,6 +190,8 @@ namespace NzbDrone.Test.Common new XElement(nameof(ConfigFileProvider.ApiKey), apiKey), new XElement(nameof(ConfigFileProvider.LogLevel), "trace"), new XElement(nameof(ConfigFileProvider.AnalyticsEnabled), false), + new XElement(nameof(ConfigFileProvider.AuthenticationMethod), enableAuth ? "Forms" : "None"), + new XElement(nameof(ConfigFileProvider.AuthenticationRequired), "DisabledForLocalAddresses"), new XElement(nameof(ConfigFileProvider.Port), Port))); var data = xDoc.ToString(); From 092e41264f2694eebd8a2f2b575fc791c03e01f9 Mon Sep 17 00:00:00 2001 From: Qstick Date: Sun, 15 Oct 2023 20:11:13 -0500 Subject: [PATCH 056/820] Always access config file via provider to utilize lock --- .../Authentication/UserService.cs | 36 +++++++++++++++++-- .../Configuration/ConfigFileProvider.cs | 3 +- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Core/Authentication/UserService.cs b/src/NzbDrone.Core/Authentication/UserService.cs index 73f70aa5b..00a7018de 100644 --- a/src/NzbDrone.Core/Authentication/UserService.cs +++ b/src/NzbDrone.Core/Authentication/UserService.cs @@ -1,7 +1,11 @@ using System; +using System.Linq; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Messaging.Events; namespace NzbDrone.Core.Authentication { @@ -15,17 +19,22 @@ namespace NzbDrone.Core.Authentication User FindUser(Guid identifier); } - public class UserService : IUserService + public class UserService : IUserService, IHandle { private readonly IUserRepository _repo; private readonly IAppFolderInfo _appFolderInfo; private readonly IDiskProvider _diskProvider; + private readonly IConfigFileProvider _configFileProvider; - public UserService(IUserRepository repo, IAppFolderInfo appFolderInfo, IDiskProvider diskProvider) + public UserService(IUserRepository repo, + IAppFolderInfo appFolderInfo, + IDiskProvider diskProvider, + IConfigFileProvider configFileProvider) { _repo = repo; _appFolderInfo = appFolderInfo; _diskProvider = diskProvider; + _configFileProvider = configFileProvider; } public User Add(string username, string password) @@ -93,5 +102,28 @@ namespace NzbDrone.Core.Authentication { return _repo.FindUser(identifier); } + + public void Handle(ApplicationStartedEvent message) + { + if (_repo.All().Any()) + { + return; + } + + var xDoc = _configFileProvider.LoadConfigFile(); + var config = xDoc.Descendants("Config").Single(); + var usernameElement = config.Descendants("Username").FirstOrDefault(); + var passwordElement = config.Descendants("Password").FirstOrDefault(); + + if (usernameElement == null || passwordElement == null) + { + return; + } + + var username = usernameElement.Value; + var password = passwordElement.Value; + + Add(username, password); + } } } diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index 2eec12d00..b0d68d688 100644 --- a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs +++ b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs @@ -22,6 +22,7 @@ namespace NzbDrone.Core.Configuration public interface IConfigFileProvider : IHandleAsync, IExecute { + XDocument LoadConfigFile(); Dictionary GetConfigDictionary(); void SaveConfigDictionary(Dictionary configValues); void EnsureDefaultConfigFile(); @@ -342,7 +343,7 @@ namespace NzbDrone.Core.Configuration SaveConfigFile(xDoc); } - private XDocument LoadConfigFile() + public XDocument LoadConfigFile() { try { From 8c04df640384602673ad7feab2cc9972ee67c8de Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 11 Jul 2022 20:00:10 -0700 Subject: [PATCH 057/820] New: Migrate user passwords to Pbkdf2 (cherry picked from commit 269e72a2193b584476bec338ef41e6fb2e5cbea6) (cherry picked from commit 104aadfdb7feb7143c41da790496a384ffb29fc8) --- src/NzbDrone.Core/Authentication/User.cs | 4 +- .../Authentication/UserService.cs | 76 ++++++++++++++++--- .../Migration/073_add_salt_to_users.cs | 16 ++++ src/NzbDrone.Core/Lidarr.Core.csproj | 1 + 4 files changed, 86 insertions(+), 11 deletions(-) create mode 100644 src/NzbDrone.Core/Datastore/Migration/073_add_salt_to_users.cs diff --git a/src/NzbDrone.Core/Authentication/User.cs b/src/NzbDrone.Core/Authentication/User.cs index 794d4824a..63c67bd5f 100644 --- a/src/NzbDrone.Core/Authentication/User.cs +++ b/src/NzbDrone.Core/Authentication/User.cs @@ -1,4 +1,4 @@ -using System; +using System; using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Authentication @@ -8,5 +8,7 @@ namespace NzbDrone.Core.Authentication public Guid Identifier { get; set; } public string Username { get; set; } public string Password { get; set; } + public string Salt { get; set; } + public int Iterations { get; set; } } } diff --git a/src/NzbDrone.Core/Authentication/UserService.cs b/src/NzbDrone.Core/Authentication/UserService.cs index 00a7018de..2a5cb5463 100644 --- a/src/NzbDrone.Core/Authentication/UserService.cs +++ b/src/NzbDrone.Core/Authentication/UserService.cs @@ -1,5 +1,7 @@ using System; using System.Linq; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; @@ -21,15 +23,16 @@ namespace NzbDrone.Core.Authentication public class UserService : IUserService, IHandle { + private const int ITERATIONS = 10000; + private const int SALT_SIZE = 128 / 8; + private const int NUMBER_OF_BYTES = 256 / 8; + private readonly IUserRepository _repo; private readonly IAppFolderInfo _appFolderInfo; private readonly IDiskProvider _diskProvider; private readonly IConfigFileProvider _configFileProvider; - public UserService(IUserRepository repo, - IAppFolderInfo appFolderInfo, - IDiskProvider diskProvider, - IConfigFileProvider configFileProvider) + public UserService(IUserRepository repo, IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider) { _repo = repo; _appFolderInfo = appFolderInfo; @@ -39,12 +42,15 @@ namespace NzbDrone.Core.Authentication public User Add(string username, string password) { - return _repo.Insert(new User + var user = new User { Identifier = Guid.NewGuid(), - Username = username.ToLowerInvariant(), - Password = password.SHA256Hash() - }); + Username = username.ToLowerInvariant() + }; + + SetUserHashedPassword(user, password); + + return _repo.Insert(user); } public User Update(User user) @@ -63,7 +69,7 @@ namespace NzbDrone.Core.Authentication if (user.Password != password) { - user.Password = password.SHA256Hash(); + SetUserHashedPassword(user, password); } user.Username = username.ToLowerInvariant(); @@ -90,7 +96,20 @@ namespace NzbDrone.Core.Authentication return null; } - if (user.Password == password.SHA256Hash()) + if (user.Salt.IsNullOrWhiteSpace()) + { + // If password matches stored SHA256 hash, update to salted hash and verify. + if (user.Password == password.SHA256Hash()) + { + SetUserHashedPassword(user, password); + + return Update(user); + } + + return null; + } + + if (VerifyHashedPassword(user, password)) { return user; } @@ -103,6 +122,43 @@ namespace NzbDrone.Core.Authentication return _repo.FindUser(identifier); } + private User SetUserHashedPassword(User user, string password) + { + var salt = GenerateSalt(); + + user.Iterations = ITERATIONS; + user.Salt = Convert.ToBase64String(salt); + user.Password = GetHashedPassword(password, salt, ITERATIONS); + + return user; + } + + private byte[] GenerateSalt() + { + var salt = new byte[SALT_SIZE]; + RandomNumberGenerator.Create().GetBytes(salt); + + return salt; + } + + private string GetHashedPassword(string password, byte[] salt, int iterations) + { + return Convert.ToBase64String(KeyDerivation.Pbkdf2( + password: password, + salt: salt, + prf: KeyDerivationPrf.HMACSHA512, + iterationCount: iterations, + numBytesRequested: NUMBER_OF_BYTES)); + } + + private bool VerifyHashedPassword(User user, string password) + { + var salt = Convert.FromBase64String(user.Salt); + var hashedPassword = GetHashedPassword(password, salt, user.Iterations); + + return user.Password == hashedPassword; + } + public void Handle(ApplicationStartedEvent message) { if (_repo.All().Any()) diff --git a/src/NzbDrone.Core/Datastore/Migration/073_add_salt_to_users.cs b/src/NzbDrone.Core/Datastore/Migration/073_add_salt_to_users.cs new file mode 100644 index 000000000..03a8e2432 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/073_add_salt_to_users.cs @@ -0,0 +1,16 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(073)] + public class add_salt_to_users : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Users") + .AddColumn("Salt").AsString().Nullable() + .AddColumn("Iterations").AsInt32().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Lidarr.Core.csproj b/src/NzbDrone.Core/Lidarr.Core.csproj index 9383aca2e..7f0bfcd8f 100644 --- a/src/NzbDrone.Core/Lidarr.Core.csproj +++ b/src/NzbDrone.Core/Lidarr.Core.csproj @@ -7,6 +7,7 @@ + From c4a339f0af74777a68b37b546284a9df942668cf Mon Sep 17 00:00:00 2001 From: Qstick Date: Thu, 20 Oct 2022 23:35:22 -0500 Subject: [PATCH 058/820] Bump SQLite to 3.42.0 (1.0.118) (cherry picked from commit e3160466e0c5b392d80f248db13c7934bc5d0117) --- src/NzbDrone.Common/Lidarr.Common.csproj | 2 +- src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj | 1 + src/NzbDrone.Core/Lidarr.Core.csproj | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Common/Lidarr.Common.csproj b/src/NzbDrone.Common/Lidarr.Common.csproj index fae33d38e..18dda6d36 100644 --- a/src/NzbDrone.Common/Lidarr.Common.csproj +++ b/src/NzbDrone.Common/Lidarr.Common.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj b/src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj index 6e7bffb4d..592951364 100644 --- a/src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj @@ -6,6 +6,7 @@ + diff --git a/src/NzbDrone.Core/Lidarr.Core.csproj b/src/NzbDrone.Core/Lidarr.Core.csproj index 7f0bfcd8f..919852e69 100644 --- a/src/NzbDrone.Core/Lidarr.Core.csproj +++ b/src/NzbDrone.Core/Lidarr.Core.csproj @@ -13,6 +13,7 @@ + From f0408353214d291a639b25afe38ed7f4579a5a10 Mon Sep 17 00:00:00 2001 From: Qstick Date: Tue, 20 Dec 2022 11:56:04 -0600 Subject: [PATCH 059/820] Handle auth options correctly in Security Settings (cherry picked from commit 0fad20e327503bac767c4df4c893f5e418866831) (cherry picked from commit 180dafe696be25a9903b6770997005577504a914) --- .../src/Settings/General/SecuritySettings.js | 52 +++++++++++++++---- src/NzbDrone.Core/Localization/Core/en.json | 4 ++ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js index 2495419ef..577ba0c78 100644 --- a/frontend/src/Settings/General/SecuritySettings.js +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -11,9 +11,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import { icons, inputTypes, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; -export const authenticationRequiredWarning = 'To prevent remote access without authentication, Radarr now requires authentication to be enabled. You can optionally disable authentication from local addresses.'; - -const authenticationMethodOptions = [ +export const authenticationMethodOptions = [ { key: 'none', get value() { @@ -21,6 +19,13 @@ const authenticationMethodOptions = [ }, isDisabled: true }, + { + key: 'external', + get value() { + return translate('External'); + }, + isHidden: true + }, { key: 'basic', get value() { @@ -36,14 +41,39 @@ const authenticationMethodOptions = [ ]; export const authenticationRequiredOptions = [ - { key: 'enabled', value: 'Enabled' }, - { key: 'disabledForLocalAddresses', value: 'Disabled for Local Addresses' } + { + key: 'enabled', + get value() { + return translate('Enabled'); + } + }, + { + key: 'disabledForLocalAddresses', + get value() { + return translate('DisabledForLocalAddresses'); + } + } ]; const certificateValidationOptions = [ - { key: 'enabled', value: 'Enabled' }, - { key: 'disabledForLocalAddresses', value: 'Disabled for Local Addresses' }, - { key: 'disabled', value: 'Disabled' } + { + key: 'enabled', + get value() { + return translate('Enabled'); + } + }, + { + key: 'disabledForLocalAddresses', + get value() { + return translate('DisabledForLocalAddresses'); + } + }, + { + key: 'disabled', + get value() { + return translate('Disabled'); + } + } ]; class SecuritySettings extends Component { @@ -112,7 +142,7 @@ class SecuritySettings extends Component { name="authenticationMethod" values={authenticationMethodOptions} helpText={translate('AuthenticationMethodHelpText')} - helpTextWarning={authenticationRequiredWarning} + helpTextWarning={translate('AuthenticationRequiredWarning')} onChange={onInputChange} {...authenticationMethod} /> @@ -121,13 +151,13 @@ class SecuritySettings extends Component { { authenticationEnabled ? - Authentication Required + {translate('AuthenticationRequired')} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 6f13b6d72..670c26a5c 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -103,6 +103,8 @@ "ArtistType": "Artist Type", "Artists": "Artists", "AudioInfo": "Audio Info", + "AuthBasic": "Basic (Browser Popup)", + "AuthForm": "Forms (Login Page)", "Authentication": "Authentication", "AuthenticationMethodHelpText": "Require Username and Password to access Lidarr", "AutoAdd": "Auto Add", @@ -269,6 +271,7 @@ "DetailedProgressBarHelpText": "Show text on progess bar", "Details": "Details", "Disabled": "Disabled", + "DisabledForLocalAddresses": "Disabled for Local Addresses", "Disambiguation": "Disambiguation", "DiscCount": "Disc Count", "DiscNumber": "Disc Number", @@ -363,6 +366,7 @@ "ExpandOtherByDefaultHelpText": "Other", "ExpandSingleByDefaultHelpText": "Singles", "ExportCustomFormat": "Export Custom Format", + "External": "External", "ExtraFileExtensionsHelpTexts1": "Comma separated list of extra files to import (.nfo will be imported as .nfo-orig)", "ExtraFileExtensionsHelpTexts2": "\"Examples: \".sub", "FailedDownloadHandling": "Failed Download Handling", From aa65dadc4976c9b6d7f8e8920d52c4f85d60dfbb Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 23 May 2022 20:41:50 -0700 Subject: [PATCH 060/820] New: Don't return API Keys and Passwords via the API (cherry picked from commit 570be882154e73f8ad1de5b16b957bcb964697fd) (cherry picked from commit 508a15e09ac1b08a90837d371353cdf11cd9ee3c) --- .../CustomFormats/CustomFormatResource.cs | 5 ++- .../DownloadClient/DownloadClientResource.cs | 4 +-- .../ImportLists/ImportListResource.cs | 4 +-- src/Lidarr.Api.V1/Indexers/IndexerResource.cs | 4 +-- .../Metadata/MetadataResource.cs | 4 +-- .../Notifications/NotificationResource.cs | 4 +-- src/Lidarr.Api.V1/ProviderControllerBase.cs | 3 +- src/Lidarr.Api.V1/ProviderResource.cs | 4 +-- src/Lidarr.Http/ClientSchema/Field.cs | 2 ++ src/Lidarr.Http/ClientSchema/SchemaBuilder.cs | 35 ++++++++++++++----- 10 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/Lidarr.Api.V1/CustomFormats/CustomFormatResource.cs b/src/Lidarr.Api.V1/CustomFormats/CustomFormatResource.cs index c5f5dbee8..48f663149 100644 --- a/src/Lidarr.Api.V1/CustomFormats/CustomFormatResource.cs +++ b/src/Lidarr.Api.V1/CustomFormats/CustomFormatResource.cs @@ -65,7 +65,10 @@ namespace Lidarr.Api.V1.CustomFormats var type = matchingSpec.GetType(); - var spec = (ICustomFormatSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type); + // Finding the exact current specification isn't possible given the dynamic nature of them and the possibility that multiple + // of the same type exist within the same format. Passing in null is safe as long as there never exists a specification that + // relies on additional privacy. + var spec = (ICustomFormatSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type, null); spec.Name = resource.Name; spec.Negate = resource.Negate; spec.Required = resource.Required; diff --git a/src/Lidarr.Api.V1/DownloadClient/DownloadClientResource.cs b/src/Lidarr.Api.V1/DownloadClient/DownloadClientResource.cs index 42f8d69db..0e5e60bed 100644 --- a/src/Lidarr.Api.V1/DownloadClient/DownloadClientResource.cs +++ b/src/Lidarr.Api.V1/DownloadClient/DownloadClientResource.cs @@ -32,14 +32,14 @@ namespace Lidarr.Api.V1.DownloadClient return resource; } - public override DownloadClientDefinition ToModel(DownloadClientResource resource) + public override DownloadClientDefinition ToModel(DownloadClientResource resource, DownloadClientDefinition existingDefinition) { if (resource == null) { return null; } - var definition = base.ToModel(resource); + var definition = base.ToModel(resource, existingDefinition); definition.Enable = resource.Enable; definition.Protocol = resource.Protocol; diff --git a/src/Lidarr.Api.V1/ImportLists/ImportListResource.cs b/src/Lidarr.Api.V1/ImportLists/ImportListResource.cs index e6a56bd21..0e89d88ff 100644 --- a/src/Lidarr.Api.V1/ImportLists/ImportListResource.cs +++ b/src/Lidarr.Api.V1/ImportLists/ImportListResource.cs @@ -45,14 +45,14 @@ namespace Lidarr.Api.V1.ImportLists return resource; } - public override ImportListDefinition ToModel(ImportListResource resource) + public override ImportListDefinition ToModel(ImportListResource resource, ImportListDefinition existingDefinition) { if (resource == null) { return null; } - var definition = base.ToModel(resource); + var definition = base.ToModel(resource, existingDefinition); definition.EnableAutomaticAdd = resource.EnableAutomaticAdd; definition.ShouldMonitor = resource.ShouldMonitor; diff --git a/src/Lidarr.Api.V1/Indexers/IndexerResource.cs b/src/Lidarr.Api.V1/Indexers/IndexerResource.cs index c5c9589eb..2514ac5da 100644 --- a/src/Lidarr.Api.V1/Indexers/IndexerResource.cs +++ b/src/Lidarr.Api.V1/Indexers/IndexerResource.cs @@ -37,14 +37,14 @@ namespace Lidarr.Api.V1.Indexers return resource; } - public override IndexerDefinition ToModel(IndexerResource resource) + public override IndexerDefinition ToModel(IndexerResource resource, IndexerDefinition existingDefinition) { if (resource == null) { return null; } - var definition = base.ToModel(resource); + var definition = base.ToModel(resource, existingDefinition); definition.EnableRss = resource.EnableRss; definition.EnableAutomaticSearch = resource.EnableAutomaticSearch; diff --git a/src/Lidarr.Api.V1/Metadata/MetadataResource.cs b/src/Lidarr.Api.V1/Metadata/MetadataResource.cs index 8072bc444..aa655b5df 100644 --- a/src/Lidarr.Api.V1/Metadata/MetadataResource.cs +++ b/src/Lidarr.Api.V1/Metadata/MetadataResource.cs @@ -23,14 +23,14 @@ namespace Lidarr.Api.V1.Metadata return resource; } - public override MetadataDefinition ToModel(MetadataResource resource) + public override MetadataDefinition ToModel(MetadataResource resource, MetadataDefinition existingDefinition) { if (resource == null) { return null; } - var definition = base.ToModel(resource); + var definition = base.ToModel(resource, existingDefinition); definition.Enable = resource.Enable; diff --git a/src/Lidarr.Api.V1/Notifications/NotificationResource.cs b/src/Lidarr.Api.V1/Notifications/NotificationResource.cs index df705d42d..0f28e8140 100644 --- a/src/Lidarr.Api.V1/Notifications/NotificationResource.cs +++ b/src/Lidarr.Api.V1/Notifications/NotificationResource.cs @@ -73,14 +73,14 @@ namespace Lidarr.Api.V1.Notifications return resource; } - public override NotificationDefinition ToModel(NotificationResource resource) + public override NotificationDefinition ToModel(NotificationResource resource, NotificationDefinition existingDefinition) { if (resource == null) { return default(NotificationDefinition); } - var definition = base.ToModel(resource); + var definition = base.ToModel(resource, existingDefinition); definition.OnGrab = resource.OnGrab; definition.OnReleaseImport = resource.OnReleaseImport; diff --git a/src/Lidarr.Api.V1/ProviderControllerBase.cs b/src/Lidarr.Api.V1/ProviderControllerBase.cs index 5b5e14249..be805c5d1 100644 --- a/src/Lidarr.Api.V1/ProviderControllerBase.cs +++ b/src/Lidarr.Api.V1/ProviderControllerBase.cs @@ -143,7 +143,8 @@ namespace Lidarr.Api.V1 private TProviderDefinition GetDefinition(TProviderResource providerResource, bool validate, bool includeWarnings, bool forceValidate) { - var definition = _resourceMapper.ToModel(providerResource); + var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null; + var definition = _resourceMapper.ToModel(providerResource, existingDefinition); if (validate && (definition.Enable || forceValidate)) { diff --git a/src/Lidarr.Api.V1/ProviderResource.cs b/src/Lidarr.Api.V1/ProviderResource.cs index b2d501a27..0bd7aa6d3 100644 --- a/src/Lidarr.Api.V1/ProviderResource.cs +++ b/src/Lidarr.Api.V1/ProviderResource.cs @@ -44,7 +44,7 @@ namespace Lidarr.Api.V1 }; } - public virtual TProviderDefinition ToModel(TProviderResource resource) + public virtual TProviderDefinition ToModel(TProviderResource resource, TProviderDefinition existingDefinition) { if (resource == null) { @@ -64,7 +64,7 @@ namespace Lidarr.Api.V1 }; var configContract = ReflectionExtensions.CoreAssembly.FindTypeByName(definition.ConfigContract); - definition.Settings = (IProviderConfig)SchemaBuilder.ReadFromSchema(resource.Fields, configContract); + definition.Settings = (IProviderConfig)SchemaBuilder.ReadFromSchema(resource.Fields, configContract, existingDefinition?.Settings); return definition; } diff --git a/src/Lidarr.Http/ClientSchema/Field.cs b/src/Lidarr.Http/ClientSchema/Field.cs index 2973521c2..2516f5ae5 100644 --- a/src/Lidarr.Http/ClientSchema/Field.cs +++ b/src/Lidarr.Http/ClientSchema/Field.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using NzbDrone.Core.Annotations; namespace Lidarr.Http.ClientSchema { @@ -18,6 +19,7 @@ namespace Lidarr.Http.ClientSchema public string SelectOptionsProviderAction { get; set; } public string Section { get; set; } public string Hidden { get; set; } + public PrivacyLevel Privacy { get; set; } public string Placeholder { get; set; } public bool IsFloat { get; set; } diff --git a/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs b/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs index 933925054..e0ee1c359 100644 --- a/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs +++ b/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs @@ -13,6 +13,7 @@ namespace Lidarr.Http.ClientSchema { public static class SchemaBuilder { + private const string PRIVATE_VALUE = "********"; private static Dictionary _mappings = new Dictionary(); public static List ToSchema(object model) @@ -26,7 +27,15 @@ namespace Lidarr.Http.ClientSchema foreach (var mapping in mappings) { var field = mapping.Field.Clone(); - field.Value = mapping.GetterFunc(model); + + if (field.Privacy == PrivacyLevel.ApiKey || field.Privacy == PrivacyLevel.Password) + { + field.Value = PRIVATE_VALUE; + } + else + { + field.Value = mapping.GetterFunc(model); + } result.Add(field); } @@ -34,7 +43,7 @@ namespace Lidarr.Http.ClientSchema return result.OrderBy(r => r.Order).ToList(); } - public static object ReadFromSchema(List fields, Type targetType) + public static object ReadFromSchema(List fields, Type targetType, object model) { Ensure.That(targetType, () => targetType).IsNotNull(); @@ -48,18 +57,25 @@ namespace Lidarr.Http.ClientSchema if (field != null) { - mapping.SetterFunc(target, field.Value); + // Use the Privacy property from the mapping's field as Privacy may not be set in the API request (nor is it required) + if ((mapping.Field.Privacy == PrivacyLevel.ApiKey || mapping.Field.Privacy == PrivacyLevel.Password) && + (field.Value?.ToString()?.Equals(PRIVATE_VALUE) ?? false) && + model != null) + { + var existingValue = mapping.GetterFunc(model); + + mapping.SetterFunc(target, existingValue); + } + else + { + mapping.SetterFunc(target, field.Value); + } } } return target; } - public static T ReadFromSchema(List fields) - { - return (T)ReadFromSchema(fields, typeof(T)); - } - // Ideally this function should begin a System.Linq.Expression expression tree since it's faster. // But it's probably not needed till performance issues pop up. public static FieldMapping[] GetFieldMappings(Type type) @@ -104,6 +120,7 @@ namespace Lidarr.Http.ClientSchema Advanced = fieldAttribute.Advanced, Type = fieldAttribute.Type.ToString().FirstCharToLower(), Section = fieldAttribute.Section, + Privacy = fieldAttribute.Privacy, Placeholder = fieldAttribute.Placeholder }; @@ -136,7 +153,7 @@ namespace Lidarr.Http.ClientSchema Field = field, PropertyType = propertyInfo.PropertyType, GetterFunc = t => propertyInfo.GetValue(targetSelector(t), null), - SetterFunc = (t, v) => propertyInfo.SetValue(targetSelector(t), valueConverter(v), null) + SetterFunc = (t, v) => propertyInfo.SetValue(targetSelector(t), v?.GetType() == propertyInfo.PropertyType ? v : valueConverter(v), null) }); } else From 2d140e35bd268aa2d56f7e3139285e2333ecf34b Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 15 Aug 2022 13:39:24 -0700 Subject: [PATCH 061/820] Don't replace private values that haven't been set (cherry picked from commit 52760e0908fa9852ed8a770f1916bb582eb8c8b4) (cherry picked from commit 0c6eae256b76c9cb1462c6bc1acf6d49e9a28794) --- src/Lidarr.Http/ClientSchema/SchemaBuilder.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs b/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs index e0ee1c359..9aacabbda 100644 --- a/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs +++ b/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs @@ -27,15 +27,13 @@ namespace Lidarr.Http.ClientSchema foreach (var mapping in mappings) { var field = mapping.Field.Clone(); + field.Value = mapping.GetterFunc(model); - if (field.Privacy == PrivacyLevel.ApiKey || field.Privacy == PrivacyLevel.Password) + if (field.Value != null && !field.Value.Equals(string.Empty) && + (field.Privacy == PrivacyLevel.ApiKey || field.Privacy == PrivacyLevel.Password)) { field.Value = PRIVATE_VALUE; } - else - { - field.Value = mapping.GetterFunc(model); - } result.Add(field); } From 6c4db36cdf32ac1a8c4512d849e16396a1da080a Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 5 Dec 2022 23:00:27 -0800 Subject: [PATCH 062/820] New: Show all options when authentication modal is open (cherry picked from commit c7d6c0f45264944bb5c00374ff025344218ef7eb) (cherry picked from commit ca93a72d63b89f7b1f3346643cc549e4df617263) --- .../AuthenticationRequiredModalContent.js | 83 ++++++++----------- src/NzbDrone.Core/Localization/Core/en.json | 4 + 2 files changed, 40 insertions(+), 47 deletions(-) diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js index 985186fde..5b9141a7b 100644 --- a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js @@ -11,7 +11,8 @@ 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 { authenticationMethodOptions, authenticationRequiredOptions, authenticationRequiredWarning } from 'Settings/General/SecuritySettings'; +import { authenticationMethodOptions, authenticationRequiredOptions } from 'Settings/General/SecuritySettings'; +import translate from 'Utilities/String/translate'; import styles from './AuthenticationRequiredModalContent.css'; function onModalClose() { @@ -54,7 +55,7 @@ function AuthenticationRequiredModalContent(props) { onModalClose={onModalClose} > - Authentication Required + {translate('AuthenticationRequired')} @@ -62,71 +63,59 @@ function AuthenticationRequiredModalContent(props) { className={styles.authRequiredAlert} kind={kinds.WARNING} > - {authenticationRequiredWarning} + {translate('AuthenticationRequiredWarning')} { isPopulated && !error ?
- Authentication + {translate('Authentication')} - { - authenticationEnabled ? - - Authentication Required + + {translate('AuthenticationRequired')} - - : - null - } + + - { - authenticationEnabled ? - - Username + + {translate('Username')} - - : - null - } + + - { - authenticationEnabled ? - - Password + + {translate('Password')} - - : - null - } + +
: null } @@ -143,7 +132,7 @@ function AuthenticationRequiredModalContent(props) { isDisabled={!authenticationEnabled} onPress={onSavePress} > - Save + {translate('Save')} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 670c26a5c..b29e17432 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -108,6 +108,10 @@ "Authentication": "Authentication", "AuthenticationMethodHelpText": "Require Username and Password to access Lidarr", "AutoAdd": "Auto Add", + "AuthenticationRequired": "Authentication Required", + "AuthenticationRequiredHelpText": "Change which requests authentication is required for. Do not change unless you understand the risks.", + "AuthenticationRequiredWarning": "To prevent remote access without authentication, Lidarr now requires authentication to be enabled. You can optionally disable authentication from local addresses.", + "Auto": "Auto", "AutoRedownloadFailedHelpText": "Automatically search for and attempt to download a different release", "Automatic": "Automatic", "AutomaticAdd": "Automatic Add", From 5946456c49820bcf38aa150b9cc1aba3117865c6 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 28 Aug 2023 22:31:12 +0300 Subject: [PATCH 063/820] Improve messaging in Authentication Required modal Co-authored-by: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> (cherry picked from commit 340740377eb83527814f6c9b6f3602321d4a4344) --- .../src/FirstRun/AuthenticationRequiredModalContent.js | 10 +++++++--- frontend/src/Settings/General/SecuritySettings.js | 4 ++-- src/NzbDrone.Core/Localization/Core/en.json | 10 +++++++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js index 5b9141a7b..c7fb75c07 100644 --- a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js @@ -63,20 +63,22 @@ function AuthenticationRequiredModalContent(props) { className={styles.authRequiredAlert} kind={kinds.WARNING} > - {translate('AuthenticationRequiredWarning')} + {translate('AuthenticationRequiredWarning', { appName: 'Lidarr' })} { isPopulated && !error ?
- {translate('Authentication')} + {translate('AuthenticationMethod')} @@ -102,6 +104,7 @@ function AuthenticationRequiredModalContent(props) { type={inputTypes.TEXT} name="username" onChange={onInputChange} + helpTextWarning={username.value === '' ? translate('AuthenticationRequiredUsernameHelpTextWarning') : undefined} {...username} /> @@ -113,6 +116,7 @@ function AuthenticationRequiredModalContent(props) { type={inputTypes.PASSWORD} name="password" onChange={onInputChange} + helpTextWarning={password.value === '' ? translate('AuthenticationRequiredPasswordHelpTextWarning') : undefined} {...password} /> diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js index 577ba0c78..551a14a4e 100644 --- a/frontend/src/Settings/General/SecuritySettings.js +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -141,8 +141,8 @@ class SecuritySettings extends Component { type={inputTypes.SELECT} name="authenticationMethod" values={authenticationMethodOptions} - helpText={translate('AuthenticationMethodHelpText')} - helpTextWarning={translate('AuthenticationRequiredWarning')} + helpText={translate('AuthenticationMethodHelpText', { appName: 'Lidarr' })} + helpTextWarning={translate('AuthenticationRequiredWarning', { appName: 'Lidarr' })} onChange={onInputChange} {...authenticationMethod} /> diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index b29e17432..04a66a2d2 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -106,12 +106,16 @@ "AuthBasic": "Basic (Browser Popup)", "AuthForm": "Forms (Login Page)", "Authentication": "Authentication", - "AuthenticationMethodHelpText": "Require Username and Password to access Lidarr", - "AutoAdd": "Auto Add", + "AuthenticationMethod": "Authentication Method", + "AuthenticationMethodHelpText": "Require Username and Password to access {appName}", + "AuthenticationMethodHelpTextWarning": "Please select a valid authentication method", "AuthenticationRequired": "Authentication Required", "AuthenticationRequiredHelpText": "Change which requests authentication is required for. Do not change unless you understand the risks.", - "AuthenticationRequiredWarning": "To prevent remote access without authentication, Lidarr now requires authentication to be enabled. You can optionally disable authentication from local addresses.", + "AuthenticationRequiredPasswordHelpTextWarning": "Enter a new password", + "AuthenticationRequiredUsernameHelpTextWarning": "Enter a new username", + "AuthenticationRequiredWarning": "To prevent remote access without authentication, {appName} now requires authentication to be enabled. You can optionally disable authentication from local addresses.", "Auto": "Auto", + "AutoAdd": "Auto Add", "AutoRedownloadFailedHelpText": "Automatically search for and attempt to download a different release", "Automatic": "Automatic", "AutomaticAdd": "Automatic Add", From cc4bd01676b18c9575e080c9a38844ebd537b42a Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 29 Aug 2023 23:13:50 +0300 Subject: [PATCH 064/820] Fixed: User check in Authentication Required modal (cherry picked from commit 33c52a70375a1b4ebbcc2cab9a19557f2cb933b2) --- frontend/src/FirstRun/AuthenticationRequiredModalContent.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js index c7fb75c07..4ad2d1b09 100644 --- a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js @@ -104,7 +104,7 @@ function AuthenticationRequiredModalContent(props) { type={inputTypes.TEXT} name="username" onChange={onInputChange} - helpTextWarning={username.value === '' ? translate('AuthenticationRequiredUsernameHelpTextWarning') : undefined} + helpTextWarning={username?.value ? undefined : translate('AuthenticationRequiredUsernameHelpTextWarning')} {...username} /> @@ -116,7 +116,7 @@ function AuthenticationRequiredModalContent(props) { type={inputTypes.PASSWORD} name="password" onChange={onInputChange} - helpTextWarning={password.value === '' ? translate('AuthenticationRequiredPasswordHelpTextWarning') : undefined} + helpTextWarning={password?.value ? undefined : translate('AuthenticationRequiredPasswordHelpTextWarning')} {...password} /> From 26efcced357f57ad7d90da1577d08b109ea8a018 Mon Sep 17 00:00:00 2001 From: Qstick Date: Fri, 20 Oct 2023 19:55:58 -0500 Subject: [PATCH 065/820] Remove unused regex in parser --- src/NzbDrone.Core/Parser/Parser.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 1b33d613d..ac91cf69d 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -202,9 +202,6 @@ namespace NzbDrone.Core.Parser private static readonly Regex AnimeReleaseGroupRegex = new Regex(@"^(?:\[(?(?!\s).+?(?.+?)(?:\W|_)?(?\d{4})", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex WordDelimiterRegex = new Regex(@"(\s|\.|,|_|-|=|\|)+", RegexOptions.Compiled); private static readonly Regex PunctuationRegex = new Regex(@"[^\w\s]", RegexOptions.Compiled); private static readonly Regex CommonWordRegex = new Regex(@"\b(a|an|the|and|or|of)\b\s?", RegexOptions.IgnoreCase | RegexOptions.Compiled); From 0a52be5c9e04726884c3b60a60478a06159f44f2 Mon Sep 17 00:00:00 2001 From: Qstick Date: Fri, 20 Oct 2023 20:00:33 -0500 Subject: [PATCH 066/820] Fixed: Improved messaging when track file was detected as deleted from disk Closes #2541 --- frontend/src/Activity/History/Details/HistoryDetails.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js index a439194f6..b90a64f47 100644 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -264,7 +264,7 @@ function HistoryDetails(props) { reasonMessage = 'File was deleted by via UI'; break; case 'MissingFromDisk': - reasonMessage = 'Lidarr was unable to find the file on disk so it was removed'; + reasonMessage = 'Lidarr was unable to find the file on disk so the file was unlinked from the album/track in the database'; break; case 'Upgrade': reasonMessage = 'File was deleted to import an upgrade'; From 4ea5c68216c5f2c9b80971776049599ee3c42f61 Mon Sep 17 00:00:00 2001 From: Qstick Date: Fri, 20 Oct 2023 20:36:51 -0500 Subject: [PATCH 067/820] Fixed: Blocklisting pending releases Closes #2357 Closes #2478 Closes #3247 Co-Authored-By: Mark McDowall --- frontend/src/Activity/Queue/Queue.js | 11 +++ frontend/src/Activity/Queue/QueueRow.js | 1 + .../Activity/Queue/RemoveQueueItemModal.js | 32 +++++---- .../Activity/Queue/RemoveQueueItemsModal.js | 32 +++++---- src/Lidarr.Api.V1/Queue/QueueController.cs | 72 +++++++++++++------ .../Blocklisting/BlocklistService.cs | 25 +++++++ .../Download/Pending/PendingReleaseService.cs | 42 +++++------ 7 files changed, 144 insertions(+), 71 deletions(-) diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js index b73ce0ad7..27d5f9e26 100644 --- a/frontend/src/Activity/Queue/Queue.js +++ b/frontend/src/Activity/Queue/Queue.js @@ -299,6 +299,17 @@ class Queue extends Component { return !!(item && item.artistId && item.albumId); }) )} + allPending={isConfirmRemoveModalOpen && ( + selectedIds.every((id) => { + const item = items.find((i) => i.id === id); + + if (!item) { + return false; + } + + return item.status === 'delay' || item.status === 'downloadClientUnavailable'; + }) + )} onRemovePress={this.onRemoveSelectedConfirmed} onModalClose={this.onConfirmRemoveModalClose} /> diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js index 08634d9f7..4c2829f55 100644 --- a/frontend/src/Activity/Queue/QueueRow.js +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -394,6 +394,7 @@ class QueueRow extends Component { isOpen={isRemoveQueueItemModalOpen} sourceTitle={title} canIgnore={!!artist} + isPending={isPending} onRemovePress={this.onRemoveQueueItemModalConfirmed} onModalClose={this.onRemoveQueueItemModalClose} /> diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js index d9e4dd7f6..4509791fc 100644 --- a/frontend/src/Activity/Queue/RemoveQueueItemModal.js +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.js @@ -72,7 +72,8 @@ class RemoveQueueItemModal extends Component { const { isOpen, sourceTitle, - canIgnore + canIgnore, + isPending } = this.props; const { removeFromClient, blocklist, skipRedownload } = this.state; @@ -95,20 +96,22 @@ class RemoveQueueItemModal extends Component { Are you sure you want to remove '{sourceTitle}' from the queue?
- - - {translate('RemoveFromDownloadClient')} - + { + isPending ? + null : + + {translate('RemoveFromDownloadClient')} - - + + + } @@ -164,6 +167,7 @@ RemoveQueueItemModal.propTypes = { isOpen: PropTypes.bool.isRequired, sourceTitle: PropTypes.string.isRequired, canIgnore: PropTypes.bool.isRequired, + isPending: PropTypes.bool.isRequired, onRemovePress: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js index 3b9164e68..f607161b0 100644 --- a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js +++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js @@ -73,7 +73,8 @@ class RemoveQueueItemsModal extends Component { const { isOpen, selectedCount, - canIgnore + canIgnore, + allPending } = this.props; const { removeFromClient, blocklist, skipRedownload } = this.state; @@ -96,20 +97,22 @@ class RemoveQueueItemsModal extends Component { {selectedCount > 1 ? translate('RemoveSelectedItemsQueueMessageText', [selectedCount]) : translate('RemoveSelectedItemQueueMessageText')} - - - {translate('RemoveFromDownloadClient')} - + { + allPending ? + null : + + {translate('RemoveFromDownloadClient')} - - + + + } @@ -165,6 +168,7 @@ RemoveQueueItemsModal.propTypes = { isOpen: PropTypes.bool.isRequired, selectedCount: PropTypes.number.isRequired, canIgnore: PropTypes.bool.isRequired, + allPending: PropTypes.bool.isRequired, onRemovePress: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/src/Lidarr.Api.V1/Queue/QueueController.cs b/src/Lidarr.Api.V1/Queue/QueueController.cs index 4bc5af52a..ddc6cafe8 100644 --- a/src/Lidarr.Api.V1/Queue/QueueController.cs +++ b/src/Lidarr.Api.V1/Queue/QueueController.cs @@ -7,6 +7,7 @@ using Lidarr.Http.REST; using Lidarr.Http.REST.Attributes; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Blocklisting; using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Download; @@ -17,6 +18,7 @@ using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Queue; using NzbDrone.SignalR; +using Sentry.Protocol; namespace Lidarr.Api.V1.Queue { @@ -32,6 +34,7 @@ namespace Lidarr.Api.V1.Queue private readonly IFailedDownloadService _failedDownloadService; private readonly IIgnoredDownloadService _ignoredDownloadService; private readonly IProvideDownloadClient _downloadClientProvider; + private readonly IBlocklistService _blocklistService; public QueueController(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, @@ -40,7 +43,8 @@ namespace Lidarr.Api.V1.Queue ITrackedDownloadService trackedDownloadService, IFailedDownloadService failedDownloadService, IIgnoredDownloadService ignoredDownloadService, - IProvideDownloadClient downloadClientProvider) + IProvideDownloadClient downloadClientProvider, + IBlocklistService blocklistService) : base(broadcastSignalRMessage) { _queueService = queueService; @@ -49,6 +53,7 @@ namespace Lidarr.Api.V1.Queue _failedDownloadService = failedDownloadService; _ignoredDownloadService = ignoredDownloadService; _downloadClientProvider = downloadClientProvider; + _blocklistService = blocklistService; _qualityComparer = new QualityModelComparer(qualityProfileService.GetDefaultProfile(string.Empty)); } @@ -62,29 +67,62 @@ namespace Lidarr.Api.V1.Queue [RestDeleteById] public void RemoveAction(int id, bool removeFromClient = true, bool blocklist = false, bool skipRedownload = false) { - var trackedDownload = Remove(id, removeFromClient, blocklist, skipRedownload); + var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); - if (trackedDownload != null) + if (pendingRelease != null) { - _trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId); + Remove(pendingRelease); + + return; } + + var trackedDownload = GetTrackedDownload(id); + + if (trackedDownload == null) + { + throw new NotFoundException(); + } + + Remove(trackedDownload, removeFromClient, blocklist, skipRedownload); + _trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId); } [HttpDelete("bulk")] public object RemoveMany([FromBody] QueueBulkResource resource, [FromQuery] bool removeFromClient = true, [FromQuery] bool blocklist = false, [FromQuery] bool skipRedownload = false) { var trackedDownloadIds = new List(); + var pendingToRemove = new List(); + var trackedToRemove = new List(); foreach (var id in resource.Ids) { - var trackedDownload = Remove(id, removeFromClient, blocklist, skipRedownload); + var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); + + if (pendingRelease != null) + { + pendingToRemove.Add(pendingRelease); + continue; + } + + var trackedDownload = GetTrackedDownload(id); if (trackedDownload != null) { - trackedDownloadIds.Add(trackedDownload.DownloadItem.DownloadId); + trackedToRemove.Add(trackedDownload); } } + foreach (var pendingRelease in pendingToRemove.DistinctBy(p => p.Id)) + { + Remove(pendingRelease); + } + + foreach (var trackedDownload in trackedToRemove.DistinctBy(t => t.DownloadItem.DownloadId)) + { + Remove(trackedDownload, removeFromClient, blocklist, skipRedownload); + trackedDownloadIds.Add(trackedDownload.DownloadItem.DownloadId); + } + _trackedDownloadService.StopTracking(trackedDownloadIds); return new { }; @@ -195,24 +233,14 @@ namespace Lidarr.Api.V1.Queue } } - private TrackedDownload Remove(int id, bool removeFromClient, bool blocklist, bool skipRedownload) + private void Remove(NzbDrone.Core.Queue.Queue pendingRelease) { - var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); - - if (pendingRelease != null) - { - _pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id); - - return null; - } - - var trackedDownload = GetTrackedDownload(id); - - if (trackedDownload == null) - { - throw new NotFoundException(); - } + _blocklistService.Block(pendingRelease.RemoteAlbum, "Pending release manually blocklisted"); + _pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id); + } + private TrackedDownload Remove(TrackedDownload trackedDownload, bool removeFromClient, bool blocklist, bool skipRedownload) + { if (removeFromClient) { var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient); diff --git a/src/NzbDrone.Core/Blocklisting/BlocklistService.cs b/src/NzbDrone.Core/Blocklisting/BlocklistService.cs index 707147b73..783d94782 100644 --- a/src/NzbDrone.Core/Blocklisting/BlocklistService.cs +++ b/src/NzbDrone.Core/Blocklisting/BlocklistService.cs @@ -16,6 +16,7 @@ namespace NzbDrone.Core.Blocklisting { bool Blocklisted(int artistId, ReleaseInfo release); PagingSpec Paged(PagingSpec pagingSpec); + void Block(RemoteAlbum remoteAlbum, string message); void Delete(int id); void Delete(List ids); } @@ -66,6 +67,30 @@ namespace NzbDrone.Core.Blocklisting return _blocklistRepository.GetPaged(pagingSpec); } + public void Block(RemoteAlbum remoteAlbum, string message) + { + var blocklist = new Blocklist + { + ArtistId = remoteAlbum.Artist.Id, + AlbumIds = remoteAlbum.Albums.Select(e => e.Id).ToList(), + SourceTitle = remoteAlbum.Release.Title, + Quality = remoteAlbum.ParsedAlbumInfo.Quality, + Date = DateTime.UtcNow, + PublishedDate = remoteAlbum.Release.PublishDate, + Size = remoteAlbum.Release.Size, + Indexer = remoteAlbum.Release.Indexer, + Protocol = remoteAlbum.Release.DownloadProtocol, + Message = message, + }; + + if (remoteAlbum.Release is TorrentInfo torrentRelease) + { + blocklist.TorrentInfoHash = torrentRelease.InfoHash; + } + + _blocklistRepository.Insert(blocklist); + } + public void Delete(int id) { _blocklistRepository.Delete(id); diff --git a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs index ace9a46d5..b3a69d16c 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs @@ -137,13 +137,6 @@ namespace NzbDrone.Core.Download.Pending } } - private ILookup CreateAlbumLookup(IEnumerable alreadyPending) - { - return alreadyPending.SelectMany(v => v.RemoteAlbum.Albums - .Select(d => new { Album = d, PendingRelease = v })) - .ToLookup(v => v.Album.Id, v => v.PendingRelease); - } - public List GetPending() { var releases = _repository.All().Select(p => @@ -163,13 +156,6 @@ namespace NzbDrone.Core.Download.Pending return releases; } - private List FilterBlockedIndexers(List releases) - { - var blockedIndexers = new HashSet(_indexerStatusService.GetBlockedProviders().Select(v => v.ProviderId)); - - return releases.Where(release => !blockedIndexers.Contains(release.IndexerId)).ToList(); - } - public List GetPendingRemoteAlbums(int artistId) { return IncludeRemoteAlbums(_repository.AllByArtistId(artistId)).Select(v => v.RemoteAlbum).ToList(); @@ -263,6 +249,20 @@ namespace NzbDrone.Core.Download.Pending .MaxBy(p => p.Release.AgeHours); } + private ILookup CreateAlbumLookup(IEnumerable alreadyPending) + { + return alreadyPending.SelectMany(v => v.RemoteAlbum.Albums + .Select(d => new { Album = d, PendingRelease = v })) + .ToLookup(v => v.Album.Id, v => v.PendingRelease); + } + + private List FilterBlockedIndexers(List releases) + { + var blockedIndexers = new HashSet(_indexerStatusService.GetBlockedProviders().Select(v => v.ProviderId)); + + return releases.Where(release => !blockedIndexers.Contains(release.IndexerId)).ToList(); + } + private List GetPendingReleases() { return IncludeRemoteAlbums(_repository.All().ToList()); @@ -354,13 +354,6 @@ namespace NzbDrone.Core.Download.Pending _eventAggregator.PublishEvent(new PendingReleasesUpdatedEvent()); } - private static Func MatchingReleasePredicate(ReleaseInfo release) - { - return p => p.Title == release.Title && - p.Release.PublishDate == release.PublishDate && - p.Release.Indexer == release.Indexer; - } - private int GetDelay(RemoteAlbum remoteAlbum) { var delayProfile = _delayProfileService.AllForTags(remoteAlbum.Artist.Tags).OrderBy(d => d.Order).First(); @@ -455,5 +448,12 @@ namespace NzbDrone.Core.Download.Pending { RemoveRejected(message.ProcessedDecisions.Rejected); } + + private static Func MatchingReleasePredicate(ReleaseInfo release) + { + return p => p.Title == release.Title && + p.Release.PublishDate == release.PublishDate && + p.Release.Indexer == release.Indexer; + } } } From 6ba4ed7d783fb9452d104b8887ade7276976221c Mon Sep 17 00:00:00 2001 From: Qstick Date: Fri, 20 Oct 2023 20:59:54 -0500 Subject: [PATCH 068/820] Remove unused using --- src/Lidarr.Api.V1/Queue/QueueController.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Lidarr.Api.V1/Queue/QueueController.cs b/src/Lidarr.Api.V1/Queue/QueueController.cs index ddc6cafe8..be061e8ad 100644 --- a/src/Lidarr.Api.V1/Queue/QueueController.cs +++ b/src/Lidarr.Api.V1/Queue/QueueController.cs @@ -18,7 +18,6 @@ using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Queue; using NzbDrone.SignalR; -using Sentry.Protocol; namespace Lidarr.Api.V1.Queue { From 94329a1d1b6115c2adf5154b3e199082f57adb2e Mon Sep 17 00:00:00 2001 From: Colin Gagnaire Date: Sat, 17 Dec 2022 03:37:21 +0100 Subject: [PATCH 069/820] New: Add support for native Freebox Download Client Closes #3222 (cherry picked from commit fb76c237bfbb8aa43bcdd9ce34d90ea843011cee) --- .../TorrentFreeboxDownloadFixture.cs | 372 ++++++++++++++++++ .../FreeboxDownloadEncoding.cs | 27 ++ .../FreeboxDownloadException.cs | 10 + .../FreeboxDownloadPriority.cs | 8 + .../FreeboxDownload/FreeboxDownloadProxy.cs | 277 +++++++++++++ .../FreeboxDownloadSettings.cs | 87 ++++ .../Responses/FreeboxDownloadConfiguration.cs | 21 + .../Responses/FreeboxDownloadTask.cs | 137 +++++++ .../FreeboxDownload/Responses/FreeboxLogin.cs | 18 + .../Responses/FreeboxResponse.cs | 69 ++++ .../FreeboxDownload/TorrentFreeboxDownload.cs | 227 +++++++++++ 11 files changed, 1253 insertions(+) create mode 100644 src/NzbDrone.Core.Test/Download/DownloadClientTests/FreeboxDownloadTests/TorrentFreeboxDownloadFixture.cs create mode 100644 src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadEncoding.cs create mode 100644 src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadException.cs create mode 100644 src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadPriority.cs create mode 100644 src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadConfiguration.cs create mode 100644 src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadTask.cs create mode 100644 src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxLogin.cs create mode 100644 src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/FreeboxDownloadTests/TorrentFreeboxDownloadFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/FreeboxDownloadTests/TorrentFreeboxDownloadFixture.cs new file mode 100644 index 000000000..0fa8ec842 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/FreeboxDownloadTests/TorrentFreeboxDownloadFixture.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.Download.Clients.FreeboxDownload; +using NzbDrone.Core.Download.Clients.FreeboxDownload.Responses; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.FreeboxDownloadTests +{ + [TestFixture] + public class TorrentFreeboxDownloadFixture : DownloadClientFixtureBase + { + protected FreeboxDownloadSettings _settings; + + protected FreeboxDownloadConfiguration _downloadConfiguration; + + protected FreeboxDownloadTask _task; + + protected string _defaultDestination = @"/some/path"; + protected string _encodedDefaultDestination = "L3NvbWUvcGF0aA=="; + protected string _category = "somecat"; + protected string _encodedDefaultDestinationAndCategory = "L3NvbWUvcGF0aC9zb21lY2F0"; + protected string _destinationDirectory = @"/path/to/media"; + protected string _encodedDestinationDirectory = "L3BhdGgvdG8vbWVkaWE="; + protected OsPath _physicalPath = new OsPath("/mnt/sdb1/mydata"); + protected string _downloadURL => "magnet:?xt=urn:btih:5dee65101db281ac9c46344cd6b175cdcad53426&dn=download"; + + [SetUp] + public void Setup() + { + Subject.Definition = new DownloadClientDefinition(); + + _settings = new FreeboxDownloadSettings() + { + Host = "127.0.0.1", + Port = 443, + ApiUrl = "/api/v1/", + AppId = "someid", + AppToken = "S0mEv3RY1oN9T0k3n" + }; + + Subject.Definition.Settings = _settings; + + _downloadConfiguration = new FreeboxDownloadConfiguration() + { + DownloadDirectory = _encodedDefaultDestination + }; + + _task = new FreeboxDownloadTask() + { + Id = "id0", + Name = "name", + DownloadDirectory = "L3NvbWUvcGF0aA==", + InfoHash = "HASH", + QueuePosition = 1, + Status = FreeboxDownloadTaskStatus.Unknown, + Eta = 0, + Error = "none", + Type = FreeboxDownloadTaskType.Bt.ToString(), + IoPriority = FreeboxDownloadTaskIoPriority.Normal.ToString(), + StopRatio = 150, + PieceLength = 125, + CreatedTimestamp = 1665261599, + Size = 1000, + ReceivedPrct = 0, + ReceivedBytes = 0, + ReceivedRate = 0, + TransmittedPrct = 0, + TransmittedBytes = 0, + TransmittedRate = 0, + }; + + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), Array.Empty())); + } + + protected void GivenCategory() + { + _settings.Category = _category; + } + + protected void GivenDestinationDirectory() + { + _settings.DestinationDirectory = _destinationDirectory; + } + + protected virtual void GivenDownloadConfiguration() + { + Mocker.GetMock() + .Setup(s => s.GetDownloadConfiguration(It.IsAny())) + .Returns(_downloadConfiguration); + } + + protected virtual void GivenTasks(List torrents) + { + if (torrents == null) + { + torrents = new List(); + } + + Mocker.GetMock() + .Setup(s => s.GetTasks(It.IsAny())) + .Returns(torrents); + } + + protected void PrepareClientToReturnQueuedItem() + { + _task.Status = FreeboxDownloadTaskStatus.Queued; + + GivenTasks(new List + { + _task + }); + } + + protected void GivenSuccessfulDownload() + { + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[1000])); + + Mocker.GetMock() + .Setup(s => s.AddTaskFromUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(PrepareClientToReturnQueuedItem); + + Mocker.GetMock() + .Setup(s => s.AddTaskFromFile(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(PrepareClientToReturnQueuedItem); + } + + protected override RemoteAlbum CreateRemoteAlbum() + { + var album = base.CreateRemoteAlbum(); + + album.Release.DownloadUrl = _downloadURL; + + return album; + } + + [Test] + public async Task Download_with_DestinationDirectory_should_force_directory() + { + GivenDestinationDirectory(); + GivenSuccessfulDownload(); + + var remoteAlbum = CreateRemoteAlbum(); + + await Subject.Download(remoteAlbum, CreateIndexer()); + + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), _encodedDestinationDirectory, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [Test] + public async Task Download_with_Category_should_force_directory() + { + GivenDownloadConfiguration(); + GivenCategory(); + GivenSuccessfulDownload(); + + var remoteAlbum = CreateRemoteAlbum(); + + await Subject.Download(remoteAlbum, CreateIndexer()); + + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), _encodedDefaultDestinationAndCategory, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [Test] + public async Task Download_without_DestinationDirectory_and_Category_should_use_default() + { + GivenDownloadConfiguration(); + GivenSuccessfulDownload(); + + var remoteAlbum = CreateRemoteAlbum(); + + await Subject.Download(remoteAlbum, CreateIndexer()); + + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), _encodedDefaultDestination, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [TestCase(false, false)] + [TestCase(true, true)] + public async Task Download_should_pause_torrent_as_expected(bool addPausedSetting, bool toBePausedFlag) + { + _settings.AddPaused = addPausedSetting; + + GivenDownloadConfiguration(); + GivenSuccessfulDownload(); + + var remoteAlbum = CreateRemoteAlbum(); + + await Subject.Download(remoteAlbum, CreateIndexer()); + + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), It.IsAny(), toBePausedFlag, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [TestCase(0, (int)FreeboxDownloadPriority.First, (int)FreeboxDownloadPriority.First, true)] + [TestCase(0, (int)FreeboxDownloadPriority.Last, (int)FreeboxDownloadPriority.First, true)] + [TestCase(0, (int)FreeboxDownloadPriority.First, (int)FreeboxDownloadPriority.Last, false)] + [TestCase(0, (int)FreeboxDownloadPriority.Last, (int)FreeboxDownloadPriority.Last, false)] + [TestCase(15, (int)FreeboxDownloadPriority.First, (int)FreeboxDownloadPriority.First, true)] + [TestCase(15, (int)FreeboxDownloadPriority.Last, (int)FreeboxDownloadPriority.First, false)] + [TestCase(15, (int)FreeboxDownloadPriority.First, (int)FreeboxDownloadPriority.Last, true)] + [TestCase(15, (int)FreeboxDownloadPriority.Last, (int)FreeboxDownloadPriority.Last, false)] + public async Task Download_should_queue_torrent_first_as_expected(int ageDay, int olderPriority, int recentPriority, bool toBeQueuedFirstFlag) + { + _settings.OlderPriority = olderPriority; + _settings.RecentPriority = recentPriority; + + GivenDownloadConfiguration(); + GivenSuccessfulDownload(); + + var remoteAlbum = CreateRemoteAlbum(); + + var album = new Music.Album() + { + ReleaseDate = DateTime.UtcNow.Date.AddDays(-ageDay) + }; + + remoteAlbum.Albums.Add(album); + + await Subject.Download(remoteAlbum, CreateIndexer()); + + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), It.IsAny(), It.IsAny(), toBeQueuedFirstFlag, It.IsAny(), It.IsAny()), Times.Once()); + } + + [TestCase(0, 0)] + [TestCase(1.5, 150)] + public async Task Download_should_define_seed_ratio_as_expected(double? providerSeedRatio, double? expectedSeedRatio) + { + GivenDownloadConfiguration(); + GivenSuccessfulDownload(); + + var remoteAlbum = CreateRemoteAlbum(); + + remoteAlbum.SeedConfiguration = new TorrentSeedConfiguration(); + remoteAlbum.SeedConfiguration.Ratio = providerSeedRatio; + + await Subject.Download(remoteAlbum, CreateIndexer()); + + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), expectedSeedRatio, It.IsAny()), Times.Once()); + } + + [Test] + public void GetItems_should_return_empty_list_if_no_tasks_available() + { + GivenTasks(new List()); + + Subject.GetItems().Should().BeEmpty(); + } + + [Test] + public void GetItems_should_return_ignore_tasks_of_unknown_type() + { + _task.Status = FreeboxDownloadTaskStatus.Done; + _task.Type = "toto"; + + GivenTasks(new List { _task }); + + Subject.GetItems().Should().BeEmpty(); + } + + [Test] + public void GetItems_when_destinationdirectory_is_set_should_ignore_downloads_in_wrong_folder() + { + _settings.DestinationDirectory = @"/some/path/that/will/not/match"; + + _task.Status = FreeboxDownloadTaskStatus.Done; + + GivenTasks(new List { _task }); + + Subject.GetItems().Should().BeEmpty(); + } + + [Test] + public void GetItems_when_category_is_set_should_ignore_downloads_in_wrong_folder() + { + _settings.Category = "somecategory"; + + _task.Status = FreeboxDownloadTaskStatus.Done; + + GivenTasks(new List { _task }); + + Subject.GetItems().Should().BeEmpty(); + } + + [TestCase(FreeboxDownloadTaskStatus.Downloading, false, false)] + [TestCase(FreeboxDownloadTaskStatus.Done, true, true)] + [TestCase(FreeboxDownloadTaskStatus.Seeding, false, false)] + [TestCase(FreeboxDownloadTaskStatus.Stopped, false, false)] + public void GetItems_should_return_canBeMoved_and_canBeDeleted_as_expected(FreeboxDownloadTaskStatus apiStatus, bool canMoveFilesExpected, bool canBeRemovedExpected) + { + _task.Status = apiStatus; + + GivenTasks(new List() { _task }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().CanBeRemoved.Should().Be(canBeRemovedExpected); + items.First().CanMoveFiles.Should().Be(canMoveFilesExpected); + } + + [TestCase(FreeboxDownloadTaskStatus.Stopped, DownloadItemStatus.Paused)] + [TestCase(FreeboxDownloadTaskStatus.Stopping, DownloadItemStatus.Paused)] + [TestCase(FreeboxDownloadTaskStatus.Queued, DownloadItemStatus.Queued)] + [TestCase(FreeboxDownloadTaskStatus.Starting, DownloadItemStatus.Downloading)] + [TestCase(FreeboxDownloadTaskStatus.Downloading, DownloadItemStatus.Downloading)] + [TestCase(FreeboxDownloadTaskStatus.Retry, DownloadItemStatus.Downloading)] + [TestCase(FreeboxDownloadTaskStatus.Checking, DownloadItemStatus.Downloading)] + [TestCase(FreeboxDownloadTaskStatus.Error, DownloadItemStatus.Warning)] + [TestCase(FreeboxDownloadTaskStatus.Seeding, DownloadItemStatus.Completed)] + [TestCase(FreeboxDownloadTaskStatus.Done, DownloadItemStatus.Completed)] + [TestCase(FreeboxDownloadTaskStatus.Unknown, DownloadItemStatus.Downloading)] + public void GetItems_should_return_item_as_downloadItemStatus(FreeboxDownloadTaskStatus apiStatus, DownloadItemStatus expectedItemStatus) + { + _task.Status = apiStatus; + + GivenTasks(new List() { _task }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().Status.Should().Be(expectedItemStatus); + } + + [Test] + public void GetItems_should_return_decoded_destination_directory() + { + var decodedDownloadDirectory = "/that/the/path"; + + _task.Status = FreeboxDownloadTaskStatus.Done; + _task.DownloadDirectory = "L3RoYXQvdGhlL3BhdGg="; + + GivenTasks(new List { _task }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().OutputPath.Should().Be(decodedDownloadDirectory); + } + + [Test] + public void GetItems_should_return_message_if_tasks_in_error() + { + _task.Status = FreeboxDownloadTaskStatus.Error; + _task.Error = "internal"; + + GivenTasks(new List { _task }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().Message.Should().Be("Internal error."); + items.First().Status.Should().Be(DownloadItemStatus.Warning); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadEncoding.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadEncoding.cs new file mode 100644 index 000000000..7c1ef310b --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadEncoding.cs @@ -0,0 +1,27 @@ +namespace NzbDrone.Core.Download.Clients.FreeboxDownload +{ + public static class EncodingForBase64 + { + public static string EncodeBase64(this string text) + { + if (text == null) + { + return null; + } + + var textAsBytes = System.Text.Encoding.UTF8.GetBytes(text); + return System.Convert.ToBase64String(textAsBytes); + } + + public static string DecodeBase64(this string encodedText) + { + if (encodedText == null) + { + return null; + } + + var textAsBytes = System.Convert.FromBase64String(encodedText); + return System.Text.Encoding.UTF8.GetString(textAsBytes); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadException.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadException.cs new file mode 100644 index 000000000..38fcf8047 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadException.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.Download.Clients.FreeboxDownload +{ + public class FreeboxDownloadException : DownloadClientException + { + public FreeboxDownloadException(string message) + : base(message) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadPriority.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadPriority.cs new file mode 100644 index 000000000..ee16d70d6 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadPriority.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.FreeboxDownload +{ + public enum FreeboxDownloadPriority + { + Last = 0, + First = 1 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadProxy.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadProxy.cs new file mode 100644 index 000000000..ae952e2b9 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadProxy.cs @@ -0,0 +1,277 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Download.Clients.FreeboxDownload.Responses; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload +{ + public interface IFreeboxDownloadProxy + { + void Authenticate(FreeboxDownloadSettings settings); + string AddTaskFromUrl(string url, string directory, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings); + string AddTaskFromFile(string fileName, byte[] fileContent, string directory, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings); + void DeleteTask(string id, bool deleteData, FreeboxDownloadSettings settings); + FreeboxDownloadConfiguration GetDownloadConfiguration(FreeboxDownloadSettings settings); + List GetTasks(FreeboxDownloadSettings settings); + } + + public class FreeboxDownloadProxy : IFreeboxDownloadProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + private ICached _authSessionTokenCache; + + public FreeboxDownloadProxy(ICacheManager cacheManager, IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + _authSessionTokenCache = cacheManager.GetCache(GetType(), "authSessionToken"); + } + + public void Authenticate(FreeboxDownloadSettings settings) + { + var request = BuildRequest(settings).Resource("/login").Build(); + + var response = ProcessRequest(request, settings); + + if (response.Result.LoggedIn == false) + { + throw new DownloadClientAuthenticationException("Not logged"); + } + } + + public string AddTaskFromUrl(string url, string directory, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings) + { + var request = BuildRequest(settings).Resource("/downloads/add").Post(); + request.Headers.ContentType = "application/x-www-form-urlencoded"; + + request.AddFormParameter("download_url", System.Web.HttpUtility.UrlPathEncode(url)); + + if (!directory.IsNullOrWhiteSpace()) + { + request.AddFormParameter("download_dir", directory); + } + + var response = ProcessRequest(request.Build(), settings); + + SetTorrentSettings(response.Result.Id, addPaused, addFirst, seedRatio, settings); + + return response.Result.Id; + } + + public string AddTaskFromFile(string fileName, byte[] fileContent, string directory, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings) + { + var request = BuildRequest(settings).Resource("/downloads/add").Post(); + + request.AddFormUpload("download_file", fileName, fileContent, "multipart/form-data"); + + if (directory.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("download_dir", directory); + } + + var response = ProcessRequest(request.Build(), settings); + + SetTorrentSettings(response.Result.Id, addPaused, addFirst, seedRatio, settings); + + return response.Result.Id; + } + + public void DeleteTask(string id, bool deleteData, FreeboxDownloadSettings settings) + { + var uri = "/downloads/" + id; + + if (deleteData == true) + { + uri += "/erase"; + } + + var request = BuildRequest(settings).Resource(uri).Build(); + + request.Method = HttpMethod.Delete; + + ProcessRequest(request, settings); + } + + public FreeboxDownloadConfiguration GetDownloadConfiguration(FreeboxDownloadSettings settings) + { + var request = BuildRequest(settings).Resource("/downloads/config/").Build(); + + return ProcessRequest(request, settings).Result; + } + + public List GetTasks(FreeboxDownloadSettings settings) + { + var request = BuildRequest(settings).Resource("/downloads/").Build(); + + return ProcessRequest>(request, settings).Result; + } + + private static string BuildCachedHeaderKey(FreeboxDownloadSettings settings) + { + return $"{settings.Host}:{settings.AppId}:{settings.AppToken}"; + } + + private void SetTorrentSettings(string id, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings) + { + var request = BuildRequest(settings).Resource("/downloads/" + id).Build(); + + request.Method = HttpMethod.Put; + + var body = new Dictionary { }; + + if (addPaused) + { + body.Add("status", FreeboxDownloadTaskStatus.Stopped.ToString().ToLower()); + } + + if (addFirst) + { + body.Add("queue_pos", "1"); + } + + if (seedRatio != null) + { + // 0 means unlimited seeding + body.Add("stop_ratio", seedRatio); + } + + if (body.Count == 0) + { + return; + } + + request.SetContent(body.ToJson()); + + ProcessRequest(request, settings); + } + + private string GetSessionToken(HttpRequestBuilder requestBuilder, FreeboxDownloadSettings settings, bool force = false) + { + var sessionToken = _authSessionTokenCache.Find(BuildCachedHeaderKey(settings)); + + if (sessionToken == null || force) + { + _authSessionTokenCache.Remove(BuildCachedHeaderKey(settings)); + + _logger.Debug($"Client needs a new Session Token to reach the API with App ID '{settings.AppId}'"); + + // Obtaining a Session Token (from official documentation): + // To protect the app_token secret, it will never be used directly to authenticate the + // application, instead the API will provide a challenge the app will combine to its + // app_token to open a session and get a session_token. + // The validity of the session_token is limited in time and the app will have to renew + // this session_token once in a while. + + // Retrieving the 'challenge' value (it changes frequently and have a limited time validity) + // needed to build password + var challengeRequest = requestBuilder.Resource("/login").Build(); + challengeRequest.Method = HttpMethod.Get; + + var challenge = ProcessRequest(challengeRequest, settings).Result.Challenge; + + // The password is computed using the 'challenge' value and the 'app_token' ('App Token' setting) + var enc = System.Text.Encoding.ASCII; + var hmac = new HMACSHA1(enc.GetBytes(settings.AppToken)); + hmac.Initialize(); + var buffer = enc.GetBytes(challenge); + var password = System.BitConverter.ToString(hmac.ComputeHash(buffer)).Replace("-", "").ToLower(); + + // Both 'app_id' ('App ID' setting) and computed password are set to get a Session Token + var sessionRequest = requestBuilder.Resource("/login/session").Post().Build(); + var body = new Dictionary + { + { "app_id", settings.AppId }, + { "password", password } + }; + sessionRequest.SetContent(body.ToJson()); + + sessionToken = ProcessRequest(sessionRequest, settings).Result.SessionToken; + + _authSessionTokenCache.Set(BuildCachedHeaderKey(settings), sessionToken); + + _logger.Debug($"New Session Token stored in cache for App ID '{settings.AppId}', ready to reach API"); + } + + return sessionToken; + } + + private HttpRequestBuilder BuildRequest(FreeboxDownloadSettings settings, bool authentication = true) + { + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.ApiUrl) + { + LogResponseContent = true + }; + + requestBuilder.Headers.ContentType = "application/json"; + + if (authentication == true) + { + requestBuilder.SetHeader("X-Fbx-App-Auth", GetSessionToken(requestBuilder, settings)); + } + + return requestBuilder; + } + + private FreeboxResponse ProcessRequest(HttpRequest request, FreeboxDownloadSettings settings) + { + request.LogResponseContent = true; + request.SuppressHttpError = true; + + HttpResponse response; + + try + { + response = _httpClient.Execute(request); + } + catch (HttpRequestException ex) + { + throw new DownloadClientUnavailableException($"Unable to reach Freebox API. Verify 'Host', 'Port' or 'Use SSL' settings. (Error: {ex.Message})", ex); + } + catch (WebException ex) + { + throw new DownloadClientUnavailableException("Unable to connect to Freebox API, please check your settings", ex); + } + + if (response.StatusCode == HttpStatusCode.Forbidden || response.StatusCode == HttpStatusCode.Unauthorized) + { + _authSessionTokenCache.Remove(BuildCachedHeaderKey(settings)); + + var responseContent = Json.Deserialize>(response.Content); + + var msg = $"Authentication to Freebox API failed. Reason: {responseContent.GetErrorDescription()}"; + _logger.Error(msg); + throw new DownloadClientAuthenticationException(msg); + } + else if (response.StatusCode == HttpStatusCode.NotFound) + { + throw new FreeboxDownloadException("Unable to reach Freebox API. Verify 'API URL' setting for base URL and version."); + } + else if (response.StatusCode == HttpStatusCode.OK) + { + var responseContent = Json.Deserialize>(response.Content); + + if (responseContent.Success) + { + return responseContent; + } + else + { + var msg = $"Freebox API returned error: {responseContent.GetErrorDescription()}"; + _logger.Error(msg); + throw new DownloadClientException(msg); + } + } + else + { + throw new DownloadClientException("Unable to connect to Freebox, please check your settings."); + } + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs new file mode 100644 index 000000000..431183092 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs @@ -0,0 +1,87 @@ +using System.Text.RegularExpressions; +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; +using NzbDrone.Core.Validation.Paths; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload +{ + public class FreeboxDownloadSettingsValidator : AbstractValidator + { + public FreeboxDownloadSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.ApiUrl).NotEmpty() + .WithMessage("'API URL' must not be empty."); + RuleFor(c => c.ApiUrl).ValidUrlBase(); + RuleFor(c => c.AppId).NotEmpty() + .WithMessage("'App ID' must not be empty."); + RuleFor(c => c.AppToken).NotEmpty() + .WithMessage("'App Token' must not be empty."); + RuleFor(c => c.Category).Matches(@"^\.?[-a-z]*$", RegexOptions.IgnoreCase) + .WithMessage("Allowed characters a-z and -"); + RuleFor(c => c.DestinationDirectory).IsValidPath() + .When(c => c.DestinationDirectory.IsNotNullOrWhiteSpace()); + RuleFor(c => c.DestinationDirectory).Empty() + .When(c => c.Category.IsNotNullOrWhiteSpace()) + .WithMessage("Cannot use 'Category' and 'Destination Directory' at the same time."); + RuleFor(c => c.Category).Empty() + .When(c => c.DestinationDirectory.IsNotNullOrWhiteSpace()) + .WithMessage("Cannot use 'Category' and 'Destination Directory' at the same time."); + } + } + + public class FreeboxDownloadSettings : IProviderConfig + { + private static readonly FreeboxDownloadSettingsValidator Validator = new FreeboxDownloadSettingsValidator(); + + public FreeboxDownloadSettings() + { + Host = "mafreebox.freebox.fr"; + Port = 443; + UseSsl = true; + ApiUrl = "/api/v1/"; + } + + [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox, HelpText = "Hostname or host IP address of the Freebox, defaults to 'mafreebox.freebox.fr' (will only work if on same network)")] + public string Host { get; set; } + + [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox, HelpText = "Port used to access Freebox interface, defaults to '443'")] + public int Port { get; set; } + + [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secured connection when connecting to Freebox API")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "API URL", Type = FieldType.Textbox, Advanced = true, HelpText = "Define Freebox API base URL with API version, eg http://[host]:[port]/[api_base_url]/[api_version]/, defaults to '/api/v1/'")] + public string ApiUrl { get; set; } + + [FieldDefinition(4, Label = "App ID", Type = FieldType.Textbox, HelpText = "App ID given when creating access to Freebox API (ie 'app_id')")] + public string AppId { get; set; } + + [FieldDefinition(5, Label = "App Token", Type = FieldType.Password, Privacy = PrivacyLevel.Password, HelpText = "App token retrieved when creating access to Freebox API (ie 'app_token')")] + public string AppToken { get; set; } + + [FieldDefinition(6, Label = "Destination Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default Freebox download location")] + public string DestinationDirectory { get; set; } + + [FieldDefinition(7, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Lidarr avoids conflicts with unrelated non-Lidarr downloads (will create a [category] subdirectory in the output directory)")] + public string Category { get; set; } + + [FieldDefinition(8, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(FreeboxDownloadPriority), HelpText = "Priority to use when grabbing albums that released within the last 14 days")] + public int RecentPriority { get; set; } + + [FieldDefinition(9, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(FreeboxDownloadPriority), HelpText = "Priority to use when grabbing albums that released over 14 days ago")] + public int OlderPriority { get; set; } + + [FieldDefinition(10, Label = "Add Paused", Type = FieldType.Checkbox)] + public bool AddPaused { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadConfiguration.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadConfiguration.cs new file mode 100644 index 000000000..23850c651 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadConfiguration.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload.Responses +{ + public class FreeboxDownloadConfiguration + { + [JsonProperty(PropertyName = "download_dir")] + public string DownloadDirectory { get; set; } + public string DecodedDownloadDirectory + { + get + { + return DownloadDirectory.DecodeBase64(); + } + set + { + DownloadDirectory = value.EncodeBase64(); + } + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadTask.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadTask.cs new file mode 100644 index 000000000..faf4f646a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadTask.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload.Responses +{ + public enum FreeboxDownloadTaskType + { + Bt, + Nzb, + Http, + Ftp + } + + public enum FreeboxDownloadTaskStatus + { + Unknown, + Stopped, + Queued, + Starting, + Downloading, + Stopping, + Error, + Done, + Checking, + Repairing, + Extracting, + Seeding, + Retry + } + + public enum FreeboxDownloadTaskIoPriority + { + Low, + Normal, + High + } + + public class FreeboxDownloadTask + { + private static readonly Dictionary Descriptions; + + [JsonProperty(PropertyName = "id")] + public string Id { get; set; } + [JsonProperty(PropertyName = "name")] + public string Name { get; set; } + [JsonProperty(PropertyName = "download_dir")] + public string DownloadDirectory { get; set; } + public string DecodedDownloadDirectory + { + get + { + return DownloadDirectory.DecodeBase64(); + } + set + { + DownloadDirectory = value.EncodeBase64(); + } + } + + [JsonProperty(PropertyName = "info_hash")] + public string InfoHash { get; set; } + [JsonProperty(PropertyName = "queue_pos")] + public int QueuePosition { get; set; } + [JsonConverter(typeof(UnderscoreStringEnumConverter), FreeboxDownloadTaskStatus.Unknown)] + public FreeboxDownloadTaskStatus Status { get; set; } + [JsonProperty(PropertyName = "eta")] + public long Eta { get; set; } + [JsonProperty(PropertyName = "error")] + public string Error { get; set; } + [JsonProperty(PropertyName = "type")] + public string Type { get; set; } + [JsonProperty(PropertyName = "io_priority")] + public string IoPriority { get; set; } + [JsonProperty(PropertyName = "stop_ratio")] + public long StopRatio { get; set; } + [JsonProperty(PropertyName = "piece_length")] + public long PieceLength { get; set; } + [JsonProperty(PropertyName = "created_ts")] + public long CreatedTimestamp { get; set; } + [JsonProperty(PropertyName = "size")] + public long Size { get; set; } + [JsonProperty(PropertyName = "rx_pct")] + public long ReceivedPrct { get; set; } + [JsonProperty(PropertyName = "rx_bytes")] + public long ReceivedBytes { get; set; } + [JsonProperty(PropertyName = "rx_rate")] + public long ReceivedRate { get; set; } + [JsonProperty(PropertyName = "tx_pct")] + public long TransmittedPrct { get; set; } + [JsonProperty(PropertyName = "tx_bytes")] + public long TransmittedBytes { get; set; } + [JsonProperty(PropertyName = "tx_rate")] + public long TransmittedRate { get; set; } + + static FreeboxDownloadTask() + { + Descriptions = new Dictionary + { + { "internal", "Internal error." }, + { "disk_full", "The disk is full." }, + { "unknown", "Unknown error." }, + { "parse_error", "Parse error." }, + { "unknown_host", "Unknown host." }, + { "timeout", "Timeout." }, + { "bad_authentication", "Invalid credentials." }, + { "connection_refused", "Remote host refused connection." }, + { "bt_tracker_error", "Unable to announce on tracker." }, + { "bt_missing_files", "Missing torrent files." }, + { "bt_file_error", "Error accessing torrent files." }, + { "missing_ctx_file", "Error accessing task context file." }, + { "nzb_no_group", "Cannot find the requested group on server." }, + { "nzb_not_found", "Article not fount on the server." }, + { "nzb_invalid_crc", "Invalid article CRC." }, + { "nzb_invalid_size", "Invalid article size." }, + { "nzb_invalid_filename", "Invalid filename." }, + { "nzb_open_failed", "Error opening." }, + { "nzb_write_failed", "Error writing." }, + { "nzb_missing_size", "Missing article size." }, + { "nzb_decode_error", "Article decoding error." }, + { "nzb_missing_segments", "Missing article segments." }, + { "nzb_error", "Other nzb error." }, + { "nzb_authentication_required", "Nzb server need authentication." } + }; + } + + public string GetErrorDescription() + { + if (Descriptions.ContainsKey(Error)) + { + return Descriptions[Error]; + } + + return $"{Error} - Unknown error"; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxLogin.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxLogin.cs new file mode 100644 index 000000000..bfb01f050 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxLogin.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload.Responses +{ + public class FreeboxLogin + { + [JsonProperty(PropertyName = "logged_in")] + public bool LoggedIn { get; set; } + [JsonProperty(PropertyName = "challenge")] + public string Challenge { get; set; } + [JsonProperty(PropertyName = "password_salt")] + public string PasswordSalt { get; set; } + [JsonProperty(PropertyName = "password_set")] + public bool PasswordSet { get; set; } + [JsonProperty(PropertyName = "session_token")] + public string SessionToken { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxResponse.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxResponse.cs new file mode 100644 index 000000000..5aff5b68a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxResponse.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload.Responses +{ + public class FreeboxResponse + { + private static readonly Dictionary Descriptions; + + [JsonProperty(PropertyName = "success")] + public bool Success { get; set; } + [JsonProperty(PropertyName = "msg")] + public string Message { get; set; } + [JsonProperty(PropertyName = "error_code")] + public string ErrorCode { get; set; } + [JsonProperty(PropertyName = "result")] + public T Result { get; set; } + + static FreeboxResponse() + { + Descriptions = new Dictionary + { + // Common errors + { "invalid_request", "Your request is invalid." }, + { "invalid_api_version", "Invalid API base url or unknown API version." }, + { "internal_error", "Internal error." }, + + // Login API errors + { "auth_required", "Invalid session token, or no session token sent." }, + { "invalid_token", "The app token you are trying to use is invalid or has been revoked." }, + { "pending_token", "The app token you are trying to use has not been validated by user yet." }, + { "insufficient_rights", "Your app permissions does not allow accessing this API." }, + { "denied_from_external_ip", "You are trying to get an app_token from a remote IP." }, + { "ratelimited", "Too many auth error have been made from your IP." }, + { "new_apps_denied", "New application token request has been disabled." }, + { "apps_denied", "API access from apps has been disabled." }, + + // Download API errors + { "task_not_found", "No task was found with the given id." }, + { "invalid_operation", "Attempt to perform an invalid operation." }, + { "invalid_file", "Error with the download file (invalid format ?)." }, + { "invalid_url", "URL is invalid." }, + { "not_implemented", "Method not implemented." }, + { "out_of_memory", "No more memory available to perform the requested action." }, + { "invalid_task_type", "The task type is invalid." }, + { "hibernating", "The downloader is hibernating." }, + { "need_bt_stopped_done", "This action is only valid for Bittorrent task in stopped or done state." }, + { "bt_tracker_not_found", "Attempt to access an invalid tracker object." }, + { "too_many_tasks", "Too many tasks." }, + { "invalid_address", "Invalid peer address." }, + { "port_conflict", "Port conflict when setting config." }, + { "invalid_priority", "Invalid priority." }, + { "ctx_file_error", "Failed to initialize task context file (need to check disk)." }, + { "exists", "Same task already exists." }, + { "port_outside_range", "Incoming port is not available for this customer." } + }; + } + + public string GetErrorDescription() + { + if (Descriptions.ContainsKey(ErrorCode)) + { + return Descriptions[ErrorCode]; + } + + return $"{ErrorCode} - Unknown error"; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs new file mode 100644 index 000000000..5595aafb7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients.FreeboxDownload.Responses; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.RemotePathMappings; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload +{ + public class TorrentFreeboxDownload : TorrentClientBase + { + private readonly IFreeboxDownloadProxy _proxy; + + public TorrentFreeboxDownload(IFreeboxDownloadProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IRemotePathMappingService remotePathMappingService, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger) + { + _proxy = proxy; + } + + public override string Name => "Freebox Download"; + + protected IEnumerable GetTorrents() + { + return _proxy.GetTasks(Settings).Where(v => v.Type.ToLower() == FreeboxDownloadTaskType.Bt.ToString().ToLower()); + } + + public override IEnumerable GetItems() + { + var torrents = GetTorrents(); + + var queueItems = new List(); + + foreach (var torrent in torrents) + { + var outputPath = new OsPath(torrent.DecodedDownloadDirectory); + + if (Settings.DestinationDirectory.IsNotNullOrWhiteSpace()) + { + if (!new OsPath(Settings.DestinationDirectory).Contains(outputPath)) + { + continue; + } + } + + if (Settings.Category.IsNotNullOrWhiteSpace()) + { + var directories = outputPath.FullPath.Split('\\', '/'); + + if (!directories.Contains(Settings.Category)) + { + continue; + } + } + + var item = new DownloadClientItem() + { + DownloadId = torrent.Id, + Category = Settings.Category, + Title = torrent.Name, + TotalSize = torrent.Size, + DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this), + RemainingSize = (long)(torrent.Size * (double)(1 - ((double)torrent.ReceivedPrct / 10000))), + RemainingTime = torrent.Eta <= 0 ? null : TimeSpan.FromSeconds(torrent.Eta), + SeedRatio = torrent.StopRatio <= 0 ? 0 : torrent.StopRatio / 100, + OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath) + }; + + switch (torrent.Status) + { + case FreeboxDownloadTaskStatus.Stopped: // task is stopped, can be resumed by setting the status to downloading + case FreeboxDownloadTaskStatus.Stopping: // task is gracefully stopping + item.Status = DownloadItemStatus.Paused; + break; + + case FreeboxDownloadTaskStatus.Queued: // task will start when a new download slot is available the queue position is stored in queue_pos attribute + item.Status = DownloadItemStatus.Queued; + break; + + case FreeboxDownloadTaskStatus.Starting: // task is preparing to start download + case FreeboxDownloadTaskStatus.Downloading: + case FreeboxDownloadTaskStatus.Retry: // you can set a task status to ‘retry’ to restart the download task. + case FreeboxDownloadTaskStatus.Checking: // checking data before lauching download. + item.Status = DownloadItemStatus.Downloading; + break; + + case FreeboxDownloadTaskStatus.Error: // there was a problem with the download, you can get an error code in the error field + item.Status = DownloadItemStatus.Warning; + item.Message = torrent.GetErrorDescription(); + break; + + case FreeboxDownloadTaskStatus.Done: // the download is over. For bt you can resume seeding setting the status to seeding if the ratio is not reached yet + case FreeboxDownloadTaskStatus.Seeding: // download is over, the content is Change to being shared to other users. The task will automatically stop once the seed ratio has been reached + item.Status = DownloadItemStatus.Completed; + break; + + case FreeboxDownloadTaskStatus.Unknown: + default: // new status in API? default to downloading + item.Message = "Unknown download state: " + torrent.Status; + _logger.Info(item.Message); + item.Status = DownloadItemStatus.Downloading; + break; + } + + item.CanBeRemoved = item.CanMoveFiles = torrent.Status == FreeboxDownloadTaskStatus.Done; + + queueItems.Add(item); + } + + return queueItems; + } + + protected override string AddFromMagnetLink(RemoteAlbum remoteAlbum, string hash, string magnetLink) + { + return _proxy.AddTaskFromUrl(magnetLink, + GetDownloadDirectory().EncodeBase64(), + ToBePaused(), + ToBeQueuedFirst(remoteAlbum), + GetSeedRatio(remoteAlbum), + Settings); + } + + protected override string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, byte[] fileContent) + { + return _proxy.AddTaskFromFile(filename, + fileContent, + GetDownloadDirectory().EncodeBase64(), + ToBePaused(), + ToBeQueuedFirst(remoteAlbum), + GetSeedRatio(remoteAlbum), + Settings); + } + + public override void RemoveItem(DownloadClientItem item, bool deleteData) + { + _proxy.DeleteTask(item.DownloadId, deleteData, Settings); + } + + public override DownloadClientInfo GetStatus() + { + var destDir = GetDownloadDirectory(); + + return new DownloadClientInfo + { + IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "::1" || Settings.Host == "localhost", + OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(destDir)) } + }; + } + + protected override void Test(List failures) + { + try + { + _proxy.Authenticate(Settings); + } + catch (DownloadClientUnavailableException ex) + { + failures.Add(new ValidationFailure("Host", ex.Message)); + failures.Add(new ValidationFailure("Port", ex.Message)); + } + catch (DownloadClientAuthenticationException ex) + { + failures.Add(new ValidationFailure("AppId", ex.Message)); + failures.Add(new ValidationFailure("AppToken", ex.Message)); + } + catch (FreeboxDownloadException ex) + { + failures.Add(new ValidationFailure("ApiUrl", ex.Message)); + } + } + + private string GetDownloadDirectory() + { + if (Settings.DestinationDirectory.IsNotNullOrWhiteSpace()) + { + return Settings.DestinationDirectory.TrimEnd('/'); + } + + var destDir = _proxy.GetDownloadConfiguration(Settings).DecodedDownloadDirectory.TrimEnd('/'); + + if (Settings.Category.IsNotNullOrWhiteSpace()) + { + destDir = $"{destDir}/{Settings.Category}"; + } + + return destDir; + } + + private bool ToBePaused() + { + return Settings.AddPaused; + } + + private bool ToBeQueuedFirst(RemoteAlbum remoteAlbum) + { + if ((remoteAlbum.IsRecentAlbum() && Settings.RecentPriority == (int)FreeboxDownloadPriority.First) || + (!remoteAlbum.IsRecentAlbum() && Settings.OlderPriority == (int)FreeboxDownloadPriority.First)) + { + return true; + } + + return false; + } + + private double? GetSeedRatio(RemoteAlbum remoteAlbum) + { + if (remoteAlbum.SeedConfiguration == null || remoteAlbum.SeedConfiguration.Ratio == null) + { + return null; + } + + return remoteAlbum.SeedConfiguration.Ratio.Value * 100; + } + } +} From fe9fa4c1ea22fe3387a5f45cbc8ef5060a6b01cf Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sun, 22 Oct 2023 09:33:17 +0300 Subject: [PATCH 070/820] Revert "Bump SQLite to 3.42.0 (1.0.118)" This reverts commit c4a339f0af74777a68b37b546284a9df942668cf. --- src/NzbDrone.Common/Lidarr.Common.csproj | 2 +- src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj | 1 - src/NzbDrone.Core/Lidarr.Core.csproj | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/NzbDrone.Common/Lidarr.Common.csproj b/src/NzbDrone.Common/Lidarr.Common.csproj index 18dda6d36..fae33d38e 100644 --- a/src/NzbDrone.Common/Lidarr.Common.csproj +++ b/src/NzbDrone.Common/Lidarr.Common.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj b/src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj index 592951364..6e7bffb4d 100644 --- a/src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj @@ -6,7 +6,6 @@ -
diff --git a/src/NzbDrone.Core/Lidarr.Core.csproj b/src/NzbDrone.Core/Lidarr.Core.csproj index 919852e69..7f0bfcd8f 100644 --- a/src/NzbDrone.Core/Lidarr.Core.csproj +++ b/src/NzbDrone.Core/Lidarr.Core.csproj @@ -13,7 +13,6 @@ - From a50027f2e7c8e3605f16bc473c9a557d0fb15d16 Mon Sep 17 00:00:00 2001 From: Servarr Date: Sun, 22 Oct 2023 06:39:22 +0000 Subject: [PATCH 071/820] Automated API Docs update --- src/Lidarr.Api.V1/openapi.json | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Lidarr.Api.V1/openapi.json b/src/Lidarr.Api.V1/openapi.json index 9d470cd9c..0fae4de79 100644 --- a/src/Lidarr.Api.V1/openapi.json +++ b/src/Lidarr.Api.V1/openapi.json @@ -8828,11 +8828,19 @@ }, "additionalProperties": false }, + "AuthenticationRequiredType": { + "enum": [ + "enabled", + "disabledForLocalAddresses" + ], + "type": "string" + }, "AuthenticationType": { "enum": [ "none", "basic", - "forms" + "forms", + "external" ], "type": "string" }, @@ -9583,6 +9591,9 @@ "type": "string", "nullable": true }, + "privacy": { + "$ref": "#/components/schemas/PrivacyLevel" + }, "placeholder": { "type": "string", "nullable": true @@ -9771,6 +9782,9 @@ "authenticationMethod": { "$ref": "#/components/schemas/AuthenticationType" }, + "authenticationRequired": { + "$ref": "#/components/schemas/AuthenticationRequiredType" + }, "analyticsEnabled": { "type": "boolean" }, @@ -11411,6 +11425,15 @@ }, "additionalProperties": false }, + "PrivacyLevel": { + "enum": [ + "normal", + "password", + "apiKey", + "userName" + ], + "type": "string" + }, "ProfileFormatItem": { "type": "object", "properties": { From 01e21c09db267a2507fb92d0dd5f12577217204a Mon Sep 17 00:00:00 2001 From: Weblate Date: Sun, 22 Oct 2023 06:33:33 +0000 Subject: [PATCH 072/820] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: BlinkersFr31 Co-authored-by: Fixer Co-authored-by: Lizandra Candido da Silva Co-authored-by: Weblate Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/fr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pt_BR/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/ro/ Translation: Servarr/Lidarr --- src/NzbDrone.Core/Localization/Core/fr.json | 2 +- .../Localization/Core/pt_BR.json | 82 +++++++++++-------- src/NzbDrone.Core/Localization/Core/ro.json | 5 +- 3 files changed, 51 insertions(+), 38 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index e93bee0cf..ae21a3b87 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -1040,7 +1040,7 @@ "RootFolderPathHelpText": "Les éléments de la liste du dossier racine seront ajoutés à", "SecondaryAlbumTypes": "Types d'albums secondaires", "ShowNextAlbumHelpText": "Afficher l'album suivant sous l'affiche", - "BypassIfHighestQualityHelpText": "Délai de contournement lorsque la version a la qualité activée la plus élevée dans le profil de qualité", + "BypassIfHighestQualityHelpText": "Ignore le délai lorsque la version a la qualité active la plus élevée dans le profil de qualité avec le protocole préféré", "ExpandOtherByDefaultHelpText": "Autre", "FutureAlbums": "Futurs albums", "MusicbrainzId": "Identifiant Musicbrainz", diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index 9db5df748..f4a0d41ea 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -20,7 +20,7 @@ "ArtistNameHelpText": "O nome do artista/álbum a excluir (pode ser qualquer coisa significativa)", "Artists": "Artistas", "Authentication": "Autenticação", - "AuthenticationMethodHelpText": "Exigir nome de usuário e senha para acessar o Lidarr", + "AuthenticationMethodHelpText": "Exigir nome de usuário e senha para acessar o {appName}", "AddListExclusion": "Adicionar exclusão à lista", "AlbumHasNotAired": "O álbum não foi lançado", "AlbumIsDownloading": "O álbum está baixando", @@ -97,8 +97,8 @@ "UnableToLoadDownloadClients": "Não foi possível carregar os clientes de download", "UnableToLoadGeneralSettings": "Não foi possível carregar as configurações gerais", "UnableToLoadHistory": "Não foi possível carregar o histórico.", - "UnableToLoadImportListExclusions": "Não foi possível carregar as exclusões de listas de importação", - "UnableToLoadIndexerOptions": "Não foi possível carregar as opções de indexador", + "UnableToLoadImportListExclusions": "Não foi possível carregar Importar exclusões de lista", + "UnableToLoadIndexerOptions": "Não foi possível carregar as opções do indexador", "UnableToLoadMediaManagementSettings": "Não foi possível carregar as configurações de gerenciamento de mídia", "UnableToLoadMetadata": "Não foi possível carregar os metadados", "UnableToLoadMetadataProfiles": "Não foi possível carregar os perfis de metadados", @@ -239,7 +239,7 @@ "EndedAllTracksDownloaded": "Terminado (todos os livros baixados)", "EntityName": "Nome da entidade", "Episode": "Episódio", - "EpisodeDoesNotHaveAnAbsoluteEpisodeNumber": "O episódio não tem um número absoluto", + "EpisodeDoesNotHaveAnAbsoluteEpisodeNumber": "O episódio não tem um número de episódio absoluto", "ErrorLoadingContents": "Erro ao carregar o conteúdo", "ErrorLoadingPreviews": "Erro ao carregar as visualizações", "Exception": "Exceção", @@ -249,9 +249,9 @@ "ExtraFileExtensionsHelpTexts1": "Lista separada por vírgulas de arquivos adicionais a importar (.nfo será importado como .nfo-orig)", "FileDateHelpText": "Alterar a data do arquivo ao importar/verificar novamente", "Filename": "Nome do arquivo", - "FileNames": "Nomes de Arquivo", + "FileNames": "Nomes de arquivo", "Files": "Arquivos", - "FirstDayOfWeek": "Primeiro Dia da Semana", + "FirstDayOfWeek": "Primeiro dia da semana", "Fixed": "Corrigido", "Folder": "Pasta", "Folders": "Pastas", @@ -268,38 +268,38 @@ "Grab": "Obter", "GrabRelease": "Capturar Versão", "GrabReleaseMessageText": "O Lidarr não conseguiu determinar a qual autor e livro esse lançamento está relacionado. O Lidarr pode não conseguir importar automaticamente este lançamento. Quer obter \"{0}\"?", - "GrabSelected": "Obter Selecionado", + "GrabSelected": "Obter selecionado", "Group": "Grupo", "HasPendingChangesNoChanges": "Sem alterações", "HasPendingChangesSaveChanges": "Salvar alterações", "History": "Histórico", "Host": "Host", "HostHelpText": "O mesmo host que você especificou para o Cliente de download remoto", - "Hostname": "Hostname", + "Hostname": "Nome do host", "ICalFeed": "Feed do iCal", "ICalHttpUrlHelpText": "Copie este URL em seu(s) cliente(s) ou clique para se inscrever se o seu navegador é compatível com webcal", "ICalLink": "Link do iCal", - "IconForCutoffUnmet": "Ícone para Corte Não Atendido", + "IconForCutoffUnmet": "Ícone para limite não atendido", "IfYouDontAddAnImportListExclusionAndTheArtistHasAMetadataProfileOtherThanNoneThenThisAlbumMayBeReaddedDuringTheNextArtistRefresh": "Se você não adicionar uma exclusão à lista de importação e o autor tiver um perfil de metadados diferente de \"Nenhum\", este livro poderá ser adicionado novamente durante a próxima atualização do autor.", - "IgnoredAddresses": "Endereços Ignorados", + "IgnoredAddresses": "Endereços ignorados", "IgnoredHelpText": "O lançamento será rejeitado se contiver um ou mais desses termos (não diferencia maiúsculas de minúsculas)", "IgnoredPlaceHolder": "Adicionar nova restrição", "IllRestartLater": "Reiniciarei mais tarde", - "ImportedTo": "Importado Para", - "ImportExtraFiles": "Importar Arquivos Extras", + "ImportedTo": "Importado para", + "ImportExtraFiles": "Importar arquivos adicionais", "ImportExtraFilesHelpText": "Importar arquivos adicionais correspondentes (legendas, nfo, etc.) após importar um arquivo de livro", "ImportFailedInterp": "Falha na importação: {0}", "ImportFailures": "Falhas na importação", "Importing": "Importando", - "ImportListExclusions": "Importar Lista de Exclusões", + "ImportListExclusions": "Importar exclusões de lista", "ImportLists": "Listas de importação", "IncludeHealthWarningsHelpText": "Incluir avisos de integridade", "IncludeUnknownArtistItemsHelpText": "Mostrar itens sem um autor na fila. Isso pode incluir autores e filmes removidos, ou qualquer outra coisa na categoria do Lidarr", - "IncludeUnmonitored": "Incluir não monitorado", + "IncludeUnmonitored": "Incluir não monitorados", "Indexer": "Indexador", "IndexerIdHelpText": "Especificar a qual indexador o perfil se aplica", "IndexerIdHelpTextWarning": "Usar um indexador específico com as palavras preferidas pode acarretar na obtenção de lançamentos duplicados", - "IndexerPriority": "Prioridade do Indexador", + "IndexerPriority": "Prioridade do indexador", "Indexers": "Indexadores", "IndexerSettings": "Configurações do Indexador", "InteractiveSearch": "Pesquisa Interativa", @@ -541,7 +541,7 @@ "UnableToLoadIndexers": "Não foi possível carregar os indexadores", "UnableToLoadLists": "Não foi possível carregar as listas", "UnmappedFiles": "Arquivos Não Mapeados", - "FileManagement": "Gerenciamento de Arquivo", + "FileManagement": "Gerenciamento de arquivo", "LidarrSupportsMultipleListsForImportingAlbumsAndArtistsIntoTheDatabase": "O Lidarr oferece suporte a várias listas para importar livros e autores para o banco de dados.", "LidarrTags": "Tags do Lidarr", "LoadingTrackFilesFailed": "Falha ao carregar arquivos do livro", @@ -610,7 +610,7 @@ "HasMonitoredAlbumsNoMonitoredAlbumsForThisArtist": "Não monitorar álbuns para este artista", "HideAlbums": "Ocultar álbuns", "HideTracks": "Ocultar faixas", - "ImportListSettings": "Configurações Gerais da Lista de Importação", + "ImportListSettings": "Configurações gerais de Importar listas", "IsExpandedHideAlbums": "Ocultar álbuns", "IsExpandedHideTracks": "Ocultar faixas", "IsExpandedShowAlbums": "Mostrar álbuns", @@ -713,14 +713,14 @@ "Error": "Erro", "ErrorRestoringBackup": "Erro ao restaurar o backup", "Events": "Eventos", - "EventType": "Tipo de Evento", + "EventType": "Tipo de evento", "Filters": "Filtros", "FreeSpace": "Espaço Livre", "General": "Geral", "Genres": "Gêneros", "Grabbed": "Obtido", - "HardlinkCopyFiles": "Hardlink/Copiar Arquivos", - "HideAdvanced": "Ocultar Avançado", + "HardlinkCopyFiles": "Criar hardlink/Copiar arquivos", + "HideAdvanced": "Ocultar opções avançadas", "Ignored": "Ignorado", "IndexerDownloadClientHelpText": "Especifique qual cliente de download é usado para baixar deste indexador", "IndexerTagHelpText": "Use este indexador apenas para artistas com pelo menos uma tag correspondente. Deixe em branco para usar com todos os artistas.", @@ -771,7 +771,7 @@ "ShouldMonitorExistingHelpText": "Monitorar automaticamente os álbuns nesta lista que já estão no Lidarr", "ShouldSearch": "Pesquisar novos itens", "ShouldSearchHelpText": "Indexadores de pesquisa para novos itens adicionados. Tenha cuidado ao usar com listas grandes.", - "ShowAdvanced": "Mostrar Avançado", + "ShowAdvanced": "Mostrar opções avançadas", "SizeLimit": "Limite de Tamanho", "SizeOnDisk": "Tamanho em disco", "SourceTitle": "Título da Fonte", @@ -836,7 +836,7 @@ "TrackCount": "Número da Faixa", "TrackImported": "Faixa Importada", "TrackProgress": "Progresso da Faixa", - "EndedOnly": "Terminado Apenas", + "EndedOnly": "Finalizado apenas", "MediaCount": "Número de Mídias", "Database": "Banco de dados", "DoneEditingGroups": "Concluir edição de grupos", @@ -861,12 +861,12 @@ "MinimumCustomFormatScore": "Pontuação Mínima de Formato Personalizado", "MinimumCustomFormatScoreHelpText": "Pontuação mínima de formato personalizado necessária para ignorar o atraso do protocolo preferido", "UnableToLoadInteractiveSearch": "Não foi possível carregar os resultados desta pesquisa de álbum. Tente mais tarde", - "UnableToLoadCustomFormats": "Não foi possível carregar formatos personalizados", + "UnableToLoadCustomFormats": "Não foi possível carregar os formatos personalizados", "CopyToClipboard": "Copiar para a área de transferência", "CouldntFindAnyResultsForTerm": "Não foi possível encontrar resultados para \"{0}\"", "CustomFormat": "Formato personalizado", - "CustomFormatRequiredHelpText": "Esta condição {0} deve ser correspondida para aplicar o formato personalizado. Caso contrário, uma única correspondência de {1} é suficiente.", - "CustomFormatSettings": "Configurações do Formato Personalizado", + "CustomFormatRequiredHelpText": "Essa condição {0} deve corresponder para que o formato personalizado seja aplicado. Caso contrário, uma correspondência {0} é suficiente.", + "CustomFormatSettings": "Configurações de formato personalizado", "CustomFormats": "Formatos personalizados", "Customformat": "Formato personalizado", "DeleteCustomFormat": "Excluir formato personalizado", @@ -877,20 +877,20 @@ "ExportCustomFormat": "Exportar formato personalizado", "FailedDownloadHandling": "Falha no gerenciamento de download", "FailedLoadingSearchResults": "Falha ao carregar os resultados da pesquisa. Tente novamente.", - "ForeignId": "ID Estrangeiro", + "ForeignId": "ID estrangeiro", "Formats": "Formatos", "NegateHelpText": "Se marcado, o formato personalizado não será aplicado se esta condição {0} corresponder.", - "IncludeCustomFormatWhenRenamingHelpText": "'Incluir em {Formatos Personalizados} formato de renomeação'", + "IncludeCustomFormatWhenRenamingHelpText": "\"Incluir no formato de renomeação {Custom Formats}\"", "ItsEasyToAddANewArtistJustStartTypingTheNameOfTheArtistYouWantToAdd": "É fácil adicionar um novo artista, basta começar a digitar o nome do artista que deseja adicionar.", "MinFormatScoreHelpText": "Pontuação mínima de formato personalizado permitida para download", "Monitor": "Monitorar", "Monitoring": "Monitorando", - "PreferTorrent": "Preferir Torrent", + "PreferTorrent": "Preferir torrent", "PreferUsenet": "Preferir Usenet", "ResetDefinitionTitlesHelpText": "Redefinir títulos de definição e valores", - "ResetDefinitions": "Redefinir Definições", + "ResetDefinitions": "Redefinir definições", "ResetTitles": "Redefinir títulos", - "SpecificMonitoringOptionHelpText": "Monitorar autores, mas só monitorar livros explicitamente incluídos na lista", + "SpecificMonitoringOptionHelpText": "Monitorar artistas, mas só monitorar os álbuns explicitamente incluídos na lista", "Clone": "Clonar", "CloneCustomFormat": "Clonar formato personalizado", "Conditions": "Condições", @@ -904,7 +904,7 @@ "ApiKeyValidationHealthCheckMessage": "Atualize sua chave de API para ter pelo menos {0} caracteres. Você pode fazer isso através das configurações ou do arquivo de configuração", "AppDataLocationHealthCheckMessage": "A atualização não será possível para evitar a exclusão de AppData na atualização", "ColonReplacement": "Substituto para dois-pontos", - "DashOrSpaceDashDependingOnName": "Traço ou Traço e Espaço dependendo do nome", + "DashOrSpaceDashDependingOnName": "Traço ou Espaço e Traço, dependendo do nome", "DeleteFormat": "Excluir Formato", "Disabled": "Desabilitado", "DownloadClientCheckDownloadingToRoot": "O cliente de download {0} coloca os downloads na pasta raiz {1}. Você não deve baixar para uma pasta raiz.", @@ -922,7 +922,7 @@ "IndexerRssHealthCheckNoIndexers": "Nenhum indexador disponível com sincronização de RSS ativada, o Lidarr não obterá novos lançamentos automaticamente", "IndexerSearchCheckNoAutomaticMessage": "Nenhum indexador disponível com a pesquisa automática ativada, o Lidarr não fornecerá nenhum resultado de pesquisa automática", "IndexerSearchCheckNoAvailableIndexersMessage": "Todos os indexadores com capacidade de pesquisa estão temporariamente indisponíveis devido a erros recentes do indexador", - "IndexerSearchCheckNoInteractiveMessage": "Nenhum indexador disponível com a Pesquisa Interativa habilitada, o Lidarr não fornecerá nenhum resultado de pesquisa interativa", + "IndexerSearchCheckNoInteractiveMessage": "Nenhum indexador disponível com a Pesquisa interativa habilitada, o Lidarr não fornecerá nenhum resultado de pesquisa interativa", "IndexerStatusCheckAllClientMessage": "Todos os indexadores estão indisponíveis devido a falhas", "IndexerStatusCheckSingleClientMessage": "Indexadores indisponíveis devido a falhas: {0}", "Loading": "carregando", @@ -949,7 +949,7 @@ "ReplaceWithSpaceDashSpace": "Substituir com Espaço, Traço e Espaço", "RootFolderCheckMultipleMessage": "Faltam várias pastas raiz: {0}", "RootFolderCheckSingleMessage": "Pasta raiz ausente: {0}", - "SmartReplace": "Substituição Inteligente", + "SmartReplace": "Substituição inteligente", "SystemTimeCheckMessage": "A hora do sistema está desligada por mais de 1 dia. Tarefas agendadas podem não ser executadas corretamente até que o horário seja corrigido", "ThereWasAnErrorLoadingThisItem": "Ocorreu um erro ao carregar este item", "ThereWasAnErrorLoadingThisPage": "Ocorreu um erro ao carregar esta página", @@ -984,7 +984,7 @@ "Required": "Necessário", "ResetQualityDefinitions": "Redefinir definições de qualidade", "ResetQualityDefinitionsMessageText": "Tem certeza de que deseja redefinir as definições de qualidade?", - "ResetTitlesHelpText": "Redefinir títulos de configuração, bem como valores", + "ResetTitlesHelpText": "Redefinir títulos de definição e valores", "BlocklistReleaseHelpText": "Impede que o Lidarr obtenha automaticamente esses arquivos novamente", "FailedToLoadQueue": "Falha ao carregar a fila", "QueueIsEmpty": "A fila está vazia", @@ -1085,5 +1085,17 @@ "DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "O cliente de download {0} está configurado para remover downloads concluídos. Isso pode resultar na remoção dos downloads do seu cliente antes que {1} possa importá-los.", "InfoUrl": "URL da info", "GrabId": "Obter ID", - "InvalidUILanguage": "Sua IU está configurada com um idioma inválido, corrija-a e salve suas configurações" + "InvalidUILanguage": "Sua IU está configurada com um idioma inválido, corrija-a e salve suas configurações", + "AuthBasic": "Básico (pop-up do navegador)", + "AuthenticationRequired": "Autenticação exigida", + "AuthenticationMethod": "Método de autenticação", + "AuthenticationRequiredHelpText": "Altere para quais solicitações a autenticação é necessária. Não mude a menos que você entenda os riscos.", + "AuthenticationRequiredUsernameHelpTextWarning": "Digite um novo nome de usuário", + "AuthenticationRequiredWarning": "Para evitar o acesso remoto sem autenticação, {appName} agora exige que a autenticação esteja habilitada. Opcionalmente, você pode desabilitar a autenticação de endereços locais.", + "DisabledForLocalAddresses": "Desabilitado para endereços locais", + "External": "Externo", + "AuthForm": "Formulário (página de login)", + "AuthenticationMethodHelpTextWarning": "Selecione um método de autenticação válido", + "AuthenticationRequiredPasswordHelpTextWarning": "Digite uma nova senha", + "Auto": "Automático" } diff --git a/src/NzbDrone.Core/Localization/Core/ro.json b/src/NzbDrone.Core/Localization/Core/ro.json index eecf37660..ca531511a 100644 --- a/src/NzbDrone.Core/Localization/Core/ro.json +++ b/src/NzbDrone.Core/Localization/Core/ro.json @@ -340,7 +340,7 @@ "SourcePath": "Calea sursei", "SSLCertPassword": "Parola SSL Cert", "SslCertPasswordHelpText": "Parola pentru fișierul pfx", - "TimeFormat": "Format de timp", + "TimeFormat": "Format ora", "TorrentDelayHelpText": "Întârziați în câteva minute pentru a aștepta înainte de a apuca un torent", "Tracks": "Urmă", "Type": "Tip", @@ -684,5 +684,6 @@ "AddIndexerImplementation": "Adăugați Indexator - {implementationName}", "AddConnectionImplementation": "Adăugați conexiune - {implementationName}", "Album": "Album", - "AddConnection": "Adăugați conexiune" + "AddConnection": "Adăugați conexiune", + "AppUpdated": "{appName} actualizat" } From f509ca0f7270b2338083585779d494765c6b27b5 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Thu, 5 Jan 2023 18:20:49 -0800 Subject: [PATCH 073/820] Refactor Artist index to use react-window (cherry picked from commit d022679b7dcbce3cec98e6a1fd0879e3c0d92523) Fixed: Restoring scroll position when going back/forward to artist list (cherry picked from commit 5aad84dba453c42b4b5a9eac43deecf91a98f4f6) --- frontend/src/Album/Album.ts | 21 + frontend/src/App/AppRoutes.js | 4 +- frontend/src/App/State/AppState.ts | 3 + frontend/src/App/State/ArtistAppState.ts | 72 +++ frontend/src/App/State/SettingsAppState.ts | 13 + frontend/src/Artist/Artist.ts | 51 ++ frontend/src/Artist/ArtistBanner.js | 4 + frontend/src/Artist/ArtistPoster.js | 4 + .../src/Artist/Delete/DeleteArtistModal.js | 1 + frontend/src/Artist/Edit/EditArtistModal.js | 1 + .../Artist/Edit/EditArtistModalConnector.js | 1 + frontend/src/Artist/Index/ArtistIndex.js | 407 --------------- frontend/src/Artist/Index/ArtistIndex.tsx | 333 ++++++++++++ .../src/Artist/Index/ArtistIndexConnector.js | 106 ---- .../Artist/Index/ArtistIndexFilterModal.tsx | 49 ++ .../Index/ArtistIndexFilterModalConnector.js | 24 - .../src/Artist/Index/ArtistIndexFooter.js | 167 ------ .../src/Artist/Index/ArtistIndexFooter.tsx | 169 ++++++ .../Index/ArtistIndexFooterConnector.js | 46 -- .../Artist/Index/ArtistIndexItemConnector.js | 153 ------ .../Index/Banners/ArtistIndexBanner.css | 29 +- .../Index/Banners/ArtistIndexBanner.css.d.ts | 3 +- .../Artist/Index/Banners/ArtistIndexBanner.js | 271 ---------- .../Index/Banners/ArtistIndexBanner.tsx | 257 +++++++++ .../Index/Banners/ArtistIndexBannerInfo.js | 115 ----- .../Index/Banners/ArtistIndexBannerInfo.tsx | 187 +++++++ .../Index/Banners/ArtistIndexBanners.js | 327 ------------ .../Index/Banners/ArtistIndexBanners.tsx | 294 +++++++++++ .../Banners/ArtistIndexBannersConnector.js | 25 - .../Options/ArtistIndexBannerOptionsModal.js | 25 - .../Options/ArtistIndexBannerOptionsModal.tsx | 21 + .../ArtistIndexBannerOptionsModalContent.js | 226 -------- .../ArtistIndexBannerOptionsModalContent.tsx | 167 ++++++ ...IndexBannerOptionsModalContentConnector.js | 23 - .../Index/Banners/selectBannerOptions.ts | 9 + ...ilterMenu.js => ArtistIndexFilterMenu.tsx} | 13 +- ...dexSortMenu.js => ArtistIndexSortMenu.tsx} | 14 +- ...dexViewMenu.js => ArtistIndexViewMenu.tsx} | 40 +- .../Index/Overview/ArtistIndexOverview.css | 8 - .../Overview/ArtistIndexOverview.css.d.ts | 1 - .../Index/Overview/ArtistIndexOverview.js | 283 ---------- .../Index/Overview/ArtistIndexOverview.tsx | 240 +++++++++ .../Index/Overview/ArtistIndexOverviewInfo.js | 249 --------- .../Overview/ArtistIndexOverviewInfo.tsx | 228 ++++++++ .../Overview/ArtistIndexOverviewInfoRow.js | 35 -- .../Overview/ArtistIndexOverviewInfoRow.tsx | 23 + .../Index/Overview/ArtistIndexOverviews.js | 275 ---------- .../Index/Overview/ArtistIndexOverviews.tsx | 203 ++++++++ .../Overview/ArtistIndexOverviewsConnector.js | 25 - .../ArtistIndexOverviewOptionsModal.js | 25 - .../ArtistIndexOverviewOptionsModal.tsx | 25 + .../ArtistIndexOverviewOptionsModalContent.js | 308 ----------- ...ArtistIndexOverviewOptionsModalContent.tsx | 197 +++++++ ...dexOverviewOptionsModalContentConnector.js | 23 - .../Index/Overview/selectOverviewOptions.ts | 9 + .../Artist/Index/Posters/ArtistIndexPoster.js | 305 ----------- .../Index/Posters/ArtistIndexPoster.tsx | 257 +++++++++ ...osterInfo.js => ArtistIndexPosterInfo.tsx} | 116 +++-- .../Index/Posters/ArtistIndexPosters.js | 351 ------------- .../Index/Posters/ArtistIndexPosters.tsx | 294 +++++++++++ .../Posters/ArtistIndexPostersConnector.js | 25 - .../Options/ArtistIndexPosterOptionsModal.js | 25 - .../Options/ArtistIndexPosterOptionsModal.tsx | 21 + .../ArtistIndexPosterOptionsModalContent.js | 248 --------- .../ArtistIndexPosterOptionsModalContent.tsx | 167 ++++++ ...IndexPosterOptionsModalContentConnector.js | 23 - .../Index/Posters/selectPosterOptions.ts | 9 + .../ProgressBar/ArtistIndexProgressBar.css | 1 - ...gressBar.js => ArtistIndexProgressBar.tsx} | 33 +- .../Index/Table/ArtistIndexActionsCell.js | 103 ---- .../Artist/Index/Table/ArtistIndexHeader.js | 86 ---- .../Index/Table/ArtistIndexHeaderConnector.js | 13 - .../src/Artist/Index/Table/ArtistIndexRow.js | 487 ------------------ .../src/Artist/Index/Table/ArtistIndexRow.tsx | 392 ++++++++++++++ .../Artist/Index/Table/ArtistIndexTable.css | 6 +- .../Index/Table/ArtistIndexTable.css.d.ts | 2 +- .../Artist/Index/Table/ArtistIndexTable.js | 134 ----- .../Artist/Index/Table/ArtistIndexTable.tsx | 205 ++++++++ .../Index/Table/ArtistIndexTableConnector.js | 29 -- ...xHeader.css => ArtistIndexTableHeader.css} | 0 ...s.d.ts => ArtistIndexTableHeader.css.d.ts} | 0 .../Index/Table/ArtistIndexTableHeader.tsx | 98 ++++ .../Index/Table/ArtistIndexTableOptions.js | 105 ---- .../Index/Table/ArtistIndexTableOptions.tsx | 62 +++ .../Table/ArtistIndexTableOptionsConnector.js | 14 - ...tistStatusCell.js => ArtistStatusCell.tsx} | 50 +- .../Artist/Index/Table/hasGrowableColumns.js | 16 - .../Artist/Index/Table/hasGrowableColumns.ts | 11 + .../Artist/Index/Table/selectTableOptions.ts | 9 + .../Index/createArtistIndexItemSelector.ts | 48 ++ .../src/Components/Link/SpinnerIconButton.js | 2 + frontend/src/Components/Menu/SortMenu.js | 5 +- frontend/src/Components/Menu/ViewMenu.js | 5 +- .../src/Components/Page/PageContentBody.js | 61 --- .../src/Components/Page/PageContentBody.tsx | 51 ++ .../Page/Toolbar/PageToolbarButton.js | 3 +- frontend/src/Components/Scroller/Scroller.js | 95 ---- frontend/src/Components/Scroller/Scroller.tsx | 90 ++++ frontend/src/Components/withScrollPosition.js | 30 -- .../src/Components/withScrollPosition.tsx | 25 + frontend/src/Helpers/Hooks/useMeasure.ts | 21 + frontend/src/Helpers/Props/ScrollDirection.ts | 8 + frontend/src/Quality/Quality.ts | 17 + .../src/Store/Actions/artistIndexActions.js | 1 + .../createArtistMetadataProfileSelector.js | 16 - .../createArtistMetadataProfileSelector.ts | 18 + .../createArtistQualityProfileSelector.js | 16 - .../createArtistQualityProfileSelector.ts | 18 + .../Store/Selectors/createArtistSelector.js | 10 + .../src/UnmappedFiles/UnmappedFilesTable.js | 24 +- .../Array/getIndexOfFirstCharacter.js | 4 +- frontend/src/typings/CustomFormat.ts | 12 + frontend/src/typings/MetadataProfile.ts | 39 ++ frontend/src/typings/QualityProfile.ts | 23 + package.json | 3 + src/NzbDrone.Core/Localization/Core/en.json | 11 +- yarn.lock | 30 ++ 117 files changed, 4704 insertions(+), 5511 deletions(-) create mode 100644 frontend/src/Album/Album.ts create mode 100644 frontend/src/App/State/ArtistAppState.ts create mode 100644 frontend/src/Artist/Artist.ts delete mode 100644 frontend/src/Artist/Index/ArtistIndex.js create mode 100644 frontend/src/Artist/Index/ArtistIndex.tsx delete mode 100644 frontend/src/Artist/Index/ArtistIndexConnector.js create mode 100644 frontend/src/Artist/Index/ArtistIndexFilterModal.tsx delete mode 100644 frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js delete mode 100644 frontend/src/Artist/Index/ArtistIndexFooter.js create mode 100644 frontend/src/Artist/Index/ArtistIndexFooter.tsx delete mode 100644 frontend/src/Artist/Index/ArtistIndexFooterConnector.js delete mode 100644 frontend/src/Artist/Index/ArtistIndexItemConnector.js delete mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBanner.js create mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx delete mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js create mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.tsx delete mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBanners.js create mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx delete mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js delete mode 100644 frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js create mode 100644 frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.tsx delete mode 100644 frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js create mode 100644 frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx delete mode 100644 frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js create mode 100644 frontend/src/Artist/Index/Banners/selectBannerOptions.ts rename frontend/src/Artist/Index/Menus/{ArtistIndexFilterMenu.js => ArtistIndexFilterMenu.tsx} (76%) rename frontend/src/Artist/Index/Menus/{ArtistIndexSortMenu.js => ArtistIndexSortMenu.tsx} (94%) rename frontend/src/Artist/Index/Menus/{ArtistIndexViewMenu.js => ArtistIndexViewMenu.tsx} (54%) delete mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverview.js create mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx delete mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js create mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx delete mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js create mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx delete mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js create mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx delete mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js delete mode 100644 frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js create mode 100644 frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.tsx delete mode 100644 frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js create mode 100644 frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx delete mode 100644 frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js create mode 100644 frontend/src/Artist/Index/Overview/selectOverviewOptions.ts delete mode 100644 frontend/src/Artist/Index/Posters/ArtistIndexPoster.js create mode 100644 frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx rename frontend/src/Artist/Index/Posters/{ArtistIndexPosterInfo.js => ArtistIndexPosterInfo.tsx} (63%) delete mode 100644 frontend/src/Artist/Index/Posters/ArtistIndexPosters.js create mode 100644 frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx delete mode 100644 frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js delete mode 100644 frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js create mode 100644 frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.tsx delete mode 100644 frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js create mode 100644 frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx delete mode 100644 frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js create mode 100644 frontend/src/Artist/Index/Posters/selectPosterOptions.ts rename frontend/src/Artist/Index/ProgressBar/{ArtistIndexProgressBar.js => ArtistIndexProgressBar.tsx} (57%) delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexHeader.js delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexRow.js create mode 100644 frontend/src/Artist/Index/Table/ArtistIndexRow.tsx delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTable.js create mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTable.tsx delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js rename frontend/src/Artist/Index/Table/{ArtistIndexHeader.css => ArtistIndexTableHeader.css} (100%) rename frontend/src/Artist/Index/Table/{ArtistIndexHeader.css.d.ts => ArtistIndexTableHeader.css.d.ts} (100%) create mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js create mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTableOptions.tsx delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js rename frontend/src/Artist/Index/Table/{ArtistStatusCell.js => ArtistStatusCell.tsx} (52%) delete mode 100644 frontend/src/Artist/Index/Table/hasGrowableColumns.js create mode 100644 frontend/src/Artist/Index/Table/hasGrowableColumns.ts create mode 100644 frontend/src/Artist/Index/Table/selectTableOptions.ts create mode 100644 frontend/src/Artist/Index/createArtistIndexItemSelector.ts delete mode 100644 frontend/src/Components/Page/PageContentBody.js create mode 100644 frontend/src/Components/Page/PageContentBody.tsx delete mode 100644 frontend/src/Components/Scroller/Scroller.js create mode 100644 frontend/src/Components/Scroller/Scroller.tsx delete mode 100644 frontend/src/Components/withScrollPosition.js create mode 100644 frontend/src/Components/withScrollPosition.tsx create mode 100644 frontend/src/Helpers/Hooks/useMeasure.ts create mode 100644 frontend/src/Helpers/Props/ScrollDirection.ts create mode 100644 frontend/src/Quality/Quality.ts delete mode 100644 frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js create mode 100644 frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts delete mode 100644 frontend/src/Store/Selectors/createArtistQualityProfileSelector.js create mode 100644 frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts create mode 100644 frontend/src/typings/CustomFormat.ts create mode 100644 frontend/src/typings/MetadataProfile.ts create mode 100644 frontend/src/typings/QualityProfile.ts diff --git a/frontend/src/Album/Album.ts b/frontend/src/Album/Album.ts new file mode 100644 index 000000000..03a129e06 --- /dev/null +++ b/frontend/src/Album/Album.ts @@ -0,0 +1,21 @@ +import ModelBase from 'App/ModelBase'; + +export interface Statistics { + trackCount: number; + trackFileCount: number; + percentOfTracks: number; + sizeOnDisk: number; + totalTrackCount: number; +} + +interface Album extends ModelBase { + foreignAlbumId: string; + title: string; + overview: string; + disambiguation?: string; + monitored: boolean; + releaseDate: string; + statistics: Statistics; +} + +export default Album; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 223e0f90e..54913f632 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -8,7 +8,7 @@ import AlbumDetailsPageConnector from 'Album/Details/AlbumDetailsPageConnector'; import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector'; import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector'; import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector'; -import ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector'; +import ArtistIndex from 'Artist/Index/ArtistIndex'; import CalendarPageConnector from 'Calendar/CalendarPageConnector'; import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; @@ -51,7 +51,7 @@ function AppRoutes(props) { { diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 8c8b99fba..e6b9d596c 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,3 +1,4 @@ +import ArtistAppState, { ArtistIndexAppState } from './ArtistAppState'; import SettingsAppState from './SettingsAppState'; import TagsAppState from './TagsAppState'; @@ -34,6 +35,8 @@ export interface CustomFilter { } interface AppState { + artist: ArtistAppState; + artistIndex: ArtistIndexAppState; settings: SettingsAppState; tags: TagsAppState; } diff --git a/frontend/src/App/State/ArtistAppState.ts b/frontend/src/App/State/ArtistAppState.ts new file mode 100644 index 000000000..9e0628df7 --- /dev/null +++ b/frontend/src/App/State/ArtistAppState.ts @@ -0,0 +1,72 @@ +import AppSectionState, { + AppSectionDeleteState, + AppSectionSaveState, +} from 'App/State/AppSectionState'; +import Artist from 'Artist/Artist'; +import Column from 'Components/Table/Column'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { Filter, FilterBuilderProp } from './AppState'; + +export interface ArtistIndexAppState { + sortKey: string; + sortDirection: SortDirection; + secondarySortKey: string; + secondarySortDirection: SortDirection; + view: string; + + posterOptions: { + detailedProgressBar: boolean; + size: string; + showTitle: boolean; + showMonitored: boolean; + showQualityProfile: boolean; + showNextAlbum: boolean; + showSearchAction: boolean; + }; + + bannerOptions: { + detailedProgressBar: boolean; + size: string; + showTitle: boolean; + showMonitored: boolean; + showQualityProfile: boolean; + showNextAlbum: boolean; + showSearchAction: boolean; + }; + + overviewOptions: { + detailedProgressBar: boolean; + size: string; + showMonitored: boolean; + showQualityProfile: boolean; + showLastAlbum: boolean; + showAdded: boolean; + showAlbumCount: boolean; + showPath: boolean; + showSizeOnDisk: boolean; + showSearchAction: boolean; + }; + + tableOptions: { + showBanners: boolean; + showSearchAction: boolean; + }; + + selectedFilterKey: string; + filterBuilderProps: FilterBuilderProp[]; + filters: Filter[]; + columns: Column[]; +} + +interface ArtistAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState { + itemMap: Record; + + deleteOptions: { + addImportListExclusion: boolean; + }; +} + +export default ArtistAppState; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 4c0680956..c5b974610 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -1,11 +1,14 @@ import AppSectionState, { AppSectionDeleteState, AppSectionSaveState, + AppSectionSchemaState, } from 'App/State/AppSectionState'; import DownloadClient from 'typings/DownloadClient'; import ImportList from 'typings/ImportList'; import Indexer from 'typings/Indexer'; +import MetadataProfile from 'typings/MetadataProfile'; import Notification from 'typings/Notification'; +import QualityProfile from 'typings/QualityProfile'; import { UiSettings } from 'typings/UiSettings'; export interface DownloadClientAppState @@ -27,13 +30,23 @@ export interface NotificationAppState extends AppSectionState, AppSectionDeleteState {} +export interface QualityProfilesAppState + extends AppSectionState, + AppSectionSchemaState {} + +export interface MetadataProfilesAppState + extends AppSectionState, + AppSectionSchemaState {} + export type UiSettingsAppState = AppSectionState; interface SettingsAppState { downloadClients: DownloadClientAppState; importLists: ImportListAppState; indexers: IndexerAppState; + metadataProfiles: MetadataProfilesAppState; notifications: NotificationAppState; + qualityProfiles: QualityProfilesAppState; uiSettings: UiSettingsAppState; } diff --git a/frontend/src/Artist/Artist.ts b/frontend/src/Artist/Artist.ts new file mode 100644 index 000000000..31b609d73 --- /dev/null +++ b/frontend/src/Artist/Artist.ts @@ -0,0 +1,51 @@ +import Album from 'Album/Album'; +import ModelBase from 'App/ModelBase'; + +export interface Image { + coverType: string; + url: string; + remoteUrl: string; +} + +export interface Statistics { + albumCount: number; + trackCount: number; + trackFileCount: number; + percentOfTracks: number; + sizeOnDisk: number; + totalTrackCount: number; +} + +export interface Ratings { + votes: number; + value: number; +} + +interface Artist extends ModelBase { + added: string; + foreignArtistId: string; + cleanName: string; + ended: boolean; + genres: string[]; + images: Image[]; + monitored: boolean; + overview: string; + path: string; + lastAlbum?: Album; + nextAlbum?: Album; + qualityProfileId: number; + metadataProfileId: number; + ratings: Ratings; + rootFolderPath: string; + albums: Album[]; + sortName: string; + statistics: Statistics; + status: string; + tags: number[]; + artistName: string; + artistType?: string; + disambiguation?: string; + isSaving?: boolean; +} + +export default Artist; diff --git a/frontend/src/Artist/ArtistBanner.js b/frontend/src/Artist/ArtistBanner.js index b409667b1..5483912e1 100644 --- a/frontend/src/Artist/ArtistBanner.js +++ b/frontend/src/Artist/ArtistBanner.js @@ -15,6 +15,10 @@ function ArtistBanner(props) { } ArtistBanner.propTypes = { + ...ArtistImage.propTypes, + coverType: PropTypes.string, + placeholder: PropTypes.string, + overflow: PropTypes.bool, size: PropTypes.number.isRequired }; diff --git a/frontend/src/Artist/ArtistPoster.js b/frontend/src/Artist/ArtistPoster.js index 4eebd9ca4..de594e5b9 100644 --- a/frontend/src/Artist/ArtistPoster.js +++ b/frontend/src/Artist/ArtistPoster.js @@ -15,6 +15,10 @@ function ArtistPoster(props) { } ArtistPoster.propTypes = { + ...ArtistImage.propTypes, + coverType: PropTypes.string, + placeholder: PropTypes.string, + overflow: PropTypes.bool, size: PropTypes.number.isRequired }; diff --git a/frontend/src/Artist/Delete/DeleteArtistModal.js b/frontend/src/Artist/Delete/DeleteArtistModal.js index 8e0b87296..c647b7735 100644 --- a/frontend/src/Artist/Delete/DeleteArtistModal.js +++ b/frontend/src/Artist/Delete/DeleteArtistModal.js @@ -26,6 +26,7 @@ function DeleteArtistModal(props) { } DeleteArtistModal.propTypes = { + ...DeleteArtistModalContentConnector.propTypes, isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Edit/EditArtistModal.js b/frontend/src/Artist/Edit/EditArtistModal.js index 6e99a2f53..f221e728c 100644 --- a/frontend/src/Artist/Edit/EditArtistModal.js +++ b/frontend/src/Artist/Edit/EditArtistModal.js @@ -18,6 +18,7 @@ function EditArtistModal({ isOpen, onModalClose, ...otherProps }) { } EditArtistModal.propTypes = { + ...EditArtistModalContentConnector.propTypes, isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Edit/EditArtistModalConnector.js b/frontend/src/Artist/Edit/EditArtistModalConnector.js index 56e336201..9c4e6325f 100644 --- a/frontend/src/Artist/Edit/EditArtistModalConnector.js +++ b/frontend/src/Artist/Edit/EditArtistModalConnector.js @@ -32,6 +32,7 @@ class EditArtistModalConnector extends Component { } EditArtistModalConnector.propTypes = { + ...EditArtistModal.propTypes, onModalClose: PropTypes.func.isRequired, clearPendingChanges: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Index/ArtistIndex.js b/frontend/src/Artist/Index/ArtistIndex.js deleted file mode 100644 index 6f68f7fcd..000000000 --- a/frontend/src/Artist/Index/ArtistIndex.js +++ /dev/null @@ -1,407 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import NoArtist from 'Artist/NoArtist'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageJumpBar from 'Components/Page/PageJumpBar'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import { align, icons, sortDirections } from 'Helpers/Props'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import translate from 'Utilities/String/translate'; -import ArtistIndexFooterConnector from './ArtistIndexFooterConnector'; -import ArtistIndexBannersConnector from './Banners/ArtistIndexBannersConnector'; -import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal'; -import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu'; -import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu'; -import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu'; -import ArtistIndexOverviewsConnector from './Overview/ArtistIndexOverviewsConnector'; -import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal'; -import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector'; -import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal'; -import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector'; -import ArtistIndexTableOptionsConnector from './Table/ArtistIndexTableOptionsConnector'; -import styles from './ArtistIndex.css'; - -function getViewComponent(view) { - if (view === 'posters') { - return ArtistIndexPostersConnector; - } - - if (view === 'banners') { - return ArtistIndexBannersConnector; - } - - if (view === 'overview') { - return ArtistIndexOverviewsConnector; - } - - return ArtistIndexTableConnector; -} - -class ArtistIndex extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - scroller: null, - jumpBarItems: { order: [] }, - jumpToCharacter: null, - isPosterOptionsModalOpen: false, - isBannerOptionsModalOpen: false, - isOverviewOptionsModalOpen: false - }; - } - - componentDidMount() { - this.setJumpBarItems(); - } - - componentDidUpdate(prevProps) { - const { - items, - sortKey, - sortDirection - } = this.props; - - if (sortKey !== prevProps.sortKey || - sortDirection !== prevProps.sortDirection || - hasDifferentItemsOrOrder(prevProps.items, items) - ) { - this.setJumpBarItems(); - } - - if (this.state.jumpToCharacter != null) { - this.setState({ jumpToCharacter: null }); - } - } - - // - // Control - - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - - setJumpBarItems() { - const { - items, - sortKey, - sortDirection - } = this.props; - - // Reset if not sorting by sortName - if (sortKey !== 'sortName') { - this.setState({ jumpBarItems: { order: [] } }); - return; - } - - const characters = _.reduce(items, (acc, item) => { - let char = item.sortName.charAt(0); - - if (!isNaN(char)) { - char = '#'; - } - - if (char in acc) { - acc[char] = acc[char] + 1; - } else { - acc[char] = 1; - } - - return acc; - }, {}); - - const order = Object.keys(characters).sort(); - - // Reverse if sorting descending - if (sortDirection === sortDirections.DESCENDING) { - order.reverse(); - } - - const jumpBarItems = { - characters, - order - }; - - this.setState({ jumpBarItems }); - } - - // - // Listeners - - onPosterOptionsPress = () => { - this.setState({ isPosterOptionsModalOpen: true }); - }; - - onPosterOptionsModalClose = () => { - this.setState({ isPosterOptionsModalOpen: false }); - }; - - onBannerOptionsPress = () => { - this.setState({ isBannerOptionsModalOpen: true }); - }; - - onBannerOptionsModalClose = () => { - this.setState({ isBannerOptionsModalOpen: false }); - }; - - onOverviewOptionsPress = () => { - this.setState({ isOverviewOptionsModalOpen: true }); - }; - - onOverviewOptionsModalClose = () => { - this.setState({ isOverviewOptionsModalOpen: false }); - }; - - onJumpBarItemPress = (jumpToCharacter) => { - this.setState({ jumpToCharacter }); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - totalItems, - items, - columns, - selectedFilterKey, - filters, - customFilters, - sortKey, - sortDirection, - view, - isRefreshingArtist, - isRssSyncExecuting, - onScroll, - onSortSelect, - onFilterSelect, - onViewSelect, - onRefreshArtistPress, - onRssSyncPress, - ...otherProps - } = this.props; - - const { - scroller, - jumpBarItems, - jumpToCharacter, - isPosterOptionsModalOpen, - isBannerOptionsModalOpen, - isOverviewOptionsModalOpen - } = this.state; - - const ViewComponent = getViewComponent(view); - const isLoaded = !!(!error && isPopulated && items.length && scroller); - const hasNoArtist = !totalItems; - - return ( - - - - - - - - - - - { - view === 'table' ? - - - : - null - } - - { - view === 'posters' ? - : - null - } - - { - view === 'banners' ? - : - null - } - - { - view === 'overview' ? - : - null - } - - - - - - - - - - - -
- - { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && -
- {getErrorMessage(error, 'Failed to load artist from API')} -
- } - - { - isLoaded && -
- - - -
- } - - { - !error && isPopulated && !items.length && - - } -
- - { - isLoaded && !!jumpBarItems.order.length && - - } -
- - - - - - -
- ); - } -} - -ArtistIndex.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - totalItems: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.oneOf(sortDirections.all), - view: PropTypes.string.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isRssSyncExecuting: PropTypes.bool.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - onSortSelect: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired, - onViewSelect: PropTypes.func.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onRssSyncPress: PropTypes.func.isRequired, - onScroll: PropTypes.func.isRequired -}; - -export default ArtistIndex; diff --git a/frontend/src/Artist/Index/ArtistIndex.tsx b/frontend/src/Artist/Index/ArtistIndex.tsx new file mode 100644 index 000000000..604b905a2 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndex.tsx @@ -0,0 +1,333 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import NoArtist from 'Artist/NoArtist'; +import { REFRESH_ARTIST, RSS_SYNC } from 'Commands/commandNames'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageJumpBar from 'Components/Page/PageJumpBar'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import withScrollPosition from 'Components/withScrollPosition'; +import { align, icons } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { + setArtistFilter, + setArtistSort, + setArtistTableOption, + setArtistView, +} from 'Store/Actions/artistIndexActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import scrollPositions from 'Store/scrollPositions'; +import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import ArtistIndexFooter from './ArtistIndexFooter'; +import ArtistIndexBanners from './Banners/ArtistIndexBanners'; +import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal'; +import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu'; +import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu'; +import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu'; +import ArtistIndexOverviews from './Overview/ArtistIndexOverviews'; +import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal'; +import ArtistIndexPosters from './Posters/ArtistIndexPosters'; +import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal'; +import ArtistIndexTable from './Table/ArtistIndexTable'; +import ArtistIndexTableOptions from './Table/ArtistIndexTableOptions'; +import styles from './ArtistIndex.css'; + +function getViewComponent(view) { + if (view === 'posters') { + return ArtistIndexPosters; + } + + if (view === 'banners') { + return ArtistIndexBanners; + } + + if (view === 'overview') { + return ArtistIndexOverviews; + } + + return ArtistIndexTable; +} + +interface ArtistIndexProps { + initialScrollTop?: number; +} + +const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + columns, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + view, + } = useSelector(createArtistClientSideCollectionItemsSelector('artistIndex')); + + const isRefreshingArtist = useSelector( + createCommandExecutingSelector(REFRESH_ARTIST) + ); + const isRssSyncExecuting = useSelector( + createCommandExecutingSelector(RSS_SYNC) + ); + const { isSmallScreen } = useSelector(createDimensionsSelector()); + const dispatch = useDispatch(); + const scrollerRef = useRef(); + const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); + const [jumpToCharacter, setJumpToCharacter] = useState(null); + + const onRefreshArtistPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + }) + ); + }, [dispatch]); + + const onRssSyncPress = useCallback(() => { + dispatch( + executeCommand({ + name: RSS_SYNC, + }) + ); + }, [dispatch]); + + const onTableOptionChange = useCallback( + (payload) => { + dispatch(setArtistTableOption(payload)); + }, + [dispatch] + ); + + const onViewSelect = useCallback( + (value) => { + dispatch(setArtistView({ view: value })); + + if (scrollerRef.current) { + scrollerRef.current.scrollTo(0, 0); + } + }, + [scrollerRef, dispatch] + ); + + const onSortSelect = useCallback( + (value) => { + dispatch(setArtistSort({ sortKey: value })); + }, + [dispatch] + ); + + const onFilterSelect = useCallback( + (value) => { + dispatch(setArtistFilter({ selectedFilterKey: value })); + }, + [dispatch] + ); + + const onOptionsPress = useCallback(() => { + setIsOptionsModalOpen(true); + }, [setIsOptionsModalOpen]); + + const onOptionsModalClose = useCallback(() => { + setIsOptionsModalOpen(false); + }, [setIsOptionsModalOpen]); + + const onJumpBarItemPress = useCallback( + (character) => { + setJumpToCharacter(character); + }, + [setJumpToCharacter] + ); + + const onScroll = useCallback( + ({ scrollTop }) => { + setJumpToCharacter(null); + scrollPositions.artistIndex = scrollTop; + }, + [setJumpToCharacter] + ); + + const jumpBarItems = useMemo(() => { + // Reset if not sorting by sortName + if (sortKey !== 'sortName') { + return { + order: [], + }; + } + + const characters = items.reduce((acc, item) => { + let char = item.sortName.charAt(0); + + if (!isNaN(char)) { + char = '#'; + } + + if (char in acc) { + acc[char] = acc[char] + 1; + } else { + acc[char] = 1; + } + + return acc; + }, {}); + + const order = Object.keys(characters).sort(); + + // Reverse if sorting descending + if (sortDirection === SortDirection.Descending) { + order.reverse(); + } + + return { + characters, + order, + }; + }, [items, sortKey, sortDirection]); + const ViewComponent = useMemo(() => getViewComponent(view), [view]); + + const isLoaded = !!(!error && isPopulated && items.length); + const hasNoArtist = !totalItems; + + return ( + + + + + + + + + + {view === 'table' ? ( + + + + ) : ( + + )} + + + + + + + + + + +
+ + {isFetching && !isPopulated ? : null} + + {!isFetching && !!error ? ( +
+ {getErrorMessage(error, 'Failed to load artist from API')} +
+ ) : null} + + {isLoaded ? ( +
+ + + +
+ ) : null} + + {!error && isPopulated && !items.length ? ( + + ) : null} +
+ + {isLoaded && !!jumpBarItems.order.length ? ( + + ) : null} +
+ {view === 'posters' ? ( + + ) : null} + {view === 'banners' ? ( + + ) : null} + {view === 'overview' ? ( + + ) : null} +
+ ); +}, 'artistIndex'); + +export default ArtistIndex; diff --git a/frontend/src/Artist/Index/ArtistIndexConnector.js b/frontend/src/Artist/Index/ArtistIndexConnector.js deleted file mode 100644 index 541d9819e..000000000 --- a/frontend/src/Artist/Index/ArtistIndexConnector.js +++ /dev/null @@ -1,106 +0,0 @@ -/* eslint max-params: 0 */ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import withScrollPosition from 'Components/withScrollPosition'; -import { setArtistFilter, setArtistSort, setArtistTableOption, setArtistView } from 'Store/Actions/artistIndexActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import scrollPositions from 'Store/scrollPositions'; -import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import ArtistIndex from './ArtistIndex'; - -function createMapStateToProps() { - return createSelector( - createArtistClientSideCollectionItemsSelector('artistIndex'), - createCommandExecutingSelector(commandNames.REFRESH_ARTIST), - createCommandExecutingSelector(commandNames.RSS_SYNC), - createDimensionsSelector(), - ( - artist, - isRefreshingArtist, - isRssSyncExecuting, - dimensionsState - ) => { - return { - ...artist, - isRefreshingArtist, - isRssSyncExecuting, - isSmallScreen: dimensionsState.isSmallScreen - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onTableOptionChange(payload) { - dispatch(setArtistTableOption(payload)); - }, - - onSortSelect(sortKey) { - dispatch(setArtistSort({ sortKey })); - }, - - onFilterSelect(selectedFilterKey) { - dispatch(setArtistFilter({ selectedFilterKey })); - }, - - dispatchSetArtistView(view) { - dispatch(setArtistView({ view })); - }, - - onRefreshArtistPress() { - dispatch(executeCommand({ - name: commandNames.REFRESH_ARTIST - })); - }, - - onRssSyncPress() { - dispatch(executeCommand({ - name: commandNames.RSS_SYNC - })); - } - }; -} - -class ArtistIndexConnector extends Component { - - // - // Listeners - - onViewSelect = (view) => { - this.props.dispatchSetArtistView(view); - }; - - onScroll = ({ scrollTop }) => { - scrollPositions.artistIndex = scrollTop; - }; - - // - // Render - - render() { - return ( - - ); - } -} - -ArtistIndexConnector.propTypes = { - isSmallScreen: PropTypes.bool.isRequired, - view: PropTypes.string.isRequired, - dispatchSetArtistView: PropTypes.func.isRequired -}; - -export default withScrollPosition( - connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexConnector), - 'artistIndex' -); diff --git a/frontend/src/Artist/Index/ArtistIndexFilterModal.tsx b/frontend/src/Artist/Index/ArtistIndexFilterModal.tsx new file mode 100644 index 000000000..ea3b99a81 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexFilterModal.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setArtistFilter } from 'Store/Actions/artistIndexActions'; + +function createArtistSelector() { + return createSelector( + (state: AppState) => state.artist.items, + (artist) => { + return artist; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.artistIndex.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +export default function ArtistIndexFilterModal(props) { + const sectionItems = useSelector(createArtistSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'artist'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload) => { + dispatch(setArtistFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js b/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js deleted file mode 100644 index cf5ec33ea..000000000 --- a/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterModal from 'Components/Filter/FilterModal'; -import { setArtistFilter } from 'Store/Actions/artistIndexActions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artist.items, - (state) => state.artistIndex.filterBuilderProps, - (sectionItems, filterBuilderProps) => { - return { - sectionItems, - filterBuilderProps, - customFilterType: 'artistIndex' - }; - } - ); -} - -const mapDispatchToProps = { - dispatchSetFilter: setArtistFilter -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.js b/frontend/src/Artist/Index/ArtistIndexFooter.js deleted file mode 100644 index 5b0f1fc5a..000000000 --- a/frontend/src/Artist/Index/ArtistIndexFooter.js +++ /dev/null @@ -1,167 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { PureComponent } from 'react'; -import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import styles from './ArtistIndexFooter.css'; - -class ArtistIndexFooter extends PureComponent { - - // - // Render - - render() { - const { artist } = this.props; - const count = artist.length; - let tracks = 0; - let trackFiles = 0; - let ended = 0; - let continuing = 0; - let monitored = 0; - let totalFileSize = 0; - - artist.forEach((s) => { - const { statistics = {} } = s; - - const { - trackCount = 0, - trackFileCount = 0, - sizeOnDisk = 0 - } = statistics; - - tracks += trackCount; - trackFiles += trackFileCount; - - if (s.status === 'ended') { - ended++; - } else { - continuing++; - } - - if (s.monitored) { - monitored++; - } - - totalFileSize += sizeOnDisk; - }); - - return ( - - {(enableColorImpairedMode) => { - return ( -
-
-
-
-
- {translate('ContinuingAllTracksDownloaded')} -
-
- -
-
-
- {translate('EndedAllTracksDownloaded')} -
-
- -
-
-
- {translate('MissingTracksArtistMonitored')} -
-
- -
-
-
- {translate('MissingTracksArtistNotMonitored')} -
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - -
-
- ); - }} - - ); - } -} - -ArtistIndexFooter.propTypes = { - artist: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -export default ArtistIndexFooter; diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.tsx b/frontend/src/Artist/Index/ArtistIndexFooter.tsx new file mode 100644 index 000000000..4b4982055 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexFooter.tsx @@ -0,0 +1,169 @@ +import classNames from 'classnames'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; +import ArtistAppState from 'App/State/ArtistAppState'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import styles from './ArtistIndexFooter.css'; + +function createUnoptimizedSelector() { + return createSelector( + createClientSideCollectionSelector('artist', 'artistIndex'), + (artist: ArtistAppState) => { + return artist.items.map((s) => { + const { monitored, status, statistics } = s; + + return { + monitored, + status, + statistics, + }; + }); + } + ); +} + +function createArtistSelector() { + return createDeepEqualSelector( + createUnoptimizedSelector(), + (artist) => artist + ); +} + +export default function ArtistIndexFooter() { + const artist = useSelector(createArtistSelector()); + const count = artist.length; + let tracks = 0; + let trackFiles = 0; + let ended = 0; + let continuing = 0; + let monitored = 0; + let totalFileSize = 0; + + artist.forEach((a) => { + const { statistics = { trackCount: 0, trackFileCount: 0, sizeOnDisk: 0 } } = + a; + + const { trackCount = 0, trackFileCount = 0, sizeOnDisk = 0 } = statistics; + + tracks += trackCount; + trackFiles += trackFileCount; + + if (a.status === 'ended') { + ended++; + } else { + continuing++; + } + + if (a.monitored) { + monitored++; + } + + totalFileSize += sizeOnDisk; + }); + + return ( + + {(enableColorImpairedMode) => { + return ( +
+
+
+
+
{translate('ContinuingAllTracksDownloaded')}
+
+ +
+
+
{translate('EndedAllTracksDownloaded')}
+
+ +
+
+
{translate('MissingTracksArtistMonitored')}
+
+ +
+
+
{translate('MissingTracksArtistNotMonitored')}
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); + }} + + ); +} diff --git a/frontend/src/Artist/Index/ArtistIndexFooterConnector.js b/frontend/src/Artist/Index/ArtistIndexFooterConnector.js deleted file mode 100644 index 2cb0e3e7d..000000000 --- a/frontend/src/Artist/Index/ArtistIndexFooterConnector.js +++ /dev/null @@ -1,46 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; -import ArtistIndexFooter from './ArtistIndexFooter'; - -function createUnoptimizedSelector() { - return createSelector( - createClientSideCollectionSelector('artist', 'artistIndex'), - (artist) => { - return artist.items.map((s) => { - const { - monitored, - status, - statistics - } = s; - - return { - monitored, - status, - statistics - }; - }); - } - ); -} - -function createArtistSelector() { - return createDeepEqualSelector( - createUnoptimizedSelector(), - (artist) => artist - ); -} - -function createMapStateToProps() { - return createSelector( - createArtistSelector(), - (artist) => { - return { - artist - }; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexFooter); diff --git a/frontend/src/Artist/Index/ArtistIndexItemConnector.js b/frontend/src/Artist/Index/ArtistIndexItemConnector.js deleted file mode 100644 index 43d92ef13..000000000 --- a/frontend/src/Artist/Index/ArtistIndexItemConnector.js +++ /dev/null @@ -1,153 +0,0 @@ -/* eslint max-params: 0 */ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import { toggleArtistMonitored } from 'Store/Actions/artistActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createArtistMetadataProfileSelector from 'Store/Selectors/createArtistMetadataProfileSelector'; -import createArtistQualityProfileSelector from 'Store/Selectors/createArtistQualityProfileSelector'; -import createArtistSelector from 'Store/Selectors/createArtistSelector'; -import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; - -function selectShowSearchAction() { - return createSelector( - (state) => state.artistIndex, - (artistIndex) => { - const view = artistIndex.view; - - switch (view) { - case 'posters': - return artistIndex.posterOptions.showSearchAction; - case 'banners': - return artistIndex.bannerOptions.showSearchAction; - case 'overview': - return artistIndex.overviewOptions.showSearchAction; - default: - return artistIndex.tableOptions.showSearchAction; - } - } - ); -} - -function createMapStateToProps() { - return createSelector( - createArtistSelector(), - createArtistQualityProfileSelector(), - createArtistMetadataProfileSelector(), - selectShowSearchAction(), - createExecutingCommandsSelector(), - ( - artist, - qualityProfile, - metadataProfile, - showSearchAction, - executingCommands - ) => { - - // If an artist is deleted this selector may fire before the parent - // selectors, which will result in an undefined artist, if that happens - // we want to return early here and again in the render function to avoid - // trying to show an artist that has no information available. - - if (!artist) { - return {}; - } - - const isRefreshingArtist = executingCommands.some((command) => { - return ( - command.name === commandNames.REFRESH_ARTIST && - command.body.artistId === artist.id - ); - }); - - const isSearchingArtist = executingCommands.some((command) => { - return ( - command.name === commandNames.ARTIST_SEARCH && - command.body.artistId === artist.id - ); - }); - - const latestAlbum = _.maxBy(artist.albums, (album) => album.releaseDate); - - return { - ...artist, - qualityProfile, - metadataProfile, - latestAlbum, - showSearchAction, - isRefreshingArtist, - isSearchingArtist - }; - } - ); -} - -const mapDispatchToProps = { - dispatchExecuteCommand: executeCommand, - toggleArtistMonitored -}; - -class ArtistIndexItemConnector extends Component { - - // - // Listeners - - onRefreshArtistPress = () => { - this.props.dispatchExecuteCommand({ - name: commandNames.REFRESH_ARTIST, - artistId: this.props.id - }); - }; - - onSearchPress = () => { - this.props.dispatchExecuteCommand({ - name: commandNames.ARTIST_SEARCH, - artistId: this.props.id - }); - }; - - onMonitoredPress = () => { - this.props.toggleArtistMonitored({ - artistId: this.props.id, - monitored: !this.props.monitored - }); - }; - - // - // Render - - render() { - const { - id, - component: ItemComponent, - ...otherProps - } = this.props; - - if (!id) { - return null; - } - - return ( - - ); - } -} - -ArtistIndexItemConnector.propTypes = { - id: PropTypes.number, - monitored: PropTypes.bool.isRequired, - component: PropTypes.elementType.isRequired, - dispatchExecuteCommand: PropTypes.func.isRequired, - toggleArtistMonitored: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexItemConnector); diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css index e22472389..7f1fc71c6 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css @@ -1,9 +1,5 @@ $hoverScale: 1.05; -.container { - padding: 10px; -} - .content { transition: all 200ms ease-in; @@ -26,12 +22,29 @@ $hoverScale: 1.05; .link { composes: link from '~Components/Link/Link.css'; + position: relative; display: block; + height: 50px; background-color: var(--defaultColor); } -.nextAiring { - background-color: #fafbfc; +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: var(--offWhite); + text-align: center; + font-size: 20px; +} + +.nextAlbum { + background-color: var(--artistBackgroundColor); text-align: center; font-size: $smallFontSize; } @@ -39,8 +52,7 @@ $hoverScale: 1.05; .title { @add-mixin truncate; - background-color: var(--defaultColor); - color: var(--white); + background-color: var(--artistBackgroundColor); text-align: center; font-size: $smallFontSize; } @@ -49,6 +61,7 @@ $hoverScale: 1.05; position: absolute; top: 0; right: 0; + z-index: 1; width: 0; height: 0; border-width: 0 25px 25px 0; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css.d.ts b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css.d.ts index 393757652..bd6cb4ac9 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css.d.ts +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css.d.ts @@ -8,7 +8,8 @@ interface CssExports { 'controls': string; 'ended': string; 'link': string; - 'nextAiring': string; + 'nextAlbum': string; + 'overlayTitle': string; 'title': string; } export const cssExports: CssExports; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js deleted file mode 100644 index 43c7ca22b..000000000 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js +++ /dev/null @@ -1,271 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ArtistBanner from 'Artist/ArtistBanner'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; -import Label from 'Components/Label'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import { icons } from 'Helpers/Props'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import translate from 'Utilities/String/translate'; -import ArtistIndexBannerInfo from './ArtistIndexBannerInfo'; -import styles from './ArtistIndexBanner.css'; - -class ArtistIndexBanner extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - // - // Listeners - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - artistName, - monitored, - status, - foreignArtistId, - nextAiring, - statistics, - images, - bannerWidth, - bannerHeight, - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - showSearchAction, - qualityProfile, - showRelativeDates, - shortDateFormat, - timeFormat, - isRefreshingArtist, - isSearchingArtist, - onRefreshArtistPress, - onSearchPress, - ...otherProps - } = this.props; - - const { - albumCount, - sizeOnDisk, - trackCount, - trackFileCount, - totalTrackCount - } = statistics; - - const { - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - const link = `/artist/${foreignArtistId}`; - - const elementStyle = { - width: `${bannerWidth}px`, - height: `${bannerHeight}px` - }; - - return ( -
-
-
- - - { - status === 'ended' && -
- } - - - - -
- - - - { - showTitle && -
- {artistName} -
- } - - { - showMonitored && -
- {monitored ? 'Monitored' : 'Unmonitored'} -
- } - - { - showQualityProfile && -
- {qualityProfile.name} -
- } - { - nextAiring && -
- { - getRelativeDate( - nextAiring, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } -
- } - - - - - - -
-
- ); - } -} - -ArtistIndexBanner.propTypes = { - id: PropTypes.number.isRequired, - artistName: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - nextAiring: PropTypes.string, - statistics: PropTypes.object.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - bannerWidth: PropTypes.number.isRequired, - bannerHeight: PropTypes.number.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showTitle: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - qualityProfile: PropTypes.object.isRequired, - showSearchAction: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isSearchingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired -}; - -ArtistIndexBanner.defaultProps = { - statistics: { - albumCount: 0, - trackCount: 0, - trackFileCount: 0, - totalTrackCount: 0 - } -}; - -export default ArtistIndexBanner; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx new file mode 100644 index 000000000..1ab5171ad --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx @@ -0,0 +1,257 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Statistics } from 'Artist/Artist'; +import ArtistBanner from 'Artist/ArtistBanner'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import ArtistIndexBannerInfo from 'Artist/Index/Banners/ArtistIndexBannerInfo'; +import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import translate from 'Utilities/String/translate'; +import selectBannerOptions from './selectBannerOptions'; +import styles from './ArtistIndexBanner.css'; + +interface ArtistIndexBannerProps { + artistId: number; + sortKey: string; + bannerWidth: number; + bannerHeight: number; +} + +function ArtistIndexBanner(props: ArtistIndexBannerProps) { + const { artistId, sortKey, bannerWidth, bannerHeight } = props; + + const { + artist, + qualityProfile, + metadataProfile, + isRefreshingArtist, + isSearchingArtist, + } = useSelector(createArtistIndexItemSelector(props.artistId)); + + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + showSearchAction, + } = useSelector(selectBannerOptions); + + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + useSelector(createUISettingsSelector()); + + const { + artistName, + artistType, + monitored, + status, + path, + foreignArtistId, + nextAlbum, + added, + statistics = {} as Statistics, + images, + tags, + } = artist; + + const { + albumCount = 0, + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0, + sizeOnDisk = 0, + } = statistics; + + const dispatch = useDispatch(); + const [hasBannerError, setHasBannerError] = useState(false); + const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); + const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: ARTIST_SEARCH, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onBannerLoadError = useCallback(() => { + setHasBannerError(true); + }, [setHasBannerError]); + + const onBannerLoad = useCallback(() => { + setHasBannerError(false); + }, [setHasBannerError]); + + const onEditArtistPress = useCallback(() => { + setIsEditArtistModalOpen(true); + }, [setIsEditArtistModalOpen]); + + const onEditArtistModalClose = useCallback(() => { + setIsEditArtistModalOpen(false); + }, [setIsEditArtistModalOpen]); + + const onDeleteArtistPress = useCallback(() => { + setIsEditArtistModalOpen(false); + setIsDeleteArtistModalOpen(true); + }, [setIsDeleteArtistModalOpen]); + + const onDeleteArtistModalClose = useCallback(() => { + setIsDeleteArtistModalOpen(false); + }, [setIsDeleteArtistModalOpen]); + + const link = `/artist/${foreignArtistId}`; + + const elementStyle = { + width: `${bannerWidth}px`, + height: `${bannerHeight}px`, + }; + + return ( +
+
+ + + {status === 'ended' ? ( +
+ ) : null} + + + + + {hasBannerError ? ( +
{artistName}
+ ) : null} + +
+ + + + {showTitle ? ( +
+ {artistName} +
+ ) : null} + + {showMonitored ? ( +
+ {monitored ? translate('Monitored') : translate('Unmonitored')} +
+ ) : null} + + {showQualityProfile ? ( +
+ {qualityProfile.name} +
+ ) : null} + + {showNextAlbum && !!nextAlbum?.releaseDate ? ( +
+ {getRelativeDate( + nextAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )} +
+ ) : null} + + + + + + +
+ ); +} + +export default ArtistIndexBanner; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js deleted file mode 100644 index f641de0e1..000000000 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js +++ /dev/null @@ -1,115 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import formatBytes from 'Utilities/Number/formatBytes'; -import styles from './ArtistIndexBannerInfo.css'; - -function ArtistIndexBannerInfo(props) { - const { - qualityProfile, - showQualityProfile, - previousAiring, - added, - albumCount, - path, - sizeOnDisk, - sortKey, - showRelativeDates, - shortDateFormat, - timeFormat - } = props; - - if (sortKey === 'qualityProfileId' && !showQualityProfile) { - return ( -
- {qualityProfile.name} -
- ); - } - - if (sortKey === 'previousAiring' && previousAiring) { - return ( -
- { - getRelativeDate( - previousAiring, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } -
- ); - } - - if (sortKey === 'added' && added) { - const addedDate = getRelativeDate( - added, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: false - } - ); - - return ( -
- {`Added ${addedDate}`} -
- ); - } - - if (sortKey === 'albumCount') { - let albums = '1 album'; - - if (albumCount === 0) { - albums = 'No albums'; - } else if (albumCount > 1) { - albums = `${albumCount} albums`; - } - - return ( -
- {albums} -
- ); - } - - if (sortKey === 'path') { - return ( -
- {path} -
- ); - } - - if (sortKey === 'sizeOnDisk') { - return ( -
- {formatBytes(sizeOnDisk)} -
- ); - } - - return null; -} - -ArtistIndexBannerInfo.propTypes = { - qualityProfile: PropTypes.object.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - previousAiring: PropTypes.string, - added: PropTypes.string, - albumCount: PropTypes.number.isRequired, - path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, - sortKey: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default ArtistIndexBannerInfo; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.tsx new file mode 100644 index 000000000..a93b0bafc --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import Album from 'Album/Album'; +import TagListConnector from 'Components/TagListConnector'; +import MetadataProfile from 'typings/MetadataProfile'; +import QualityProfile from 'typings/QualityProfile'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import styles from './ArtistIndexBannerInfo.css'; + +interface ArtistIndexBannerInfoProps { + artistType?: string; + showQualityProfile: boolean; + qualityProfile?: QualityProfile; + metadataProfile?: MetadataProfile; + showNextAlbum: boolean; + nextAlbum?: Album; + lastAlbum?: Album; + added?: string; + albumCount: number; + path: string; + sizeOnDisk?: number; + tags?: number[]; + sortKey: string; + showRelativeDates: boolean; + shortDateFormat: string; + longDateFormat: string; + timeFormat: string; +} + +function ArtistIndexBannerInfo(props: ArtistIndexBannerInfoProps) { + const { + artistType, + qualityProfile, + metadataProfile, + showQualityProfile, + showNextAlbum, + nextAlbum, + lastAlbum, + added, + albumCount, + path, + sizeOnDisk, + tags, + sortKey, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + } = props; + + if (sortKey === 'artistType' && artistType) { + return ( +
+ {artistType} +
+ ); + } + + if ( + sortKey === 'qualityProfileId' && + !showQualityProfile && + !!qualityProfile?.name + ) { + return ( +
+ {qualityProfile.name} +
+ ); + } + + if (sortKey === 'metadataProfileId' && !!metadataProfile?.name) { + return ( +
+ {metadataProfile.name} +
+ ); + } + + if (sortKey === 'nextAlbum' && !showNextAlbum && !!nextAlbum?.releaseDate) { + return ( +
+ {getRelativeDate( + nextAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )} +
+ ); + } + + if (sortKey === 'lastAlbum' && !!lastAlbum?.releaseDate) { + return ( +
+ {getRelativeDate( + lastAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )} +
+ ); + } + + if (sortKey === 'added' && added) { + const addedDate = getRelativeDate( + added, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: false, + } + ); + + return ( +
+ {translate('Added')}: {addedDate} +
+ ); + } + + if (sortKey === 'albumCount') { + let albums = translate('OneAlbum'); + + if (albumCount === 0) { + albums = translate('NoAlbums'); + } else if (albumCount > 1) { + albums = translate('CountAlbums', { albumCount }); + } + + return
{albums}
; + } + + if (sortKey === 'path') { + return ( +
+ {path} +
+ ); + } + + if (sortKey === 'sizeOnDisk') { + return ( +
+ {formatBytes(sizeOnDisk)} +
+ ); + } + + if (sortKey === 'tags' && tags) { + return ( +
+ +
+ ); + } + + return null; +} + +export default ArtistIndexBannerInfo; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js deleted file mode 100644 index be3cdb502..000000000 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js +++ /dev/null @@ -1,327 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Grid, WindowScroller } from 'react-virtualized'; -import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; -import Measure from 'Components/Measure'; -import dimensions from 'Styles/Variables/dimensions'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import ArtistIndexBanner from './ArtistIndexBanner'; -import styles from './ArtistIndexBanners.css'; - -// container dimensions -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); -const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); - -const additionalColumnCount = { - small: 3, - medium: 2, - large: 1 -}; - -function calculateColumnWidth(width, bannerSize, isSmallScreen) { - const maxiumColumnWidth = isSmallScreen ? 344 : 364; - const columns = Math.floor(width / maxiumColumnWidth); - const remainder = width % maxiumColumnWidth; - - if (remainder === 0 && bannerSize === 'large') { - return maxiumColumnWidth; - } - - return Math.floor(width / (columns + additionalColumnCount[bannerSize])); -} - -function calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions) { - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile - } = bannerOptions; - - const nextAiringHeight = 19; - - const heights = [ - bannerHeight, - detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, - nextAiringHeight, - isSmallScreen ? columnPaddingSmallScreen : columnPadding - ]; - - if (showTitle) { - heights.push(19); - } - - if (showMonitored) { - heights.push(19); - } - - if (showQualityProfile) { - heights.push(19); - } - - switch (sortKey) { - case 'seasons': - case 'previousAiring': - case 'added': - case 'path': - case 'sizeOnDisk': - heights.push(19); - break; - case 'qualityProfileId': - if (!showQualityProfile) { - heights.push(19); - } - break; - default: - // No need to add a height of 0 - } - - return heights.reduce((acc, height) => acc + height, 0); -} - -function calculateHeight(bannerWidth) { - return Math.ceil((88/476) * bannerWidth); -} - -class ArtistIndexBanners extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - width: 0, - columnWidth: 364, - columnCount: 1, - bannerWidth: 476, - bannerHeight: 88, - rowHeight: calculateRowHeight(88, null, props.isSmallScreen, {}), - scrollRestored: false - }; - - this._isInitialized = false; - this._grid = null; - } - - componentDidUpdate(prevProps, prevState) { - const { - items, - sortKey, - bannerOptions, - jumpToCharacter, - scrollTop - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight, - scrollRestored - } = this.state; - - if (prevProps.sortKey !== sortKey || - prevProps.bannerOptions !== bannerOptions) { - this.calculateGrid(); - } - - if (this._grid && - (prevState.width !== width || - prevState.columnWidth !== columnWidth || - prevState.columnCount !== columnCount || - prevState.rowHeight !== rowHeight || - hasDifferentItemsOrOrder(prevProps.items, items))) { - // recomputeGridSize also forces Grid to discard its cache of rendered cells - this._grid.recomputeGridSize(); - } - - if (this._grid && scrollTop !== 0 && !scrollRestored) { - this.setState({ scrollRestored: true }); - this._grid.scrollToPosition({ scrollTop }); - } - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - const index = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (this._grid && index != null) { - const row = Math.floor(index / columnCount); - - this._grid.scrollToCell({ - rowIndex: row, - columnIndex: 0 - }); - } - } - - } - - // - // Control - - setGridRef = (ref) => { - this._grid = ref; - }; - - calculateGrid = (width = this.state.width, isSmallScreen) => { - const { - sortKey, - bannerOptions - } = this.props; - - const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; - const columnWidth = calculateColumnWidth(width, bannerOptions.size, isSmallScreen); - const columnCount = Math.max(Math.floor(width / columnWidth), 1); - const bannerWidth = columnWidth - padding; - const bannerHeight = calculateHeight(bannerWidth); - const rowHeight = calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions); - - this.setState({ - width, - columnWidth, - columnCount, - bannerWidth, - bannerHeight, - rowHeight - }); - }; - - cellRenderer = ({ key, rowIndex, columnIndex, style }) => { - const { - items, - sortKey, - bannerOptions, - showRelativeDates, - shortDateFormat, - timeFormat - } = this.props; - - const { - bannerWidth, - bannerHeight, - columnCount - } = this.state; - - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile - } = bannerOptions; - - const artist = items[rowIndex * columnCount + columnIndex]; - - if (!artist) { - return null; - } - - return ( -
- -
- ); - }; - - // - // Listeners - - onMeasure = ({ width }) => { - this.calculateGrid(width, this.props.isSmallScreen); - }; - - // - // Render - - render() { - const { - items, - isSmallScreen, - scroller - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight - } = this.state; - - const rowCount = Math.ceil(items.length / columnCount); - - return ( - - - {({ height, registerChild, onChildScroll, scrollTop }) => { - if (!height) { - return
; - } - - return ( - - ); - } - } - - - ); - } -} - -ArtistIndexBanners.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - bannerOptions: PropTypes.object.isRequired, - jumpToCharacter: PropTypes.string, - scrollTop: PropTypes.number.isRequired, - scroller: PropTypes.instanceOf(Element).isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default ArtistIndexBanners; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx new file mode 100644 index 000000000..3b8aec5a3 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx @@ -0,0 +1,294 @@ +import { throttle } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window'; +import { createSelector } from 'reselect'; +import Artist from 'Artist/Artist'; +import ArtistIndexBanner from 'Artist/Index/Banners/ArtistIndexBanner'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import SortDirection from 'Helpers/Props/SortDirection'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; + +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.artistIndexColumnPaddingSmallScreen +); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +const ADDITIONAL_COLUMN_COUNT = { + small: 3, + medium: 2, + large: 1, +}; + +interface CellItemData { + layout: { + columnCount: number; + padding: number; + bannerWidth: number; + bannerHeight: number; + }; + items: Artist[]; + sortKey: string; +} + +interface ArtistIndexBannersProps { + items: Artist[]; + sortKey?: string; + sortDirection?: SortDirection; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: React.MutableRefObject; + isSmallScreen: boolean; +} + +const artistIndexSelector = createSelector( + (state) => state.artistIndex.bannerOptions, + (bannerOptions) => { + return { + bannerOptions, + }; + } +); + +const Cell: React.FC> = ({ + columnIndex, + rowIndex, + style, + data, +}) => { + const { layout, items, sortKey } = data; + + const { columnCount, padding, bannerWidth, bannerHeight } = layout; + + const index = rowIndex * columnCount + columnIndex; + + if (index >= items.length) { + return null; + } + + const artist = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +export default function ArtistIndexBanners(props: ArtistIndexBannersProps) { + const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props; + + const { bannerOptions } = useSelector(artistIndexSelector); + const ref: React.MutableRefObject = useRef(); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const columnWidth = useMemo(() => { + const { width } = size; + const maximumColumnWidth = isSmallScreen ? 344 : 364; + const columns = Math.floor(width / maximumColumnWidth); + const remainder = width % maximumColumnWidth; + return remainder === 0 + ? maximumColumnWidth + : Math.floor( + width / (columns + ADDITIONAL_COLUMN_COUNT[bannerOptions.size]) + ); + }, [isSmallScreen, bannerOptions, size]); + + const columnCount = useMemo( + () => Math.max(Math.floor(size.width / columnWidth), 1), + [size, columnWidth] + ); + const padding = props.isSmallScreen + ? columnPaddingSmallScreen + : columnPadding; + const bannerWidth = columnWidth - padding * 2; + const bannerHeight = Math.ceil((88 / 476) * bannerWidth); + + const rowHeight = useMemo(() => { + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + } = bannerOptions; + + const nextAiringHeight = 19; + + const heights = [ + bannerHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding, + ]; + + if (showTitle) { + heights.push(19); + } + + if (showMonitored) { + heights.push(19); + } + + if (showQualityProfile) { + heights.push(19); + } + + if (showNextAlbum) { + heights.push(19); + } + + switch (sortKey) { + case 'artistType': + case 'metadataProfileId': + case 'lastAlbum': + case 'added': + case 'albumCount': + case 'path': + case 'sizeOnDisk': + case 'tags': + heights.push(19); + break; + case 'qualityProfileId': + if (!showQualityProfile) { + heights.push(19); + } + break; + case 'nextAlbum': + if (!showNextAlbum) { + heights.push(19); + } + break; + default: + // No need to add a height of 0 + } + + return heights.reduce((acc, height) => acc + height, 0); + }, [isSmallScreen, bannerOptions, sortKey, bannerHeight]); + + useEffect(() => { + const current = scrollerRef.current; + + if (isSmallScreen) { + const padding = bodyPaddingSmallScreen - 5; + + setSize({ + width: window.innerWidth - padding * 2, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = bodyPadding - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollListener = isSmallScreen ? window : scrollerRef.current; + const currentScrollerRef = scrollerRef.current; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + ref.current.scrollTo({ scrollLeft: 0, scrollTop }); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, ref, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const rowIndex = Math.floor(index / columnCount); + + const scrollTop = rowIndex * rowHeight + padding; + + ref.current.scrollTo({ scrollLeft: 0, scrollTop }); + scrollerRef.current.scrollTo(0, scrollTop); + } + } + }, [ + jumpToCharacter, + rowHeight, + columnCount, + padding, + items, + scrollerRef, + ref, + ]); + + return ( +
+ + ref={ref} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + columnCount={columnCount} + columnWidth={columnWidth} + rowCount={Math.ceil(items.length / columnCount)} + rowHeight={rowHeight} + itemData={{ + layout: { + columnCount, + padding, + bannerWidth, + bannerHeight, + }, + items, + sortKey, + }} + > + {Cell} + +
+ ); +} diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js deleted file mode 100644 index 1cf68ba2b..000000000 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import ArtistIndexBanners from './ArtistIndexBanners'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex.bannerOptions, - createUISettingsSelector(), - createDimensionsSelector(), - (bannerOptions, uiSettings, dimensions) => { - return { - bannerOptions, - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat, - isSmallScreen: dimensions.isSmallScreen - }; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexBanners); diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js deleted file mode 100644 index 34c8abfcf..000000000 --- a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import ArtistIndexBannerOptionsModalContentConnector from './ArtistIndexBannerOptionsModalContentConnector'; - -function ArtistIndexBannerOptionsModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -ArtistIndexBannerOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexBannerOptionsModal; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.tsx b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.tsx new file mode 100644 index 000000000..156e06079 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistIndexBannerOptionsModalContent from './ArtistIndexBannerOptionsModalContent'; + +interface ArtistIndexBannerOptionsModalProps { + isOpen: boolean; + onModalClose(...args: unknown[]): unknown; +} + +function ArtistIndexBannerOptionsModal({ + isOpen, + onModalClose, +}: ArtistIndexBannerOptionsModalProps) { + return ( + + + + ); +} + +export default ArtistIndexBannerOptionsModal; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js deleted file mode 100644 index 8951a7b3d..000000000 --- a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js +++ /dev/null @@ -1,226 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -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 } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -const bannerSizeOptions = [ - { key: 'small', value: 'Small' }, - { key: 'medium', value: 'Medium' }, - { key: 'large', value: 'Large' } -]; - -class ArtistIndexBannerOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - detailedProgressBar: props.detailedProgressBar, - size: props.size, - showTitle: props.showTitle, - showMonitored: props.showMonitored, - showQualityProfile: props.showQualityProfile, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showSearchAction - } = this.props; - - const state = {}; - - if (detailedProgressBar !== prevProps.detailedProgressBar) { - state.detailedProgressBar = detailedProgressBar; - } - - if (size !== prevProps.size) { - state.size = size; - } - - if (showTitle !== prevProps.showTitle) { - state.showTitle = showTitle; - } - - if (showMonitored !== prevProps.showMonitored) { - state.showMonitored = showMonitored; - } - - if (showQualityProfile !== prevProps.showQualityProfile) { - state.showQualityProfile = showQualityProfile; - } - - if (showSearchAction !== prevProps.showSearchAction) { - state.showSearchAction = showSearchAction; - } - - if (!_.isEmpty(state)) { - this.setState(state); - } - } - - // - // Listeners - - onChangeBannerOption = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onChangeBannerOption({ [name]: value }); - }); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showSearchAction - } = this.state; - - return ( - - - Options - - - -
- - - {translate('Size')} - - - - - - - - {translate('DetailedProgressBar')} - - - - - - - - {translate('ShowName')} - - - - - - - - {translate('ShowMonitored')} - - - - - - - - {translate('ShowQualityProfile')} - - - - - - - - {translate('ShowSearch')} - - - - -
-
- - - - -
- ); - } -} - -ArtistIndexBannerOptionsModalContent.propTypes = { - size: PropTypes.string.isRequired, - showTitle: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onChangeBannerOption: PropTypes.func.isRequired, - showMonitored: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexBannerOptionsModalContent; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx new file mode 100644 index 000000000..f75311bca --- /dev/null +++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx @@ -0,0 +1,167 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import selectBannerOptions from 'Artist/Index/Banners/selectBannerOptions'; +import Form from 'Components/Form/Form'; +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 } from 'Helpers/Props'; +import { setArtistBannerOption } from 'Store/Actions/artistIndexActions'; +import translate from 'Utilities/String/translate'; + +const bannerSizeOptions = [ + { + key: 'small', + get value() { + return translate('Small'); + }, + }, + { + key: 'medium', + get value() { + return translate('Medium'); + }, + }, + { + key: 'large', + get value() { + return translate('Large'); + }, + }, +]; + +interface ArtistIndexBannerOptionsModalContentProps { + onModalClose(...args: unknown[]): unknown; +} + +function ArtistIndexBannerOptionsModalContent( + props: ArtistIndexBannerOptionsModalContentProps +) { + const { onModalClose } = props; + + const bannerOptions = useSelector(selectBannerOptions); + + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + showSearchAction, + } = bannerOptions; + + const dispatch = useDispatch(); + + const onBannerOptionChange = useCallback( + ({ name, value }) => { + dispatch(setArtistBannerOption({ [name]: value })); + }, + [dispatch] + ); + + return ( + + {translate('BannerOptions')} + + +
+ + {translate('BannerSize')} + + + + + + {translate('DetailedProgressBar')} + + + + + + {translate('ShowName')} + + + + + + {translate('ShowMonitored')} + + + + + + {translate('ShowQualityProfile')} + + + + + + {translate('ShowNextAlbum')} + + + + + + {translate('ShowSearch')} + + + +
+
+ + + + +
+ ); +} + +export default ArtistIndexBannerOptionsModalContent; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js deleted file mode 100644 index 884edd05d..000000000 --- a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js +++ /dev/null @@ -1,23 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setArtistBannerOption } from 'Store/Actions/artistIndexActions'; -import ArtistIndexBannerOptionsModalContent from './ArtistIndexBannerOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex, - (artistIndex) => { - return artistIndex.bannerOptions; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onChangeBannerOption(payload) { - dispatch(setArtistBannerOption(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexBannerOptionsModalContent); diff --git a/frontend/src/Artist/Index/Banners/selectBannerOptions.ts b/frontend/src/Artist/Index/Banners/selectBannerOptions.ts new file mode 100644 index 000000000..529c15e06 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/selectBannerOptions.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const selectBannerOptions = createSelector( + (state: AppState) => state.artistIndex.bannerOptions, + (bannerOptions) => bannerOptions +); + +export default selectBannerOptions; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx similarity index 76% rename from frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js rename to frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx index d146fdf7d..19be069e5 100644 --- a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js +++ b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import ArtistIndexFilterModalConnector from 'Artist/Index/ArtistIndexFilterModalConnector'; +import ArtistIndexFilterModal from 'Artist/Index/ArtistIndexFilterModal'; import FilterMenu from 'Components/Menu/FilterMenu'; import { align } from 'Helpers/Props'; @@ -10,7 +10,7 @@ function ArtistIndexFilterMenu(props) { filters, customFilters, isDisabled, - onFilterSelect + onFilterSelect, } = props; return ( @@ -20,22 +20,23 @@ function ArtistIndexFilterMenu(props) { selectedFilterKey={selectedFilterKey} filters={filters} customFilters={customFilters} - filterModalConnectorComponent={ArtistIndexFilterModalConnector} + filterModalConnectorComponent={ArtistIndexFilterModal} onFilterSelect={onFilterSelect} /> ); } ArtistIndexFilterMenu.propTypes = { - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, isDisabled: PropTypes.bool.isRequired, - onFilterSelect: PropTypes.func.isRequired + onFilterSelect: PropTypes.func.isRequired, }; ArtistIndexFilterMenu.defaultProps = { - showCustomFilters: false + showCustomFilters: false, }; export default ArtistIndexFilterMenu; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx similarity index 94% rename from frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js rename to frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx index 967b34d49..3d4b8ded0 100644 --- a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js +++ b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx @@ -6,18 +6,10 @@ import SortMenuItem from 'Components/Menu/SortMenuItem'; import { align, sortDirections } from 'Helpers/Props'; function ArtistIndexSortMenu(props) { - const { - sortKey, - sortDirection, - isDisabled, - onSortSelect - } = props; + const { sortKey, sortDirection, isDisabled, onSortSelect } = props; return ( - + + - - Table + + {translate('Table')} - - Posters + + {translate('Posters')} - - Banners + + {translate('Banners')} - Overview + {translate('Overview')} @@ -57,7 +39,7 @@ function ArtistIndexViewMenu(props) { ArtistIndexViewMenu.propTypes = { view: PropTypes.string.isRequired, isDisabled: PropTypes.bool.isRequired, - onViewSelect: PropTypes.func.isRequired + onViewSelect: PropTypes.func.isRequired, }; export default ArtistIndexViewMenu; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css index 3b1888228..1f482a2d6 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css @@ -1,13 +1,5 @@ $hoverScale: 1.05; -.container { - &:hover { - .content { - background-color: var(--tableRowHoverBackgroundColor); - } - } -} - .content { display: flex; flex-grow: 1; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css.d.ts b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css.d.ts index 76a72536a..de94277cc 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css.d.ts +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css.d.ts @@ -2,7 +2,6 @@ // Please do not change this file! interface CssExports { 'actions': string; - 'container': string; 'content': string; 'details': string; 'ended': string; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js deleted file mode 100644 index 1baac838f..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js +++ /dev/null @@ -1,283 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextTruncate from 'react-text-truncate'; -import ArtistPoster from 'Artist/ArtistPoster'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import { icons } from 'Helpers/Props'; -import dimensions from 'Styles/Variables/dimensions'; -import fonts from 'Styles/Variables/fonts'; -import translate from 'Utilities/String/translate'; -import ArtistIndexOverviewInfo from './ArtistIndexOverviewInfo'; -import styles from './ArtistIndexOverview.css'; - -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const defaultFontSize = parseInt(fonts.defaultFontSize); -const lineHeight = parseFloat(fonts.lineHeight); - -// Hardcoded height beased on line-height of 32 + bottom margin of 10. -// Less side-effecty than using react-measure. -const titleRowHeight = 42; - -function getContentHeight(rowHeight, isSmallScreen) { - const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; - - return rowHeight - padding; -} - -class ArtistIndexOverview extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - // - // Listeners - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - artistName, - overview, - monitored, - status, - foreignArtistId, - nextAiring, - statistics, - images, - posterWidth, - posterHeight, - qualityProfile, - overviewOptions, - showSearchAction, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - rowHeight, - isSmallScreen, - isRefreshingArtist, - isSearchingArtist, - onRefreshArtistPress, - onSearchPress, - ...otherProps - } = this.props; - - const { - albumCount, - sizeOnDisk, - trackCount, - trackFileCount, - totalTrackCount - } = statistics; - - const { - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - const link = `/artist/${foreignArtistId}`; - - const elementStyle = { - width: `${posterWidth}px`, - height: `${posterHeight}px` - }; - - const contentHeight = getContentHeight(rowHeight, isSmallScreen); - const overviewHeight = contentHeight - titleRowHeight; - - return ( -
-
-
-
- { - status === 'ended' && -
- } - - - - -
- - -
- -
-
- - {artistName} - - -
- - - { - showSearchAction && - - } - - -
-
- -
- - - - - - -
-
-
- - - - -
- ); - } -} - -ArtistIndexOverview.propTypes = { - id: PropTypes.number.isRequired, - artistName: PropTypes.string.isRequired, - overview: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - nextAiring: PropTypes.string, - statistics: PropTypes.object.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - posterWidth: PropTypes.number.isRequired, - posterHeight: PropTypes.number.isRequired, - rowHeight: PropTypes.number.isRequired, - qualityProfile: PropTypes.object.isRequired, - overviewOptions: PropTypes.object.isRequired, - showSearchAction: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isSearchingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired -}; - -ArtistIndexOverview.defaultProps = { - statistics: { - albumCount: 0, - trackCount: 0, - trackFileCount: 0, - totalTrackCount: 0 - } -}; - -export default ArtistIndexOverview; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx new file mode 100644 index 000000000..afb06c380 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx @@ -0,0 +1,240 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import TextTruncate from 'react-text-truncate'; +import { Statistics } from 'Artist/Artist'; +import ArtistPoster from 'Artist/ArtistPoster'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import dimensions from 'Styles/Variables/dimensions'; +import fonts from 'Styles/Variables/fonts'; +import translate from 'Utilities/String/translate'; +import createArtistIndexItemSelector from '../createArtistIndexItemSelector'; +import ArtistIndexOverviewInfo from './ArtistIndexOverviewInfo'; +import selectOverviewOptions from './selectOverviewOptions'; +import styles from './ArtistIndexOverview.css'; + +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.artistIndexColumnPaddingSmallScreen +); +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +// Hardcoded height based on line-height of 32 + bottom margin of 10. +// Less side-effecty than using react-measure. +const TITLE_HEIGHT = 42; + +interface ArtistIndexOverviewProps { + artistId: number; + sortKey: string; + posterWidth: number; + posterHeight: number; + rowHeight: number; + isSmallScreen: boolean; +} + +function ArtistIndexOverview(props: ArtistIndexOverviewProps) { + const { + artistId, + sortKey, + posterWidth, + posterHeight, + rowHeight, + isSmallScreen, + } = props; + + const { artist, qualityProfile, isRefreshingArtist, isSearchingArtist } = + useSelector(createArtistIndexItemSelector(props.artistId)); + + const overviewOptions = useSelector(selectOverviewOptions); + + const { + artistName, + monitored, + status, + path, + foreignArtistId, + nextAlbum, + lastAlbum, + added, + overview, + statistics = {} as Statistics, + images, + } = artist; + + const { + albumCount = 0, + sizeOnDisk = 0, + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0, + } = statistics; + + const dispatch = useDispatch(); + const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); + const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: ARTIST_SEARCH, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onEditArtistPress = useCallback(() => { + setIsEditArtistModalOpen(true); + }, [setIsEditArtistModalOpen]); + + const onEditArtistModalClose = useCallback(() => { + setIsEditArtistModalOpen(false); + }, [setIsEditArtistModalOpen]); + + const onDeleteArtistPress = useCallback(() => { + setIsEditArtistModalOpen(false); + setIsDeleteArtistModalOpen(true); + }, [setIsDeleteArtistModalOpen]); + + const onDeleteArtistModalClose = useCallback(() => { + setIsDeleteArtistModalOpen(false); + }, [setIsDeleteArtistModalOpen]); + + const link = `/artist/${foreignArtistId}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px`, + }; + + const contentHeight = useMemo(() => { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; + }, [rowHeight, isSmallScreen]); + + const overviewHeight = contentHeight - TITLE_HEIGHT; + + return ( +
+
+
+
+ {status === 'ended' && ( +
+ )} + + + + +
+ + +
+ +
+
+ + {artistName} + + +
+ + + {overviewOptions.showSearchAction ? ( + + ) : null} + + +
+
+ +
+ + + + + +
+
+
+ + + + +
+ ); +} + +export default ArtistIndexOverview; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js deleted file mode 100644 index f7cda7916..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js +++ /dev/null @@ -1,249 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { icons } from 'Helpers/Props'; -import dimensions from 'Styles/Variables/dimensions'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import formatBytes from 'Utilities/Number/formatBytes'; -import ArtistIndexOverviewInfoRow from './ArtistIndexOverviewInfoRow'; -import styles from './ArtistIndexOverviewInfo.css'; - -const infoRowHeight = parseInt(dimensions.artistIndexOverviewInfoRowHeight); - -const rows = [ - { - name: 'monitored', - showProp: 'showMonitored', - valueProp: 'monitored' - }, - { - name: 'qualityProfileId', - showProp: 'showQualityProfile', - valueProp: 'qualityProfileId' - }, - { - name: 'lastAlbum', - showProp: 'showLastAlbum', - valueProp: 'lastAlbum' - }, - { - name: 'added', - showProp: 'showAdded', - valueProp: 'added' - }, - { - name: 'albumCount', - showProp: 'showAlbumCount', - valueProp: 'albumCount' - }, - { - name: 'path', - showProp: 'showPath', - valueProp: 'path' - }, - { - name: 'sizeOnDisk', - showProp: 'showSizeOnDisk', - valueProp: 'sizeOnDisk' - } -]; - -function isVisible(row, props) { - const { - name, - showProp, - valueProp - } = row; - - if (props[valueProp] == null) { - return false; - } - - return props[showProp] || props.sortKey === name; -} - -function getInfoRowProps(row, props) { - const { name } = row; - - if (name === 'monitored') { - const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored'; - - return { - title: monitoredText, - iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED, - label: monitoredText - }; - } - - if (name === 'qualityProfileId') { - return { - title: 'Quality Profile', - iconName: icons.PROFILE, - label: props.qualityProfile.name - }; - } - - if (name === 'lastAlbum') { - const { - lastAlbum, - showRelativeDates, - shortDateFormat, - timeFormat - } = props; - - return { - title: `Last Album: ${lastAlbum.title}`, - iconName: icons.CALENDAR, - label: getRelativeDate( - lastAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - }; - } - - if (name === 'added') { - const { - added, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat - } = props; - - return { - title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, - iconName: icons.ADD, - label: getRelativeDate( - added, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - }; - } - - if (name === 'albumCount') { - const { albumCount } = props; - let albums = '1 album'; - - if (albumCount === 0) { - albums = 'No albums'; - } else if (albumCount > 1) { - albums = `${albumCount} albums`; - } - - return { - title: 'Album Count', - iconName: icons.CIRCLE, - label: albums - }; - } - - if (name === 'path') { - return { - title: 'Path', - iconName: icons.FOLDER, - label: props.path - }; - } - - if (name === 'sizeOnDisk') { - return { - title: 'Size on Disk', - iconName: icons.DRIVE, - label: formatBytes(props.sizeOnDisk) - }; - } -} - -function ArtistIndexOverviewInfo(props) { - const { - height, - nextAiring, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat - } = props; - - let shownRows = 1; - - const maxRows = Math.floor(height / (infoRowHeight + 4)); - - return ( -
- { - !!nextAiring && - - } - - { - rows.map((row) => { - if (!isVisible(row, props)) { - return null; - } - - if (shownRows >= maxRows) { - return null; - } - - shownRows++; - - const infoRowProps = getInfoRowProps(row, props); - - return ( - - ); - }) - } -
- ); -} - -ArtistIndexOverviewInfo.propTypes = { - height: PropTypes.number.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showAdded: PropTypes.bool.isRequired, - showAlbumCount: PropTypes.bool.isRequired, - showPath: PropTypes.bool.isRequired, - showSizeOnDisk: PropTypes.bool.isRequired, - monitored: PropTypes.bool.isRequired, - nextAiring: PropTypes.string, - qualityProfile: PropTypes.object.isRequired, - lastAlbum: PropTypes.object, - added: PropTypes.string, - albumCount: PropTypes.number.isRequired, - path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, - sortKey: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default ArtistIndexOverviewInfo; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx new file mode 100644 index 000000000..c0c62ba84 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx @@ -0,0 +1,228 @@ +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import Album from 'Album/Album'; +import { icons } from 'Helpers/Props'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import dimensions from 'Styles/Variables/dimensions'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import ArtistIndexOverviewInfoRow from './ArtistIndexOverviewInfoRow'; +import styles from './ArtistIndexOverviewInfo.css'; + +const infoRowHeight = parseInt(dimensions.artistIndexOverviewInfoRowHeight); + +const rows = [ + { + name: 'monitored', + showProp: 'showMonitored', + valueProp: 'monitored', + }, + { + name: 'qualityProfileId', + showProp: 'showQualityProfile', + valueProp: 'qualityProfileId', + }, + { + name: 'lastAlbum', + showProp: 'showLastAlbum', + valueProp: 'lastAlbum', + }, + { + name: 'added', + showProp: 'showAdded', + valueProp: 'added', + }, + { + name: 'albumCount', + showProp: 'showAlbumCount', + valueProp: 'albumCount', + }, + { + name: 'path', + showProp: 'showPath', + valueProp: 'path', + }, + { + name: 'sizeOnDisk', + showProp: 'showSizeOnDisk', + valueProp: 'sizeOnDisk', + }, +]; + +function getInfoRowProps(row, props, uiSettings) { + const { name } = row; + + if (name === 'monitored') { + const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored'; + + return { + title: monitoredText, + iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED, + label: monitoredText, + }; + } + + if (name === 'qualityProfileId') { + return { + title: 'Quality Profile', + iconName: icons.PROFILE, + label: props.qualityProfile.name, + }; + } + + if (name === 'lastAlbum' && !!props.lastAlbum?.title) { + const lastAlbum = props.lastAlbum; + const { showRelativeDates, shortDateFormat, timeFormat } = uiSettings; + + return { + title: `Last Album: ${lastAlbum.title}`, + iconName: icons.CALENDAR, + label: getRelativeDate( + lastAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + ), + }; + } + + if (name === 'added') { + const added = props.added; + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + uiSettings; + + return { + title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, + iconName: icons.ADD, + label: getRelativeDate(added, shortDateFormat, showRelativeDates, { + timeFormat, + timeForToday: true, + }), + }; + } + + if (name === 'albumCount') { + const { albumCount } = props; + let albums = '1 album'; + + if (albumCount === 0) { + albums = 'No albums'; + } else if (albumCount > 1) { + albums = `${albumCount} albums`; + } + + return { + title: 'Album Count', + iconName: icons.CIRCLE, + label: albums, + }; + } + + if (name === 'path') { + return { + title: 'Path', + iconName: icons.FOLDER, + label: props.path, + }; + } + + if (name === 'sizeOnDisk') { + return { + title: 'Size on Disk', + iconName: icons.DRIVE, + label: formatBytes(props.sizeOnDisk), + }; + } +} + +interface ArtistIndexOverviewInfoProps { + height: number; + showMonitored: boolean; + showQualityProfile: boolean; + showLastAlbum: boolean; + showAdded: boolean; + showAlbumCount: boolean; + showPath: boolean; + showSizeOnDisk: boolean; + monitored: boolean; + nextAlbum?: Album; + qualityProfile: object; + lastAlbum?: Album; + added?: string; + albumCount: number; + path: string; + sizeOnDisk?: number; + sortKey: string; +} + +function ArtistIndexOverviewInfo(props: ArtistIndexOverviewInfoProps) { + const { height, nextAlbum } = props; + + const uiSettings = useSelector(createUISettingsSelector()); + + const { shortDateFormat, showRelativeDates, longDateFormat, timeFormat } = + uiSettings; + + let shownRows = 1; + const maxRows = Math.floor(height / (infoRowHeight + 4)); + + const rowInfo = useMemo(() => { + return rows.map((row) => { + const { name, showProp, valueProp } = row; + + const isVisible = + props[valueProp] != null && (props[showProp] || props.sortKey === name); + + return { + ...row, + isVisible, + }; + }); + }, [props]); + + return ( +
+ {!!nextAlbum?.releaseDate && ( + + )} + + {rowInfo.map((row) => { + if (!row.isVisible) { + return null; + } + + if (shownRows >= maxRows) { + return null; + } + + shownRows++; + + const infoRowProps = getInfoRowProps(row, props, uiSettings); + + return ; + })} +
+ ); +} + +export default ArtistIndexOverviewInfo; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js deleted file mode 100644 index b04029b88..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js +++ /dev/null @@ -1,35 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import styles from './ArtistIndexOverviewInfoRow.css'; - -function ArtistIndexOverviewInfoRow(props) { - const { - title, - iconName, - label - } = props; - - return ( -
- - - {label} -
- ); -} - -ArtistIndexOverviewInfoRow.propTypes = { - title: PropTypes.string, - iconName: PropTypes.object.isRequired, - label: PropTypes.string.isRequired -}; - -export default ArtistIndexOverviewInfoRow; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx new file mode 100644 index 000000000..931d7053c --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './ArtistIndexOverviewInfoRow.css'; + +interface ArtistIndexOverviewInfoRowProps { + title?: string; + iconName: object; + label: string; +} + +function ArtistIndexOverviewInfoRow(props: ArtistIndexOverviewInfoRowProps) { + const { title, iconName, label } = props; + + return ( +
+ + + {label} +
+ ); +} + +export default ArtistIndexOverviewInfoRow; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js deleted file mode 100644 index 101092170..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js +++ /dev/null @@ -1,275 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Grid, WindowScroller } from 'react-virtualized'; -import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; -import Measure from 'Components/Measure'; -import dimensions from 'Styles/Variables/dimensions'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import ArtistIndexOverview from './ArtistIndexOverview'; -import styles from './ArtistIndexOverviews.css'; - -// Poster container dimensions -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); -const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); - -function calculatePosterWidth(posterSize, isSmallScreen) { - const maxiumPosterWidth = isSmallScreen ? 192 : 202; - - if (posterSize === 'large') { - return maxiumPosterWidth; - } - - if (posterSize === 'medium') { - return Math.floor(maxiumPosterWidth * 0.75); - } - - return Math.floor(maxiumPosterWidth * 0.5); -} - -function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) { - const { - detailedProgressBar - } = overviewOptions; - - const heights = [ - posterHeight, - detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, - isSmallScreen ? columnPaddingSmallScreen : columnPadding - ]; - - return heights.reduce((acc, height) => acc + height, 0); -} - -function calculatePosterHeight(posterWidth) { - return posterWidth; -} - -class ArtistIndexOverviews extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - width: 0, - columnCount: 1, - posterWidth: 238, - posterHeight: 238, - rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}), - scrollRestored: false - }; - - this._grid = null; - } - - componentDidUpdate(prevProps, prevState) { - const { - items, - sortKey, - overviewOptions, - jumpToCharacter, - scrollTop, - isSmallScreen - } = this.props; - - const { - width, - rowHeight, - scrollRestored - } = this.state; - - if (prevProps.sortKey !== sortKey || - prevProps.overviewOptions !== overviewOptions) { - this.calculateGrid(this.state.width, isSmallScreen); - } - - if ( - this._grid && - (prevState.width !== width || - prevState.rowHeight !== rowHeight || - hasDifferentItemsOrOrder(prevProps.items, items) || - prevProps.overviewOptions !== overviewOptions - ) - ) { - // recomputeGridSize also forces Grid to discard its cache of rendered cells - this._grid.recomputeGridSize(); - } - - if (this._grid && scrollTop !== 0 && !scrollRestored) { - this.setState({ scrollRestored: true }); - this._grid.scrollToPosition({ scrollTop }); - } - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - const index = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (this._grid && index != null) { - - this._grid.scrollToCell({ - rowIndex: index, - columnIndex: 0 - }); - } - } - } - - // - // Control - - setGridRef = (ref) => { - this._grid = ref; - }; - - calculateGrid = (width = this.state.width, isSmallScreen) => { - const { - sortKey, - overviewOptions - } = this.props; - - const posterWidth = calculatePosterWidth(overviewOptions.size, isSmallScreen); - const posterHeight = calculatePosterHeight(posterWidth); - const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions); - - this.setState({ - width, - posterWidth, - posterHeight, - rowHeight - }); - }; - - cellRenderer = ({ key, rowIndex, style }) => { - const { - items, - sortKey, - overviewOptions, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - isSmallScreen - } = this.props; - - const { - posterWidth, - posterHeight, - rowHeight - } = this.state; - - const artist = items[rowIndex]; - - if (!artist) { - return null; - } - - return ( -
- -
- ); - }; - - // - // Listeners - - onMeasure = ({ width }) => { - this.calculateGrid(width, this.props.isSmallScreen); - }; - - // - // Render - - render() { - const { - items, - isSmallScreen, - scroller - } = this.props; - - const { - width, - rowHeight - } = this.state; - - return ( - - - {({ height, registerChild, onChildScroll, scrollTop }) => { - if (!height) { - return
; - } - - return ( -
- -
- ); - } - } - - - ); - } -} - -ArtistIndexOverviews.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - overviewOptions: PropTypes.object.isRequired, - scrollTop: PropTypes.number.isRequired, - jumpToCharacter: PropTypes.string, - scroller: PropTypes.instanceOf(Element).isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default ArtistIndexOverviews; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx new file mode 100644 index 000000000..acb56fe23 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx @@ -0,0 +1,203 @@ +import { throttle } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; +import Artist from 'Artist/Artist'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import ArtistIndexOverview from './ArtistIndexOverview'; +import selectOverviewOptions from './selectOverviewOptions'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.artistIndexColumnPaddingSmallScreen +); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); + +interface RowItemData { + items: Artist[]; + sortKey: string; + posterWidth: number; + posterHeight: number; + rowHeight: number; + isSmallScreen: boolean; +} + +interface ArtistIndexOverviewsProps { + items: Artist[]; + sortKey?: string; + sortDirection?: string; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: React.MutableRefObject; + isSmallScreen: boolean; +} + +const Row: React.FC> = ({ + index, + style, + data, +}) => { + const { items, ...otherData } = data; + + if (index >= items.length) { + return null; + } + + const artist = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +function ArtistIndexOverviews(props: ArtistIndexOverviewsProps) { + const { items, sortKey, jumpToCharacter, isSmallScreen, scrollerRef } = props; + + const { size: posterSize, detailedProgressBar } = useSelector( + selectOverviewOptions + ); + const listRef: React.MutableRefObject = useRef(); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const posterWidth = useMemo(() => { + const maxiumPosterWidth = isSmallScreen ? 192 : 202; + + if (posterSize === 'large') { + return maxiumPosterWidth; + } + + if (posterSize === 'medium') { + return Math.floor(maxiumPosterWidth * 0.75); + } + + return Math.floor(maxiumPosterWidth * 0.5); + }, [posterSize, isSmallScreen]); + + const posterHeight = useMemo(() => { + return posterWidth; + }, [posterWidth]); + + const rowHeight = useMemo(() => { + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding, + ]; + + return heights.reduce((acc, height) => acc + height, 0); + }, [detailedProgressBar, posterHeight, isSmallScreen]); + + useEffect(() => { + const current = scrollerRef.current as HTMLElement; + + if (isSmallScreen) { + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = + (isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollListener = isSmallScreen ? window : scrollerRef.current; + const currentScrollerRef = scrollerRef.current; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + listRef.current.scrollTo(scrollTop); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, listRef, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + let scrollTop = index * rowHeight; + + // If the offset is zero go to the top, otherwise offset + // by the approximate size of the header + padding (37 + 20). + if (scrollTop > 0) { + const offset = 57; + + scrollTop += offset; + } + + listRef.current.scrollTo(scrollTop); + scrollerRef.current.scrollTo(0, scrollTop); + } + } + }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); + + return ( +
+ + ref={listRef} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + itemCount={items.length} + itemSize={rowHeight} + itemData={{ + items, + sortKey, + posterWidth, + posterHeight, + rowHeight, + isSmallScreen, + }} + > + {Row} + +
+ ); +} + +export default ArtistIndexOverviews; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js deleted file mode 100644 index 030e8999b..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import ArtistIndexOverviews from './ArtistIndexOverviews'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex.overviewOptions, - createUISettingsSelector(), - createDimensionsSelector(), - (overviewOptions, uiSettings, dimensions) => { - return { - overviewOptions, - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat, - isSmallScreen: dimensions.isSmallScreen - }; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexOverviews); diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js deleted file mode 100644 index 9ca575185..000000000 --- a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import ArtistIndexOverviewOptionsModalContentConnector from './ArtistIndexOverviewOptionsModalContentConnector'; - -function ArtistIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -ArtistIndexOverviewOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexOverviewOptionsModal; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.tsx b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.tsx new file mode 100644 index 000000000..bc999cee4 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistIndexOverviewOptionsModalContent from './ArtistIndexOverviewOptionsModalContent'; + +interface ArtistIndexOverviewOptionsModalProps { + isOpen: boolean; + onModalClose(...args: unknown[]): void; +} + +function ArtistIndexOverviewOptionsModal({ + isOpen, + onModalClose, + ...otherProps +}: ArtistIndexOverviewOptionsModalProps) { + return ( + + + + ); +} + +export default ArtistIndexOverviewOptionsModal; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js deleted file mode 100644 index 226f46a1b..000000000 --- a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js +++ /dev/null @@ -1,308 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -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 } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -const posterSizeOptions = [ - { key: 'small', value: 'Small' }, - { key: 'medium', value: 'Medium' }, - { key: 'large', value: 'Large' } -]; - -class ArtistIndexOverviewOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - detailedProgressBar: props.detailedProgressBar, - size: props.size, - showMonitored: props.showMonitored, - showQualityProfile: props.showQualityProfile, - showLastAlbum: props.showLastAlbum, - showAdded: props.showAdded, - showAlbumCount: props.showAlbumCount, - showPath: props.showPath, - showSizeOnDisk: props.showSizeOnDisk, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - detailedProgressBar, - size, - showMonitored, - showQualityProfile, - showLastAlbum, - showAdded, - showAlbumCount, - showPath, - showSizeOnDisk, - showSearchAction - } = this.props; - - const state = {}; - - if (detailedProgressBar !== prevProps.detailedProgressBar) { - state.detailedProgressBar = detailedProgressBar; - } - - if (size !== prevProps.size) { - state.size = size; - } - - if (showMonitored !== prevProps.showMonitored) { - state.showMonitored = showMonitored; - } - - if (showQualityProfile !== prevProps.showQualityProfile) { - state.showQualityProfile = showQualityProfile; - } - - if (showLastAlbum !== prevProps.showLastAlbum) { - state.showLastAlbum = showLastAlbum; - } - - if (showAdded !== prevProps.showAdded) { - state.showAdded = showAdded; - } - - if (showAlbumCount !== prevProps.showAlbumCount) { - state.showAlbumCount = showAlbumCount; - } - - if (showPath !== prevProps.showPath) { - state.showPath = showPath; - } - - if (showSizeOnDisk !== prevProps.showSizeOnDisk) { - state.showSizeOnDisk = showSizeOnDisk; - } - - if (showSearchAction !== prevProps.showSearchAction) { - state.showSearchAction = showSearchAction; - } - - if (!_.isEmpty(state)) { - this.setState(state); - } - } - - // - // Listeners - - onChangeOverviewOption = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onChangeOverviewOption({ [name]: value }); - }); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - detailedProgressBar, - size, - showMonitored, - showQualityProfile, - showLastAlbum, - showAdded, - showAlbumCount, - showPath, - showSizeOnDisk, - showSearchAction - } = this.state; - - return ( - - - Overview Options - - - -
- - - {translate('PosterSize')} - - - - - - - - {translate('DetailedProgressBar')} - - - - - - - - {translate('ShowMonitored')} - - - - - - - - - {translate('ShowQualityProfile')} - - - - - - - - {translate('ShowLastAlbum')} - - - - - - - - {translate('ShowDateAdded')} - - - - - - - - {translate('ShowAlbumCount')} - - - - - - - - {translate('ShowPath')} - - - - - - - - {translate('ShowSizeOnDisk')} - - - - - - - - {translate('ShowSearch')} - - - - -
-
- - - - -
- ); - } -} - -ArtistIndexOverviewOptionsModalContent.propTypes = { - size: PropTypes.string.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showLastAlbum: PropTypes.bool.isRequired, - showAdded: PropTypes.bool.isRequired, - showAlbumCount: PropTypes.bool.isRequired, - showPath: PropTypes.bool.isRequired, - showSizeOnDisk: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onChangeOverviewOption: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexOverviewOptionsModalContent; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx new file mode 100644 index 000000000..e19692e41 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx @@ -0,0 +1,197 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Form from 'Components/Form/Form'; +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 } from 'Helpers/Props'; +import { setArtistOverviewOption } from 'Store/Actions/artistIndexActions'; +import translate from 'Utilities/String/translate'; +import selectOverviewOptions from '../selectOverviewOptions'; + +const posterSizeOptions = [ + { + key: 'small', + get value() { + return translate('Small'); + }, + }, + { + key: 'medium', + get value() { + return translate('Medium'); + }, + }, + { + key: 'large', + get value() { + return translate('Large'); + }, + }, +]; + +interface ArtistIndexOverviewOptionsModalContentProps { + onModalClose(...args: unknown[]): void; +} + +function ArtistIndexOverviewOptionsModalContent( + props: ArtistIndexOverviewOptionsModalContentProps +) { + const { onModalClose } = props; + + const { + detailedProgressBar, + size, + showMonitored, + showQualityProfile, + showLastAlbum, + showAdded, + showAlbumCount, + showPath, + showSizeOnDisk, + showSearchAction, + } = useSelector(selectOverviewOptions); + + const dispatch = useDispatch(); + + const onOverviewOptionChange = useCallback( + ({ name, value }) => { + dispatch(setArtistOverviewOption({ [name]: value })); + }, + [dispatch] + ); + + return ( + + {translate('OverviewOptions')} + + +
+ + {translate('PosterSize')} + + + + + + {translate('DetailedProgressBar')} + + + + + + {translate('ShowMonitored')} + + + + + + {translate('ShowQualityProfile')} + + + + + + {translate('ShowLastAlbum')} + + + + + + {translate('ShowDateAdded')} + + + + + + {translate('ShowAlbumCount')} + + + + + + {translate('ShowPath')} + + + + + + {translate('ShowSizeOnDisk')} + + + + + + {translate('ShowSearch')} + + + +
+
+ + + + +
+ ); +} + +export default ArtistIndexOverviewOptionsModalContent; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js deleted file mode 100644 index 70c30dba6..000000000 --- a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js +++ /dev/null @@ -1,23 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setArtistOverviewOption } from 'Store/Actions/artistIndexActions'; -import ArtistIndexOverviewOptionsModalContent from './ArtistIndexOverviewOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex, - (artistIndex) => { - return artistIndex.overviewOptions; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onChangeOverviewOption(payload) { - dispatch(setArtistOverviewOption(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexOverviewOptionsModalContent); diff --git a/frontend/src/Artist/Index/Overview/selectOverviewOptions.ts b/frontend/src/Artist/Index/Overview/selectOverviewOptions.ts new file mode 100644 index 000000000..5875163c8 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/selectOverviewOptions.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const selectOverviewOptions = createSelector( + (state: AppState) => state.artistIndex.overviewOptions, + (overviewOptions) => overviewOptions +); + +export default selectOverviewOptions; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js deleted file mode 100644 index 455736ff1..000000000 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js +++ /dev/null @@ -1,305 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ArtistPoster from 'Artist/ArtistPoster'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; -import Label from 'Components/Label'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import { icons } from 'Helpers/Props'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import translate from 'Utilities/String/translate'; -import ArtistIndexPosterInfo from './ArtistIndexPosterInfo'; -import styles from './ArtistIndexPoster.css'; - -class ArtistIndexPoster extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - hasPosterError: false, - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - // - // Listeners - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - onPosterLoad = () => { - if (this.state.hasPosterError) { - this.setState({ hasPosterError: false }); - } - }; - - onPosterLoadError = () => { - if (!this.state.hasPosterError) { - this.setState({ hasPosterError: true }); - } - }; - - // - // Render - - render() { - const { - id, - artistName, - monitored, - foreignArtistId, - status, - nextAlbum, - lastAlbum, - statistics, - images, - posterWidth, - posterHeight, - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - qualityProfile, - showNextAlbum, - showSearchAction, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - isRefreshingArtist, - isSearchingArtist, - onRefreshArtistPress, - onSearchPress, - ...otherProps - } = this.props; - - const { - albumCount, - sizeOnDisk, - trackCount, - trackFileCount, - totalTrackCount - } = statistics; - - const { - hasPosterError, - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - const link = `/artist/${foreignArtistId}`; - - const elementStyle = { - width: `${posterWidth}px`, - height: `${posterHeight}px` - }; - - return ( -
-
-
- - - { - status === 'ended' && -
- } - - - - - { - hasPosterError && -
- {artistName} -
- } - - -
- - - - { - showTitle && -
- {artistName} -
- } - - { - showMonitored && -
- {monitored ? 'Monitored' : 'Unmonitored'} -
- } - - { - showQualityProfile && -
- {qualityProfile.name} -
- } - - { - showNextAlbum && !!nextAlbum?.releaseDate && -
- { - getRelativeDate( - nextAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } -
- } - - - - - -
-
- ); - } -} - -ArtistIndexPoster.propTypes = { - id: PropTypes.number.isRequired, - artistName: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - nextAlbum: PropTypes.object, - lastAlbum: PropTypes.object, - statistics: PropTypes.object.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - posterWidth: PropTypes.number.isRequired, - posterHeight: PropTypes.number.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showTitle: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - qualityProfile: PropTypes.object.isRequired, - showNextAlbum: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isSearchingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired -}; - -ArtistIndexPoster.defaultProps = { - statistics: { - albumCount: 0, - trackCount: 0, - trackFileCount: 0, - totalTrackCount: 0 - } -}; - -export default ArtistIndexPoster; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx new file mode 100644 index 000000000..46c637ab4 --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx @@ -0,0 +1,257 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Statistics } from 'Artist/Artist'; +import ArtistPoster from 'Artist/ArtistPoster'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector'; +import ArtistIndexPosterInfo from 'Artist/Index/Posters/ArtistIndexPosterInfo'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import translate from 'Utilities/String/translate'; +import selectPosterOptions from './selectPosterOptions'; +import styles from './ArtistIndexPoster.css'; + +interface ArtistIndexPosterProps { + artistId: number; + sortKey: string; + posterWidth: number; + posterHeight: number; +} + +function ArtistIndexPoster(props: ArtistIndexPosterProps) { + const { artistId, sortKey, posterWidth, posterHeight } = props; + + const { + artist, + qualityProfile, + metadataProfile, + isRefreshingArtist, + isSearchingArtist, + } = useSelector(createArtistIndexItemSelector(props.artistId)); + + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + showSearchAction, + } = useSelector(selectPosterOptions); + + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + useSelector(createUISettingsSelector()); + + const { + artistName, + artistType, + monitored, + status, + path, + foreignArtistId, + nextAlbum, + added, + statistics = {} as Statistics, + images, + tags, + } = artist; + + const { + albumCount = 0, + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0, + sizeOnDisk = 0, + } = statistics; + + const dispatch = useDispatch(); + const [hasPosterError, setHasPosterError] = useState(false); + const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); + const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: ARTIST_SEARCH, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onPosterLoadError = useCallback(() => { + setHasPosterError(true); + }, [setHasPosterError]); + + const onPosterLoad = useCallback(() => { + setHasPosterError(false); + }, [setHasPosterError]); + + const onEditArtistPress = useCallback(() => { + setIsEditArtistModalOpen(true); + }, [setIsEditArtistModalOpen]); + + const onEditArtistModalClose = useCallback(() => { + setIsEditArtistModalOpen(false); + }, [setIsEditArtistModalOpen]); + + const onDeleteArtistPress = useCallback(() => { + setIsEditArtistModalOpen(false); + setIsDeleteArtistModalOpen(true); + }, [setIsDeleteArtistModalOpen]); + + const onDeleteArtistModalClose = useCallback(() => { + setIsDeleteArtistModalOpen(false); + }, [setIsDeleteArtistModalOpen]); + + const link = `/artist/${foreignArtistId}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px`, + }; + + return ( +
+
+ + + {status === 'ended' ? ( +
+ ) : null} + + + + + {hasPosterError ? ( +
{artistName}
+ ) : null} + +
+ + + + {showTitle ? ( +
+ {artistName} +
+ ) : null} + + {showMonitored ? ( +
+ {monitored ? translate('Monitored') : translate('Unmonitored')} +
+ ) : null} + + {showQualityProfile ? ( +
+ {qualityProfile.name} +
+ ) : null} + + {showNextAlbum && !!nextAlbum?.releaseDate ? ( +
+ {getRelativeDate( + nextAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )} +
+ ) : null} + + + + + + +
+ ); +} + +export default ArtistIndexPoster; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.tsx similarity index 63% rename from frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js rename to frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.tsx index 20a34bffd..0d4ff9135 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.tsx @@ -1,16 +1,39 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import Album from 'Album/Album'; import TagListConnector from 'Components/TagListConnector'; +import MetadataProfile from 'typings/MetadataProfile'; +import QualityProfile from 'typings/QualityProfile'; import formatDateTime from 'Utilities/Date/formatDateTime'; import getRelativeDate from 'Utilities/Date/getRelativeDate'; import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; import styles from './ArtistIndexPosterInfo.css'; -function ArtistIndexPosterInfo(props) { +interface ArtistIndexPosterInfoProps { + artistType?: string; + showQualityProfile: boolean; + qualityProfile?: QualityProfile; + metadataProfile?: MetadataProfile; + showNextAlbum: boolean; + nextAlbum?: Album; + lastAlbum?: Album; + added?: string; + albumCount: number; + path: string; + sizeOnDisk?: number; + tags?: number[]; + sortKey: string; + showRelativeDates: boolean; + shortDateFormat: string; + longDateFormat: string; + timeFormat: string; +} + +function ArtistIndexPosterInfo(props: ArtistIndexPosterInfoProps) { const { artistType, qualityProfile, + metadataProfile, showQualityProfile, showNextAlbum, nextAlbum, @@ -24,7 +47,7 @@ function ArtistIndexPosterInfo(props) { showRelativeDates, shortDateFormat, longDateFormat, - timeFormat + timeFormat, } = props; if (sortKey === 'artistType' && artistType) { @@ -35,7 +58,11 @@ function ArtistIndexPosterInfo(props) { ); } - if (sortKey === 'qualityProfileId' && !showQualityProfile) { + if ( + sortKey === 'qualityProfileId' && + !showQualityProfile && + !!qualityProfile?.name + ) { return (
{qualityProfile.name} @@ -43,6 +70,14 @@ function ArtistIndexPosterInfo(props) { ); } + if (sortKey === 'metadataProfileId' && !!metadataProfile?.name) { + return ( +
+ {metadataProfile.name} +
+ ); + } + if (sortKey === 'nextAlbum' && !showNextAlbum && !!nextAlbum?.releaseDate) { return (
- { - getRelativeDate( - nextAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } + {getRelativeDate( + nextAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )}
); } @@ -78,17 +111,15 @@ function ArtistIndexPosterInfo(props) { timeFormat )}`} > - { - getRelativeDate( - lastAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } + {getRelativeDate( + lastAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )}
); } @@ -100,7 +131,7 @@ function ArtistIndexPosterInfo(props) { showRelativeDates, { timeFormat, - timeForToday: false + timeForToday: false, } ); @@ -123,11 +154,7 @@ function ArtistIndexPosterInfo(props) { albums = translate('CountAlbums', { albumCount }); } - return ( -
- {albums} -
- ); + return
{albums}
; } if (sortKey === 'path') { @@ -146,12 +173,10 @@ function ArtistIndexPosterInfo(props) { ); } - if (sortKey === 'tags') { + if (sortKey === 'tags' && tags) { return (
- +
); } @@ -159,23 +184,4 @@ function ArtistIndexPosterInfo(props) { return null; } -ArtistIndexPosterInfo.propTypes = { - artistType: PropTypes.string, - qualityProfile: PropTypes.object.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showNextAlbum: PropTypes.bool.isRequired, - nextAlbum: PropTypes.object, - lastAlbum: PropTypes.object, - added: PropTypes.string, - albumCount: PropTypes.number.isRequired, - path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, - tags: PropTypes.arrayOf(PropTypes.number).isRequired, - sortKey: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - export default ArtistIndexPosterInfo; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js deleted file mode 100644 index 69df97c7c..000000000 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js +++ /dev/null @@ -1,351 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Grid, WindowScroller } from 'react-virtualized'; -import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; -import Measure from 'Components/Measure'; -import dimensions from 'Styles/Variables/dimensions'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import ArtistIndexPoster from './ArtistIndexPoster'; -import styles from './ArtistIndexPosters.css'; - -// Poster container dimensions -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); -const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); - -const additionalColumnCount = { - small: 3, - medium: 2, - large: 1 -}; - -function calculateColumnWidth(width, posterSize, isSmallScreen) { - const maxiumColumnWidth = isSmallScreen ? 172 : 182; - const columns = Math.floor(width / maxiumColumnWidth); - const remainder = width % maxiumColumnWidth; - - if (remainder === 0 && posterSize === 'large') { - return maxiumColumnWidth; - } - - return Math.floor(width / (columns + additionalColumnCount[posterSize])); -} - -function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) { - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - showNextAlbum - } = posterOptions; - - const nextAiringHeight = 19; - - const heights = [ - posterHeight, - detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, - nextAiringHeight, - isSmallScreen ? columnPaddingSmallScreen : columnPadding - ]; - - if (showTitle) { - heights.push(19); - } - - if (showMonitored) { - heights.push(19); - } - - if (showQualityProfile) { - heights.push(19); - } - - if (showNextAlbum) { - heights.push(19); - } - - switch (sortKey) { - case 'artistType': - case 'lastAlbum': - case 'seasons': - case 'added': - case 'albumCount': - case 'path': - case 'sizeOnDisk': - case 'tags': - heights.push(19); - break; - case 'qualityProfileId': - if (!showQualityProfile) { - heights.push(19); - } - break; - case 'nextAlbum': - if (!showNextAlbum) { - heights.push(19); - } - break; - default: - // No need to add a height of 0 - } - - return heights.reduce((acc, height) => acc + height, 0); -} - -function calculatePosterHeight(posterWidth) { - return Math.ceil(posterWidth); -} - -class ArtistIndexPosters extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - width: 0, - columnWidth: 182, - columnCount: 1, - posterWidth: 238, - posterHeight: 238, - rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}), - scrollRestored: false - }; - - this._isInitialized = false; - this._grid = null; - this._padding = props.isSmallScreen ? columnPaddingSmallScreen : columnPadding; - } - - componentDidUpdate(prevProps, prevState) { - const { - items, - sortKey, - posterOptions, - jumpToCharacter, - scrollTop, - isSmallScreen - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight, - scrollRestored - } = this.state; - - if (prevProps.sortKey !== sortKey || - prevProps.posterOptions !== posterOptions) { - this.calculateGrid(width, isSmallScreen); - } - - if (this._grid && - (prevState.width !== width || - prevState.columnWidth !== columnWidth || - prevState.columnCount !== columnCount || - prevState.rowHeight !== rowHeight || - hasDifferentItemsOrOrder(prevProps.items, items))) { - // recomputeGridSize also forces Grid to discard its cache of rendered cells - this._grid.recomputeGridSize(); - } - - if (this._grid && scrollTop !== 0 && !scrollRestored) { - this.setState({ scrollRestored: true }); - this._grid.scrollToPosition({ scrollTop }); - } - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - const index = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (this._grid && index != null) { - const row = Math.floor(index / columnCount); - - this._grid.scrollToCell({ - rowIndex: row, - columnIndex: 0 - }); - } - } - } - - // - // Control - - setGridRef = (ref) => { - this._grid = ref; - }; - - calculateGrid = (width = this.state.width, isSmallScreen) => { - const { - sortKey, - posterOptions - } = this.props; - - const columnWidth = calculateColumnWidth(width, posterOptions.size, isSmallScreen); - const columnCount = Math.max(Math.floor(width / columnWidth), 1); - const posterWidth = columnWidth - this._padding * 2; - const posterHeight = calculatePosterHeight(posterWidth); - const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions); - - this.setState({ - width, - columnWidth, - columnCount, - posterWidth, - posterHeight, - rowHeight - }); - }; - - cellRenderer = ({ key, rowIndex, columnIndex, style }) => { - const { - items, - sortKey, - posterOptions, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat - } = this.props; - - const { - posterWidth, - posterHeight, - columnCount - } = this.state; - - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - showNextAlbum - } = posterOptions; - - const artist = items[rowIndex * columnCount + columnIndex]; - - if (!artist) { - return null; - } - - return ( -
- -
- ); - }; - - // - // Listeners - - onMeasure = ({ width }) => { - this.calculateGrid(width, this.props.isSmallScreen); - }; - - // - // Render - - render() { - const { - scroller, - items, - isSmallScreen - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight - } = this.state; - - const rowCount = Math.ceil(items.length / columnCount); - - return ( - - - {({ height, registerChild, onChildScroll, scrollTop }) => { - if (!height) { - return
; - } - - return ( -
- -
- ); - } - } - - - ); - } -} - -ArtistIndexPosters.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - posterOptions: PropTypes.object.isRequired, - jumpToCharacter: PropTypes.string, - scrollTop: PropTypes.number.isRequired, - scroller: PropTypes.instanceOf(Element).isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired -}; - -export default ArtistIndexPosters; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx new file mode 100644 index 000000000..8a7eec694 --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx @@ -0,0 +1,294 @@ +import { throttle } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window'; +import { createSelector } from 'reselect'; +import Artist from 'Artist/Artist'; +import ArtistIndexPoster from 'Artist/Index/Posters/ArtistIndexPoster'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import SortDirection from 'Helpers/Props/SortDirection'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; + +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.artistIndexColumnPaddingSmallScreen +); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +const ADDITIONAL_COLUMN_COUNT = { + small: 3, + medium: 2, + large: 1, +}; + +interface CellItemData { + layout: { + columnCount: number; + padding: number; + posterWidth: number; + posterHeight: number; + }; + items: Artist[]; + sortKey: string; +} + +interface ArtistIndexPostersProps { + items: Artist[]; + sortKey?: string; + sortDirection?: SortDirection; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: React.MutableRefObject; + isSmallScreen: boolean; +} + +const artistIndexSelector = createSelector( + (state) => state.artistIndex.posterOptions, + (posterOptions) => { + return { + posterOptions, + }; + } +); + +const Cell: React.FC> = ({ + columnIndex, + rowIndex, + style, + data, +}) => { + const { layout, items, sortKey } = data; + + const { columnCount, padding, posterWidth, posterHeight } = layout; + + const index = rowIndex * columnCount + columnIndex; + + if (index >= items.length) { + return null; + } + + const artist = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +export default function ArtistIndexPosters(props: ArtistIndexPostersProps) { + const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props; + + const { posterOptions } = useSelector(artistIndexSelector); + const ref: React.MutableRefObject = useRef(); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const columnWidth = useMemo(() => { + const { width } = size; + const maximumColumnWidth = isSmallScreen ? 172 : 182; + const columns = Math.floor(width / maximumColumnWidth); + const remainder = width % maximumColumnWidth; + return remainder === 0 + ? maximumColumnWidth + : Math.floor( + width / (columns + ADDITIONAL_COLUMN_COUNT[posterOptions.size]) + ); + }, [isSmallScreen, posterOptions, size]); + + const columnCount = useMemo( + () => Math.max(Math.floor(size.width / columnWidth), 1), + [size, columnWidth] + ); + const padding = props.isSmallScreen + ? columnPaddingSmallScreen + : columnPadding; + const posterWidth = columnWidth - padding * 2; + const posterHeight = Math.ceil(posterWidth); + + const rowHeight = useMemo(() => { + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + } = posterOptions; + + const nextAiringHeight = 19; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding, + ]; + + if (showTitle) { + heights.push(19); + } + + if (showMonitored) { + heights.push(19); + } + + if (showQualityProfile) { + heights.push(19); + } + + if (showNextAlbum) { + heights.push(19); + } + + switch (sortKey) { + case 'artistType': + case 'metadataProfileId': + case 'lastAlbum': + case 'added': + case 'albumCount': + case 'path': + case 'sizeOnDisk': + case 'tags': + heights.push(19); + break; + case 'qualityProfileId': + if (!showQualityProfile) { + heights.push(19); + } + break; + case 'nextAlbum': + if (!showNextAlbum) { + heights.push(19); + } + break; + default: + // No need to add a height of 0 + } + + return heights.reduce((acc, height) => acc + height, 0); + }, [isSmallScreen, posterOptions, sortKey, posterHeight]); + + useEffect(() => { + const current = scrollerRef.current; + + if (isSmallScreen) { + const padding = bodyPaddingSmallScreen - 5; + + setSize({ + width: window.innerWidth - padding * 2, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = bodyPadding - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollListener = isSmallScreen ? window : scrollerRef.current; + const currentScrollerRef = scrollerRef.current; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + ref.current.scrollTo({ scrollLeft: 0, scrollTop }); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, ref, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const rowIndex = Math.floor(index / columnCount); + + const scrollTop = rowIndex * rowHeight + padding; + + ref.current.scrollTo({ scrollLeft: 0, scrollTop }); + scrollerRef.current.scrollTo(0, scrollTop); + } + } + }, [ + jumpToCharacter, + rowHeight, + columnCount, + padding, + items, + scrollerRef, + ref, + ]); + + return ( +
+ + ref={ref} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + columnCount={columnCount} + columnWidth={columnWidth} + rowCount={Math.ceil(items.length / columnCount)} + rowHeight={rowHeight} + itemData={{ + layout: { + columnCount, + padding, + posterWidth, + posterHeight, + }, + items, + sortKey, + }} + > + {Cell} + +
+ ); +} diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js deleted file mode 100644 index bff8bef81..000000000 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import ArtistIndexPosters from './ArtistIndexPosters'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex.posterOptions, - createUISettingsSelector(), - createDimensionsSelector(), - (posterOptions, uiSettings, dimensions) => { - return { - posterOptions, - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat, - isSmallScreen: dimensions.isSmallScreen - }; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexPosters); diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js deleted file mode 100644 index e1b0a257a..000000000 --- a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import ArtistIndexPosterOptionsModalContentConnector from './ArtistIndexPosterOptionsModalContentConnector'; - -function ArtistIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -ArtistIndexPosterOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexPosterOptionsModal; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.tsx b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.tsx new file mode 100644 index 000000000..69368807a --- /dev/null +++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent'; + +interface ArtistIndexPosterOptionsModalProps { + isOpen: boolean; + onModalClose(...args: unknown[]): unknown; +} + +function ArtistIndexPosterOptionsModal({ + isOpen, + onModalClose, +}: ArtistIndexPosterOptionsModalProps) { + return ( + + + + ); +} + +export default ArtistIndexPosterOptionsModal; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js deleted file mode 100644 index d0bc50baa..000000000 --- a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js +++ /dev/null @@ -1,248 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -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 } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -const posterSizeOptions = [ - { key: 'small', value: 'Small' }, - { key: 'medium', value: 'Medium' }, - { key: 'large', value: 'Large' } -]; - -class ArtistIndexPosterOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - detailedProgressBar: props.detailedProgressBar, - size: props.size, - showTitle: props.showTitle, - showMonitored: props.showMonitored, - showQualityProfile: props.showQualityProfile, - showNextAlbum: props.showNextAlbum, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showNextAlbum, - showSearchAction - } = this.props; - - const state = {}; - - if (detailedProgressBar !== prevProps.detailedProgressBar) { - state.detailedProgressBar = detailedProgressBar; - } - - if (size !== prevProps.size) { - state.size = size; - } - - if (showTitle !== prevProps.showTitle) { - state.showTitle = showTitle; - } - - if (showMonitored !== prevProps.showMonitored) { - state.showMonitored = showMonitored; - } - - if (showQualityProfile !== prevProps.showQualityProfile) { - state.showQualityProfile = showQualityProfile; - } - - if (showNextAlbum !== prevProps.showNextAlbum) { - state.showNextAlbum = showNextAlbum; - } - - if (showSearchAction !== prevProps.showSearchAction) { - state.showSearchAction = showSearchAction; - } - - if (!_.isEmpty(state)) { - this.setState(state); - } - } - - // - // Listeners - - onChangePosterOption = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onChangePosterOption({ [name]: value }); - }); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showNextAlbum, - showSearchAction - } = this.state; - - return ( - - - Poster Options - - - -
- - - {translate('PosterSize')} - - - - - - - - {translate('DetailedProgressBar')} - - - - - - - - {translate('ShowName')} - - - - - - - - {translate('ShowMonitored')} - - - - - - - - {translate('ShowQualityProfile')} - - - - - - - - {translate('ShowNextAlbum')} - - - - - - - - {translate('ShowSearch')} - - - - -
-
- - - - -
- ); - } -} - -ArtistIndexPosterOptionsModalContent.propTypes = { - size: PropTypes.string.isRequired, - showTitle: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showNextAlbum: PropTypes.bool.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onChangePosterOption: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexPosterOptionsModalContent; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx new file mode 100644 index 000000000..e1e60801c --- /dev/null +++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx @@ -0,0 +1,167 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import selectPosterOptions from 'Artist/Index/Posters/selectPosterOptions'; +import Form from 'Components/Form/Form'; +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 } from 'Helpers/Props'; +import { setArtistPosterOption } from 'Store/Actions/artistIndexActions'; +import translate from 'Utilities/String/translate'; + +const posterSizeOptions = [ + { + key: 'small', + get value() { + return translate('Small'); + }, + }, + { + key: 'medium', + get value() { + return translate('Medium'); + }, + }, + { + key: 'large', + get value() { + return translate('Large'); + }, + }, +]; + +interface ArtistIndexPosterOptionsModalContentProps { + onModalClose(...args: unknown[]): unknown; +} + +function ArtistIndexPosterOptionsModalContent( + props: ArtistIndexPosterOptionsModalContentProps +) { + const { onModalClose } = props; + + const posterOptions = useSelector(selectPosterOptions); + + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + showSearchAction, + } = posterOptions; + + const dispatch = useDispatch(); + + const onPosterOptionChange = useCallback( + ({ name, value }) => { + dispatch(setArtistPosterOption({ [name]: value })); + }, + [dispatch] + ); + + return ( + + {translate('PosterOptions')} + + +
+ + {translate('PosterSize')} + + + + + + {translate('DetailedProgressBar')} + + + + + + {translate('ShowName')} + + + + + + {translate('ShowMonitored')} + + + + + + {translate('ShowQualityProfile')} + + + + + + {translate('ShowNextAlbum')} + + + + + + {translate('ShowSearch')} + + + +
+
+ + + + +
+ ); +} + +export default ArtistIndexPosterOptionsModalContent; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js deleted file mode 100644 index 72af268ad..000000000 --- a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js +++ /dev/null @@ -1,23 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setArtistPosterOption } from 'Store/Actions/artistIndexActions'; -import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex, - (artistIndex) => { - return artistIndex.posterOptions; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onChangePosterOption(payload) { - dispatch(setArtistPosterOption(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexPosterOptionsModalContent); diff --git a/frontend/src/Artist/Index/Posters/selectPosterOptions.ts b/frontend/src/Artist/Index/Posters/selectPosterOptions.ts new file mode 100644 index 000000000..1a53a0add --- /dev/null +++ b/frontend/src/Artist/Index/Posters/selectPosterOptions.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const selectPosterOptions = createSelector( + (state: AppState) => state.artistIndex.posterOptions, + (posterOptions) => posterOptions +); + +export default selectPosterOptions; diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css index ce5313877..9b5777117 100644 --- a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css +++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css @@ -4,7 +4,6 @@ border-radius: 0; background-color: #5b5b5b; color: var(--white); - transition: width 200ms ease; } .progressBar { diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx similarity index 57% rename from frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js rename to frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx index 27d5c6f77..f76fa6588 100644 --- a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js +++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import ProgressBar from 'Components/ProgressBar'; import { sizes } from 'Helpers/Props'; @@ -6,7 +5,17 @@ import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; import translate from 'Utilities/String/translate'; import styles from './ArtistIndexProgressBar.css'; -function ArtistIndexProgressBar(props) { +interface ArtistIndexProgressBarProps { + monitored: boolean; + status: string; + trackCount: number; + trackFileCount: number; + totalTrackCount: number; + posterWidth: number; + detailedProgressBar: boolean; +} + +function ArtistIndexProgressBar(props: ArtistIndexProgressBarProps) { const { monitored, status, @@ -14,10 +23,10 @@ function ArtistIndexProgressBar(props) { trackFileCount, totalTrackCount, posterWidth, - detailedProgressBar + detailedProgressBar, } = props; - const progress = trackCount ? trackFileCount / trackCount * 100 : 100; + const progress = trackCount ? (trackFileCount / trackCount) * 100 : 100; const text = `${trackFileCount} / ${trackCount}`; return ( @@ -29,20 +38,14 @@ function ArtistIndexProgressBar(props) { size={detailedProgressBar ? sizes.MEDIUM : sizes.SMALL} showText={detailedProgressBar} text={text} - title={translate('TrackFileCountTrackCountTotalTotalTrackCountInterp', [trackFileCount, trackCount, totalTrackCount])} + title={translate('ArtistProgressBarText', { + trackFileCount, + trackCount, + totalTrackCount, + })} width={posterWidth} /> ); } -ArtistIndexProgressBar.propTypes = { - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - trackCount: PropTypes.number.isRequired, - trackFileCount: PropTypes.number.isRequired, - totalTrackCount: PropTypes.number.isRequired, - posterWidth: PropTypes.number.isRequired, - detailedProgressBar: PropTypes.bool.isRequired -}; - export default ArtistIndexProgressBar; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js b/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js deleted file mode 100644 index a2a3c8dab..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js +++ /dev/null @@ -1,103 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import IconButton from 'Components/Link/IconButton'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import { icons } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -class ArtistIndexActionsCell extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - // - // Listeners - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - isRefreshingArtist, - onRefreshArtistPress, - ...otherProps - } = this.props; - - const { - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - return ( - - - - - - - - - - ); - } -} - -ArtistIndexActionsCell.propTypes = { - id: PropTypes.number.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired -}; - -export default ArtistIndexActionsCell; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.js b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js deleted file mode 100644 index 7054bbaf3..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexHeader.js +++ /dev/null @@ -1,86 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import IconButton from 'Components/Link/IconButton'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; -import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; -import { icons } from 'Helpers/Props'; -import ArtistIndexTableOptionsConnector from './ArtistIndexTableOptionsConnector'; -import hasGrowableColumns from './hasGrowableColumns'; -import styles from './ArtistIndexHeader.css'; - -function ArtistIndexHeader(props) { - const { - showBanners, - columns, - onTableOptionChange, - ...otherProps - } = props; - - return ( - - { - columns.map((column) => { - const { - name, - label, - isSortable, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'actions') { - return ( - - - - - - - ); - } - - return ( - - {typeof label === 'function' ? label() : label} - - ); - }) - } - - ); -} - -ArtistIndexHeader.propTypes = { - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onTableOptionChange: PropTypes.func.isRequired, - showBanners: PropTypes.bool.isRequired -}; - -export default ArtistIndexHeader; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js deleted file mode 100644 index 37ddd9ef3..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from 'react-redux'; -import { setArtistTableOption } from 'Store/Actions/artistIndexActions'; -import ArtistIndexHeader from './ArtistIndexHeader'; - -function createMapDispatchToProps(dispatch, props) { - return { - onTableOptionChange(payload) { - dispatch(setArtistTableOption(payload)); - } - }; -} - -export default connect(undefined, createMapDispatchToProps)(ArtistIndexHeader); diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.js b/frontend/src/Artist/Index/Table/ArtistIndexRow.js deleted file mode 100644 index 0dc5585ca..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexRow.js +++ /dev/null @@ -1,487 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import AlbumTitleLink from 'Album/AlbumTitleLink'; -import ArtistBanner from 'Artist/ArtistBanner'; -import ArtistNameLink from 'Artist/ArtistNameLink'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import HeartRating from 'Components/HeartRating'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import ProgressBar from 'Components/ProgressBar'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import TagListConnector from 'Components/TagListConnector'; -import { icons } from 'Helpers/Props'; -import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import ArtistStatusCell from './ArtistStatusCell'; -import hasGrowableColumns from './hasGrowableColumns'; -import styles from './ArtistIndexRow.css'; - -class ArtistIndexRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - hasBannerError: false, - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - onUseSceneNumberingChange = () => { - // Mock handler to satisfy `onChange` being required for `CheckInput`. - // - }; - - onBannerLoad = () => { - if (this.state.hasBannerError) { - this.setState({ hasBannerError: false }); - } - }; - - onBannerLoadError = () => { - if (!this.state.hasBannerError) { - this.setState({ hasBannerError: true }); - } - }; - - // - // Render - - render() { - const { - id, - monitored, - status, - artistName, - foreignArtistId, - artistType, - qualityProfile, - metadataProfile, - nextAlbum, - lastAlbum, - added, - statistics, - genres, - ratings, - path, - tags, - images, - isSaving, - showBanners, - showSearchAction, - columns, - isRefreshingArtist, - isSearchingArtist, - onRefreshArtistPress, - onSearchPress, - onMonitoredPress - } = this.props; - - const { - albumCount, - trackCount, - trackFileCount, - totalTrackCount, - sizeOnDisk - } = statistics; - - const { - hasBannerError, - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - return ( - <> - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'status') { - return ( - - ); - } - - if (name === 'sortName') { - return ( - - { - showBanners ? - - - - { - hasBannerError && -
- {artistName} -
- } - : - - - } -
- ); - } - - if (name === 'artistType') { - return ( - - {artistType} - - ); - } - - if (name === 'qualityProfileId') { - return ( - - {qualityProfile.name} - - ); - } - - if (name === 'metadataProfileId') { - return ( - - {metadataProfile.name} - - ); - } - - if (name === 'nextAlbum') { - if (nextAlbum) { - return ( - - - - ); - } - return ( - - None - - ); - } - - if (name === 'lastAlbum') { - if (lastAlbum) { - return ( - - - - ); - } - return ( - - None - - ); - } - - if (name === 'added') { - return ( - - ); - } - - if (name === 'albumCount') { - return ( - - {albumCount} - - ); - } - - if (name === 'trackProgress') { - const progress = trackCount ? trackFileCount / trackCount * 100 : 100; - - return ( - - - - ); - } - - if (name === 'trackCount') { - return ( - - {totalTrackCount} - - ); - } - - if (name === 'path') { - return ( - - {path} - - ); - } - - if (name === 'sizeOnDisk') { - return ( - - {formatBytes(sizeOnDisk)} - - ); - } - - if (name === 'genres') { - const joinedGenres = genres.join(', '); - - return ( - - - {joinedGenres} - - - ); - } - - if (name === 'ratings') { - return ( - - - - ); - } - - if (name === 'tags') { - return ( - - - - ); - } - - if (name === 'actions') { - return ( - - - - { - showSearchAction && - - } - - - - ); - } - - return null; - }) - } - - - - - - ); - } -} - -ArtistIndexRow.propTypes = { - id: PropTypes.number.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - artistName: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - artistType: PropTypes.string, - qualityProfile: PropTypes.object.isRequired, - metadataProfile: PropTypes.object.isRequired, - nextAlbum: PropTypes.object, - lastAlbum: PropTypes.object, - added: PropTypes.string, - statistics: PropTypes.object.isRequired, - latestAlbum: PropTypes.object, - path: PropTypes.string.isRequired, - genres: PropTypes.arrayOf(PropTypes.string).isRequired, - ratings: PropTypes.object.isRequired, - tags: PropTypes.arrayOf(PropTypes.number).isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - isSaving: PropTypes.bool.isRequired, - showBanners: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isSearchingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired, - onMonitoredPress: PropTypes.func.isRequired -}; - -ArtistIndexRow.defaultProps = { - statistics: { - albumCount: 0, - trackCount: 0, - trackFileCount: 0, - totalTrackCount: 0 - }, - genres: [], - tags: [] -}; - -export default ArtistIndexRow; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx b/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx new file mode 100644 index 000000000..de508a331 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx @@ -0,0 +1,392 @@ +import classNames from 'classnames'; +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import { Statistics } from 'Artist/Artist'; +import ArtistBanner from 'Artist/ArtistBanner'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector'; +import ArtistStatusCell from 'Artist/Index/Table/ArtistStatusCell'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import HeartRating from 'Components/HeartRating'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ProgressBar from 'Components/ProgressBar'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import Column from 'Components/Table/Column'; +import TagListConnector from 'Components/TagListConnector'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import hasGrowableColumns from './hasGrowableColumns'; +import selectTableOptions from './selectTableOptions'; +import styles from './ArtistIndexRow.css'; + +interface ArtistIndexRowProps { + artistId: number; + sortKey: string; + columns: Column[]; +} + +function ArtistIndexRow(props: ArtistIndexRowProps) { + const { artistId, columns } = props; + + const { + artist, + qualityProfile, + metadataProfile, + isRefreshingArtist, + isSearchingArtist, + } = useSelector(createArtistIndexItemSelector(props.artistId)); + + const { showBanners, showSearchAction } = useSelector(selectTableOptions); + + const { + artistName, + foreignArtistId, + monitored, + status, + path, + nextAlbum, + lastAlbum, + added, + statistics = {} as Statistics, + images, + artistType, + genres = [], + ratings, + tags = [], + isSaving = false, + } = artist; + + const { + albumCount = 0, + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0, + sizeOnDisk = 0, + } = statistics; + + const dispatch = useDispatch(); + const [hasBannerError, setHasBannerError] = useState(false); + const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); + const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: ARTIST_SEARCH, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onBannerLoadError = useCallback(() => { + setHasBannerError(true); + }, [setHasBannerError]); + + const onBannerLoad = useCallback(() => { + setHasBannerError(false); + }, [setHasBannerError]); + + const onEditArtistPress = useCallback(() => { + setIsEditArtistModalOpen(true); + }, [setIsEditArtistModalOpen]); + + const onEditArtistModalClose = useCallback(() => { + setIsEditArtistModalOpen(false); + }, [setIsEditArtistModalOpen]); + + const onDeleteArtistPress = useCallback(() => { + setIsEditArtistModalOpen(false); + setIsDeleteArtistModalOpen(true); + }, [setIsDeleteArtistModalOpen]); + + const onDeleteArtistModalClose = useCallback(() => { + setIsDeleteArtistModalOpen(false); + }, [setIsDeleteArtistModalOpen]); + + return ( + <> + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'sortName') { + return ( + + {showBanners ? ( + + + + {hasBannerError && ( +
{artistName}
+ )} + + ) : ( + + )} +
+ ); + } + + if (name === 'artistType') { + return ( + + {artistType} + + ); + } + + if (name === 'qualityProfileId') { + return ( + + {qualityProfile.name} + + ); + } + + if (name === 'qualityProfileId') { + return ( + + {qualityProfile.name} + + ); + } + + if (name === 'metadataProfileId') { + return ( + + {metadataProfile.name} + + ); + } + + if (name === 'nextAlbum') { + if (nextAlbum) { + return ( + + + + ); + } + return ( + + None + + ); + } + + if (name === 'lastAlbum') { + if (lastAlbum) { + return ( + + + + ); + } + return ( + + None + + ); + } + + if (name === 'added') { + return ( + + ); + } + + if (name === 'albumCount') { + return ( + + {albumCount} + + ); + } + + if (name === 'trackProgress') { + const progress = trackCount + ? (trackFileCount / trackCount) * 100 + : 100; + + return ( + + + + ); + } + + if (name === 'trackCount') { + return ( + + {totalTrackCount} + + ); + } + + if (name === 'path') { + return ( + + {path} + + ); + } + + if (name === 'sizeOnDisk') { + return ( + + {formatBytes(sizeOnDisk)} + + ); + } + + if (name === 'genres') { + const joinedGenres = genres.join(', '); + + return ( + + {joinedGenres} + + ); + } + + if (name === 'ratings') { + return ( + + + + ); + } + + if (name === 'tags') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + + + {showSearchAction ? ( + + ) : null} + + + + ); + } + + return null; + })} + + + + + + ); +} + +export default ArtistIndexRow; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.css b/frontend/src/Artist/Index/Table/ArtistIndexTable.css index 23ab127b5..455f0bc7c 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.css +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.css @@ -1,5 +1,3 @@ -.tableContainer { - composes: tableContainer from '~Components/Table/VirtualTable.css'; - - flex: 1 0 auto; +.tableScroller { + position: relative; } diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.css.d.ts b/frontend/src/Artist/Index/Table/ArtistIndexTable.css.d.ts index fbc2e3b9a..712cb8f72 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.css.d.ts +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.css.d.ts @@ -1,7 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'tableContainer': string; + 'tableScroller': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.js b/frontend/src/Artist/Index/Table/ArtistIndexTable.js deleted file mode 100644 index 00f6a80d1..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.js +++ /dev/null @@ -1,134 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; -import VirtualTable from 'Components/Table/VirtualTable'; -import VirtualTableRow from 'Components/Table/VirtualTableRow'; -import { sortDirections } from 'Helpers/Props'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import ArtistIndexHeaderConnector from './ArtistIndexHeaderConnector'; -import ArtistIndexRow from './ArtistIndexRow'; -import styles from './ArtistIndexTable.css'; - -class ArtistIndexTable extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - scrollIndex: null - }; - } - - componentDidUpdate(prevProps) { - const { - items, - jumpToCharacter - } = this.props; - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - - const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (scrollIndex != null) { - this.setState({ scrollIndex }); - } - } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) { - this.setState({ scrollIndex: null }); - } - } - - // - // Control - - rowRenderer = ({ key, rowIndex, style }) => { - const { - items, - columns, - showBanners, - isSaving - } = this.props; - - const artist = items[rowIndex]; - - return ( - - - - ); - }; - - // - // Render - - render() { - const { - items, - columns, - sortKey, - sortDirection, - showBanners, - isSmallScreen, - onSortPress, - scroller, - scrollTop - } = this.props; - - return ( - - } - columns={columns} - sortKey={sortKey} - sortDirection={sortDirection} - /> - ); - } -} - -ArtistIndexTable.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.oneOf(sortDirections.all), - showBanners: PropTypes.bool.isRequired, - isSaving: PropTypes.bool.isRequired, - jumpToCharacter: PropTypes.string, - scrollTop: PropTypes.number, - scroller: PropTypes.instanceOf(Element).isRequired, - isSmallScreen: PropTypes.bool.isRequired, - onSortPress: PropTypes.func.isRequired -}; - -export default ArtistIndexTable; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx b/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx new file mode 100644 index 000000000..1c556384d --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx @@ -0,0 +1,205 @@ +import { throttle } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; +import { createSelector } from 'reselect'; +import Artist from 'Artist/Artist'; +import ArtistIndexRow from 'Artist/Index/Table/ArtistIndexRow'; +import ArtistIndexTableHeader from 'Artist/Index/Table/ArtistIndexTableHeader'; +import Scroller from 'Components/Scroller/Scroller'; +import Column from 'Components/Table/Column'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import ScrollDirection from 'Helpers/Props/ScrollDirection'; +import SortDirection from 'Helpers/Props/SortDirection'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import selectTableOptions from './selectTableOptions'; +import styles from './ArtistIndexTable.css'; + +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); + +interface RowItemData { + items: Artist[]; + sortKey: string; + columns: Column[]; +} + +interface ArtistIndexTableProps { + items: Artist[]; + sortKey?: string; + sortDirection?: SortDirection; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: React.MutableRefObject; + isSmallScreen: boolean; +} + +const columnsSelector = createSelector( + (state) => state.artistIndex.columns, + (columns) => columns +); + +const Row: React.FC> = ({ + index, + style, + data, +}) => { + const { items, sortKey, columns } = data; + + if (index >= items.length) { + return null; + } + + const artist = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +function ArtistIndexTable(props: ArtistIndexTableProps) { + const { + items, + sortKey, + sortDirection, + jumpToCharacter, + isSmallScreen, + scrollerRef, + } = props; + + const columns = useSelector(columnsSelector); + const { showBanners } = useSelector(selectTableOptions); + const listRef: React.MutableRefObject = useRef(); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const rowHeight = useMemo(() => { + return showBanners ? 70 : 38; + }, [showBanners]); + + useEffect(() => { + const current = scrollerRef.current as HTMLElement; + + if (isSmallScreen) { + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = + (isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollListener = isSmallScreen ? window : scrollerRef.current; + const currentScrollerRef = scrollerRef.current; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + listRef.current.scrollTo(scrollTop); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, listRef, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + let scrollTop = index * rowHeight; + + // If the offset is zero go to the top, otherwise offset + // by the approximate size of the header + padding (37 + 20). + if (scrollTop > 0) { + const offset = 57; + + scrollTop += offset; + } + + listRef.current.scrollTo(scrollTop); + scrollerRef.current.scrollTo(0, scrollTop); + } + } + }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); + + return ( +
+ + + + ref={listRef} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + itemCount={items.length} + itemSize={rowHeight} + itemData={{ + items, + sortKey, + columns, + }} + > + {Row} + + +
+ ); +} + +export default ArtistIndexTable; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js deleted file mode 100644 index 3a97425cc..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js +++ /dev/null @@ -1,29 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setArtistSort } from 'Store/Actions/artistIndexActions'; -import ArtistIndexTable from './ArtistIndexTable'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app.dimensions, - (state) => state.artistIndex.tableOptions, - (state) => state.artistIndex.columns, - (dimensions, tableOptions, columns) => { - return { - isSmallScreen: dimensions.isSmallScreen, - showBanners: tableOptions.showBanners, - columns - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onSortPress(sortKey) { - dispatch(setArtistSort({ sortKey })); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexTable); diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css similarity index 100% rename from frontend/src/Artist/Index/Table/ArtistIndexHeader.css rename to frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css.d.ts b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css.d.ts similarity index 100% rename from frontend/src/Artist/Index/Table/ArtistIndexHeader.css.d.ts rename to frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css.d.ts diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx new file mode 100644 index 000000000..de4230024 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx @@ -0,0 +1,98 @@ +import classNames from 'classnames'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import ArtistIndexTableOptions from 'Artist/Index/Table/ArtistIndexTableOptions'; +import IconButton from 'Components/Link/IconButton'; +import Column from 'Components/Table/Column'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import { icons } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { + setArtistSort, + setArtistTableOption, +} from 'Store/Actions/artistIndexActions'; +import hasGrowableColumns from './hasGrowableColumns'; +import styles from './ArtistIndexTableHeader.css'; + +interface ArtistIndexTableHeaderProps { + showBanners: boolean; + columns: Column[]; + sortKey?: string; + sortDirection?: SortDirection; +} + +function ArtistIndexTableHeader(props: ArtistIndexTableHeaderProps) { + const { showBanners, columns, sortKey, sortDirection } = props; + + const dispatch = useDispatch(); + + const onSortPress = useCallback( + (value) => { + dispatch(setArtistSort({ sortKey: value })); + }, + [dispatch] + ); + + const onTableOptionChange = useCallback( + (payload) => { + dispatch(setArtistTableOption(payload)); + }, + [dispatch] + ); + + return ( + + {columns.map((column) => { + const { name, label, isSortable, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { + return ( + + + + + + ); + } + + return ( + + {typeof label === 'function' ? label() : label} + + ); + })} + + ); +} + +export default ArtistIndexTableHeader; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js deleted file mode 100644 index 6fd619ad0..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js +++ /dev/null @@ -1,105 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import { inputTypes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -class ArtistIndexTableOptions extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - showBanners: props.showBanners, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - showBanners, - showSearchAction - } = this.props; - - if ( - showBanners !== prevProps.showBanners || - showSearchAction !== prevProps.showSearchAction - ) { - this.setState({ - showBanners, - showSearchAction - }); - } - } - - // - // Listeners - - onTableOptionChange = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onTableOptionChange({ - tableOptions: { - ...this.state, - [name]: value - } - }); - }); - }; - - // - // Render - - render() { - const { - showBanners, - showSearchAction - } = this.state; - - return ( - - - - {translate('ShowBanners')} - - - - - - - - {translate('ShowSearch')} - - - - - - ); - } -} - -ArtistIndexTableOptions.propTypes = { - showBanners: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onTableOptionChange: PropTypes.func.isRequired -}; - -export default ArtistIndexTableOptions; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.tsx b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.tsx new file mode 100644 index 000000000..f7f2bbd20 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.tsx @@ -0,0 +1,62 @@ +import React, { Fragment, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import selectTableOptions from './selectTableOptions'; + +interface ArtistIndexTableOptionsProps { + onTableOptionChange(...args: unknown[]): unknown; +} + +function ArtistIndexTableOptions(props: ArtistIndexTableOptionsProps) { + const { onTableOptionChange } = props; + + const tableOptions = useSelector(selectTableOptions); + + const { showBanners, showSearchAction } = tableOptions; + + const onTableOptionChangeWrapper = useCallback( + ({ name, value }) => { + onTableOptionChange({ + tableOptions: { + ...tableOptions, + [name]: value, + }, + }); + }, + [tableOptions, onTableOptionChange] + ); + + return ( + + + {translate('ShowBanners')} + + + + + + {translate('ShowSearch')} + + + + + ); +} + +export default ArtistIndexTableOptions; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js deleted file mode 100644 index 0a1607cf2..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js +++ /dev/null @@ -1,14 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import ArtistIndexTableOptions from './ArtistIndexTableOptions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex.tableOptions, - (tableOptions) => { - return tableOptions; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexTableOptions); diff --git a/frontend/src/Artist/Index/Table/ArtistStatusCell.js b/frontend/src/Artist/Index/Table/ArtistStatusCell.tsx similarity index 52% rename from frontend/src/Artist/Index/Table/ArtistStatusCell.js rename to frontend/src/Artist/Index/Table/ArtistStatusCell.tsx index 1f163a473..05be1573e 100644 --- a/frontend/src/Artist/Index/Table/ArtistStatusCell.js +++ b/frontend/src/Artist/Index/Table/ArtistStatusCell.tsx @@ -1,31 +1,44 @@ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; import Icon from 'Components/Icon'; import MonitorToggleButton from 'Components/MonitorToggleButton'; import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; import { icons } from 'Helpers/Props'; +import { toggleArtistMonitored } from 'Store/Actions/artistActions'; import translate from 'Utilities/String/translate'; import styles from './ArtistStatusCell.css'; -function ArtistStatusCell(props) { +interface ArtistStatusCellProps { + className: string; + artistId: number; + artistType?: string; + monitored: boolean; + status: string; + isSaving: boolean; + component?: React.ElementType; +} + +function ArtistStatusCell(props: ArtistStatusCellProps) { const { className, + artistId, artistType, monitored, status, isSaving, - onMonitoredPress, - component: Component, + component: Component = VirtualTableRowCell, ...otherProps } = props; const endedString = artistType === 'Person' ? 'Deceased' : 'Inactive'; + const dispatch = useDispatch(); + + const onMonitoredPress = useCallback(() => { + dispatch(toggleArtistMonitored({ artistId, monitored: !monitored })); + }, [artistId, monitored, dispatch]); return ( - + ); } -ArtistStatusCell.propTypes = { - className: PropTypes.string.isRequired, - artistType: PropTypes.string, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - isSaving: PropTypes.bool.isRequired, - onMonitoredPress: PropTypes.func.isRequired, - component: PropTypes.elementType -}; - -ArtistStatusCell.defaultProps = { - className: styles.status, - component: VirtualTableRowCell -}; - export default ArtistStatusCell; diff --git a/frontend/src/Artist/Index/Table/hasGrowableColumns.js b/frontend/src/Artist/Index/Table/hasGrowableColumns.js deleted file mode 100644 index 994436d9f..000000000 --- a/frontend/src/Artist/Index/Table/hasGrowableColumns.js +++ /dev/null @@ -1,16 +0,0 @@ -const growableColumns = [ - 'qualityProfileId', - 'path', - 'tags' -]; - -export default function hasGrowableColumns(columns) { - return columns.some((column) => { - const { - name, - isVisible - } = column; - - return growableColumns.includes(name) && isVisible; - }); -} diff --git a/frontend/src/Artist/Index/Table/hasGrowableColumns.ts b/frontend/src/Artist/Index/Table/hasGrowableColumns.ts new file mode 100644 index 000000000..ed0cc6c58 --- /dev/null +++ b/frontend/src/Artist/Index/Table/hasGrowableColumns.ts @@ -0,0 +1,11 @@ +import Column from 'Components/Table/Column'; + +const growableColumns = ['qualityProfileId', 'path', 'tags']; + +export default function hasGrowableColumns(columns: Column[]) { + return columns.some((column) => { + const { name, isVisible } = column; + + return growableColumns.includes(name) && isVisible; + }); +} diff --git a/frontend/src/Artist/Index/Table/selectTableOptions.ts b/frontend/src/Artist/Index/Table/selectTableOptions.ts new file mode 100644 index 000000000..b6a2a6a94 --- /dev/null +++ b/frontend/src/Artist/Index/Table/selectTableOptions.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const selectTableOptions = createSelector( + (state: AppState) => state.artistIndex.tableOptions, + (tableOptions) => tableOptions +); + +export default selectTableOptions; diff --git a/frontend/src/Artist/Index/createArtistIndexItemSelector.ts b/frontend/src/Artist/Index/createArtistIndexItemSelector.ts new file mode 100644 index 000000000..86ee8a560 --- /dev/null +++ b/frontend/src/Artist/Index/createArtistIndexItemSelector.ts @@ -0,0 +1,48 @@ +import { createSelector } from 'reselect'; +import Artist from 'Artist/Artist'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import createArtistMetadataProfileSelector from 'Store/Selectors/createArtistMetadataProfileSelector'; +import createArtistQualityProfileSelector from 'Store/Selectors/createArtistQualityProfileSelector'; +import { createArtistSelectorForHook } from 'Store/Selectors/createArtistSelector'; +import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; + +function createArtistIndexItemSelector(artistId: number) { + return createSelector( + createArtistSelectorForHook(artistId), + createArtistQualityProfileSelector(artistId), + createArtistMetadataProfileSelector(artistId), + createExecutingCommandsSelector(), + (artist: Artist, qualityProfile, metadataProfile, executingCommands) => { + // If an artist is deleted this selector may fire before the parent + // selectors, which will result in an undefined artist, if that happens + // we want to return early here and again in the render function to avoid + // trying to show an artist that has no information available. + + if (!artist) { + return {}; + } + + const isRefreshingArtist = executingCommands.some((command) => { + return ( + command.name === REFRESH_ARTIST && command.body.artistId === artist.id + ); + }); + + const isSearchingArtist = executingCommands.some((command) => { + return ( + command.name === ARTIST_SEARCH && command.body.artistId === artist.id + ); + }); + + return { + artist, + qualityProfile, + metadataProfile, + isRefreshingArtist, + isSearchingArtist, + }; + } + ); +} + +export default createArtistIndexItemSelector; diff --git a/frontend/src/Components/Link/SpinnerIconButton.js b/frontend/src/Components/Link/SpinnerIconButton.js index a804fafc5..d36ebb24d 100644 --- a/frontend/src/Components/Link/SpinnerIconButton.js +++ b/frontend/src/Components/Link/SpinnerIconButton.js @@ -23,6 +23,8 @@ function SpinnerIconButton(props) { } SpinnerIconButton.propTypes = { + ...IconButton.propTypes, + className: PropTypes.string, name: PropTypes.object.isRequired, spinningName: PropTypes.object.isRequired, isDisabled: PropTypes.bool.isRequired, diff --git a/frontend/src/Components/Menu/SortMenu.js b/frontend/src/Components/Menu/SortMenu.js index df8bb2a36..ec068fdf9 100644 --- a/frontend/src/Components/Menu/SortMenu.js +++ b/frontend/src/Components/Menu/SortMenu.js @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import Menu from 'Components/Menu/Menu'; import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; -import { icons } from 'Helpers/Props'; +import { align, icons } from 'Helpers/Props'; function SortMenu(props) { const { @@ -30,7 +30,8 @@ function SortMenu(props) { SortMenu.propTypes = { className: PropTypes.string, children: PropTypes.node.isRequired, - isDisabled: PropTypes.bool.isRequired + isDisabled: PropTypes.bool.isRequired, + alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT]) }; SortMenu.defaultProps = { diff --git a/frontend/src/Components/Menu/ViewMenu.js b/frontend/src/Components/Menu/ViewMenu.js index 0f87d53f8..7eb505b8f 100644 --- a/frontend/src/Components/Menu/ViewMenu.js +++ b/frontend/src/Components/Menu/ViewMenu.js @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import Menu from 'Components/Menu/Menu'; import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; -import { icons } from 'Helpers/Props'; +import { align, icons } from 'Helpers/Props'; function ViewMenu(props) { const { @@ -27,7 +27,8 @@ function ViewMenu(props) { ViewMenu.propTypes = { children: PropTypes.node.isRequired, - isDisabled: PropTypes.bool.isRequired + isDisabled: PropTypes.bool.isRequired, + alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT]) }; ViewMenu.defaultProps = { diff --git a/frontend/src/Components/Page/PageContentBody.js b/frontend/src/Components/Page/PageContentBody.js deleted file mode 100644 index 1c93e575b..000000000 --- a/frontend/src/Components/Page/PageContentBody.js +++ /dev/null @@ -1,61 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Scroller from 'Components/Scroller/Scroller'; -import { scrollDirections } from 'Helpers/Props'; -import { isLocked } from 'Utilities/scrollLock'; -import styles from './PageContentBody.css'; - -class PageContentBody extends Component { - - // - // Listeners - - onScroll = (props) => { - const { onScroll } = this.props; - - if (this.props.onScroll && !isLocked()) { - onScroll(props); - } - }; - - // - // Render - - render() { - const { - className, - innerClassName, - children, - dispatch, - ...otherProps - } = this.props; - - return ( - -
- {children} -
-
- ); - } -} - -PageContentBody.propTypes = { - className: PropTypes.string, - innerClassName: PropTypes.string, - children: PropTypes.node.isRequired, - onScroll: PropTypes.func, - dispatch: PropTypes.func -}; - -PageContentBody.defaultProps = { - className: styles.contentBody, - innerClassName: styles.innerContentBody -}; - -export default PageContentBody; diff --git a/frontend/src/Components/Page/PageContentBody.tsx b/frontend/src/Components/Page/PageContentBody.tsx new file mode 100644 index 000000000..972a9bade --- /dev/null +++ b/frontend/src/Components/Page/PageContentBody.tsx @@ -0,0 +1,51 @@ +import React, { forwardRef, ReactNode, useCallback } from 'react'; +import Scroller from 'Components/Scroller/Scroller'; +import ScrollDirection from 'Helpers/Props/ScrollDirection'; +import { isLocked } from 'Utilities/scrollLock'; +import styles from './PageContentBody.css'; + +interface PageContentBodyProps { + className?: string; + innerClassName?: string; + children: ReactNode; + initialScrollTop?: number; + onScroll?: (payload) => void; +} + +const PageContentBody = forwardRef( + ( + props: PageContentBodyProps, + ref: React.MutableRefObject + ) => { + const { + className = styles.contentBody, + innerClassName = styles.innerContentBody, + children, + onScroll, + ...otherProps + } = props; + + const onScrollWrapper = useCallback( + (payload) => { + if (onScroll && !isLocked()) { + onScroll(payload); + } + }, + [onScroll] + ); + + return ( + +
{children}
+
+ ); + } +); + +export default PageContentBody; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js index 2d179396a..c93603aa9 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js @@ -45,7 +45,8 @@ PageToolbarButton.propTypes = { iconName: PropTypes.object.isRequired, spinningName: PropTypes.object, isSpinning: PropTypes.bool, - isDisabled: PropTypes.bool + isDisabled: PropTypes.bool, + onPress: PropTypes.func }; PageToolbarButton.defaultProps = { diff --git a/frontend/src/Components/Scroller/Scroller.js b/frontend/src/Components/Scroller/Scroller.js deleted file mode 100644 index 205f1aadd..000000000 --- a/frontend/src/Components/Scroller/Scroller.js +++ /dev/null @@ -1,95 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { scrollDirections } from 'Helpers/Props'; -import styles from './Scroller.css'; - -class Scroller extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scroller = null; - } - - componentDidMount() { - const { - scrollDirection, - autoFocus, - scrollTop - } = this.props; - - if (this.props.scrollTop != null) { - this._scroller.scrollTop = scrollTop; - } - - if (autoFocus && scrollDirection !== scrollDirections.NONE) { - this._scroller.focus({ preventScroll: true }); - } - } - - // - // Control - - _setScrollerRef = (ref) => { - this._scroller = ref; - - this.props.registerScroller(ref); - }; - - // - // Render - - render() { - const { - className, - scrollDirection, - autoScroll, - children, - scrollTop, - onScroll, - registerScroller, - ...otherProps - } = this.props; - - return ( -
- {children} -
- ); - } - -} - -Scroller.propTypes = { - className: PropTypes.string, - scrollDirection: PropTypes.oneOf(scrollDirections.all).isRequired, - autoFocus: PropTypes.bool.isRequired, - autoScroll: PropTypes.bool.isRequired, - scrollTop: PropTypes.number, - children: PropTypes.node, - onScroll: PropTypes.func, - registerScroller: PropTypes.func -}; - -Scroller.defaultProps = { - scrollDirection: scrollDirections.VERTICAL, - autoFocus: true, - autoScroll: true, - registerScroller: () => { /* no-op */ } -}; - -export default Scroller; diff --git a/frontend/src/Components/Scroller/Scroller.tsx b/frontend/src/Components/Scroller/Scroller.tsx new file mode 100644 index 000000000..2bcb899aa --- /dev/null +++ b/frontend/src/Components/Scroller/Scroller.tsx @@ -0,0 +1,90 @@ +import classNames from 'classnames'; +import { throttle } from 'lodash'; +import React, { forwardRef, ReactNode, useEffect, useRef } from 'react'; +import ScrollDirection from 'Helpers/Props/ScrollDirection'; +import styles from './Scroller.css'; + +interface ScrollerProps { + className?: string; + scrollDirection?: ScrollDirection; + autoFocus?: boolean; + autoScroll?: boolean; + scrollTop?: number; + initialScrollTop?: number; + children?: ReactNode; + onScroll?: (payload) => void; +} + +const Scroller = forwardRef( + (props: ScrollerProps, ref: React.MutableRefObject) => { + const { + className, + autoFocus = false, + autoScroll = true, + scrollDirection = ScrollDirection.Vertical, + children, + scrollTop, + initialScrollTop, + onScroll, + ...otherProps + } = props; + + const internalRef = useRef(); + const currentRef = ref ?? internalRef; + + useEffect( + () => { + if (initialScrollTop != null) { + currentRef.current.scrollTop = initialScrollTop; + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + if (scrollTop != null) { + currentRef.current.scrollTop = scrollTop; + } + + if (autoFocus && scrollDirection !== ScrollDirection.None) { + currentRef.current.focus({ preventScroll: true }); + } + }, [autoFocus, currentRef, scrollDirection, scrollTop]); + + useEffect(() => { + const div = currentRef.current; + + const handleScroll = throttle(() => { + const scrollLeft = div.scrollLeft; + const scrollTop = div.scrollTop; + + onScroll?.({ scrollLeft, scrollTop }); + }, 10); + + div.addEventListener('scroll', handleScroll); + + return () => { + div.removeEventListener('scroll', handleScroll); + }; + }, [currentRef, onScroll]); + + return ( +
+ {children} +
+ ); + } +); + +export default Scroller; diff --git a/frontend/src/Components/withScrollPosition.js b/frontend/src/Components/withScrollPosition.js deleted file mode 100644 index bb089b8b0..000000000 --- a/frontend/src/Components/withScrollPosition.js +++ /dev/null @@ -1,30 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import scrollPositions from 'Store/scrollPositions'; - -function withScrollPosition(WrappedComponent, scrollPositionKey) { - function ScrollPosition(props) { - const { - history - } = props; - - const scrollTop = history.action === 'POP' || (history.location.state && history.location.state.restoreScrollPosition) ? - scrollPositions[scrollPositionKey] : - 0; - - return ( - - ); - } - - ScrollPosition.propTypes = { - history: PropTypes.object.isRequired - }; - - return ScrollPosition; -} - -export default withScrollPosition; diff --git a/frontend/src/Components/withScrollPosition.tsx b/frontend/src/Components/withScrollPosition.tsx new file mode 100644 index 000000000..ec13c6ab8 --- /dev/null +++ b/frontend/src/Components/withScrollPosition.tsx @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import scrollPositions from 'Store/scrollPositions'; + +function withScrollPosition(WrappedComponent, scrollPositionKey) { + function ScrollPosition(props) { + const { history } = props; + + const initialScrollTop = + history.action === 'POP' || + (history.location.state && history.location.state.restoreScrollPosition) + ? scrollPositions[scrollPositionKey] + : 0; + + return ; + } + + ScrollPosition.propTypes = { + history: PropTypes.object.isRequired, + }; + + return ScrollPosition; +} + +export default withScrollPosition; diff --git a/frontend/src/Helpers/Hooks/useMeasure.ts b/frontend/src/Helpers/Hooks/useMeasure.ts new file mode 100644 index 000000000..7b36b2844 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useMeasure.ts @@ -0,0 +1,21 @@ +import { ResizeObserver as ResizeObserverPolyfill } from '@juggle/resize-observer'; +import { + default as useMeasureHook, + Options, + RectReadOnly, +} from 'react-use-measure'; + +const ResizeObserver = window.ResizeObserver || ResizeObserverPolyfill; + +export type Measurements = RectReadOnly; + +function useMeasure( + options?: Omit +): ReturnType { + return useMeasureHook({ + polyfill: ResizeObserver, + ...options, + }); +} + +export default useMeasure; diff --git a/frontend/src/Helpers/Props/ScrollDirection.ts b/frontend/src/Helpers/Props/ScrollDirection.ts new file mode 100644 index 000000000..0da932d22 --- /dev/null +++ b/frontend/src/Helpers/Props/ScrollDirection.ts @@ -0,0 +1,8 @@ +enum ScrollDirection { + Horizontal = 'horizontal', + Vertical = 'vertical', + None = 'none', + Both = 'both', +} + +export default ScrollDirection; diff --git a/frontend/src/Quality/Quality.ts b/frontend/src/Quality/Quality.ts new file mode 100644 index 000000000..5be1475fc --- /dev/null +++ b/frontend/src/Quality/Quality.ts @@ -0,0 +1,17 @@ +export interface Revision { + version: number; + real: number; + isRepack: boolean; +} + +interface Quality { + id: number; + name: string; +} + +export interface QualityModel { + quality: Quality; + revision: Revision; +} + +export default Quality; diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js index e54b38df6..aa78daf40 100644 --- a/frontend/src/Store/Actions/artistIndexActions.js +++ b/frontend/src/Store/Actions/artistIndexActions.js @@ -39,6 +39,7 @@ export const defaultState = { showTitle: false, showMonitored: true, showQualityProfile: true, + showNextAlbum: true, showSearchAction: false }, diff --git a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js deleted file mode 100644 index de5205948..000000000 --- a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createSelector } from 'reselect'; -import createArtistSelector from './createArtistSelector'; - -function createArtistMetadataProfileSelector() { - return createSelector( - (state) => state.settings.metadataProfiles.items, - createArtistSelector(), - (metadataProfiles, artist = {}) => { - return metadataProfiles.find((profile) => { - return profile.id === artist.metadataProfileId; - }); - } - ); -} - -export default createArtistMetadataProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts new file mode 100644 index 000000000..0acbd3997 --- /dev/null +++ b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts @@ -0,0 +1,18 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import Artist from 'Artist/Artist'; +import { createArtistSelectorForHook } from './createArtistSelector'; + +function createArtistMetadataProfileSelector(artistId: number) { + return createSelector( + (state: AppState) => state.settings.metadataProfiles.items, + createArtistSelectorForHook(artistId), + (metadataProfiles, artist = {} as Artist) => { + return metadataProfiles.find((profile) => { + return profile.id === artist.metadataProfileId; + }); + } + ); +} + +export default createArtistMetadataProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js deleted file mode 100644 index 5819eb080..000000000 --- a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createSelector } from 'reselect'; -import createArtistSelector from './createArtistSelector'; - -function createArtistQualityProfileSelector() { - return createSelector( - (state) => state.settings.qualityProfiles.items, - createArtistSelector(), - (qualityProfiles, artist = {}) => { - return qualityProfiles.find((profile) => { - return profile.id === artist.qualityProfileId; - }); - } - ); -} - -export default createArtistQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts new file mode 100644 index 000000000..99325276f --- /dev/null +++ b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts @@ -0,0 +1,18 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import Artist from 'Artist/Artist'; +import { createArtistSelectorForHook } from './createArtistSelector'; + +function createArtistQualityProfileSelector(artistId: number) { + return createSelector( + (state: AppState) => state.settings.qualityProfiles.items, + createArtistSelectorForHook(artistId), + (qualityProfiles, artist = {} as Artist) => { + return qualityProfiles.find( + (profile) => profile.id === artist.qualityProfileId + ); + } + ); +} + +export default createArtistQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistSelector.js b/frontend/src/Store/Selectors/createArtistSelector.js index 104ef83e3..c335f37f5 100644 --- a/frontend/src/Store/Selectors/createArtistSelector.js +++ b/frontend/src/Store/Selectors/createArtistSelector.js @@ -1,5 +1,15 @@ import { createSelector } from 'reselect'; +export function createArtistSelectorForHook(artistId) { + return createSelector( + (state) => state.artist.itemMap, + (state) => state.artist.items, + (itemMap, allArtists) => { + return artistId ? allArtists[itemMap[artistId]]: undefined; + } + ); +} + function createArtistSelector() { return createSelector( (state, { artistId }) => artistId, diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTable.js b/frontend/src/UnmappedFiles/UnmappedFilesTable.js index 817826cc1..75a4d1d06 100644 --- a/frontend/src/UnmappedFiles/UnmappedFilesTable.js +++ b/frontend/src/UnmappedFiles/UnmappedFilesTable.js @@ -27,6 +27,8 @@ class UnmappedFilesTable extends Component { constructor(props, context) { super(props, context); + this.scrollerRef = React.createRef(); + this.state = { scroller: null, allSelected: false, @@ -65,13 +67,6 @@ class UnmappedFilesTable extends Component { } } - // - // Control - - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - getSelectedIds = () => { if (this.state.allUnselected) { return []; @@ -184,7 +179,6 @@ class UnmappedFilesTable extends Component { } = this.props; const { - scroller, allSelected, allUnselected, selectedState @@ -227,9 +221,7 @@ class UnmappedFilesTable extends Component { - + { isFetching && !isPopulated && @@ -243,11 +235,14 @@ class UnmappedFilesTable extends Component { } { - isPopulated && !error && !!items.length && scroller && + isPopulated && + !error && + !!items.length && + this.scrollerRef.current ? + /> : + null } diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js index d27cfd604..cec7fb09a 100644 --- a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js +++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js @@ -1,7 +1,5 @@ -import _ from 'lodash'; - export default function getIndexOfFirstCharacter(items, character) { - return _.findIndex(items, (item) => { + return items.findIndex((item) => { const firstCharacter = item.sortName.charAt(0); if (character === '#') { diff --git a/frontend/src/typings/CustomFormat.ts b/frontend/src/typings/CustomFormat.ts new file mode 100644 index 000000000..7cef9f6ef --- /dev/null +++ b/frontend/src/typings/CustomFormat.ts @@ -0,0 +1,12 @@ +export interface QualityProfileFormatItem { + format: number; + name: string; + score: number; +} + +interface CustomFormat { + id: number; + name: string; +} + +export default CustomFormat; diff --git a/frontend/src/typings/MetadataProfile.ts b/frontend/src/typings/MetadataProfile.ts new file mode 100644 index 000000000..a02c99c5a --- /dev/null +++ b/frontend/src/typings/MetadataProfile.ts @@ -0,0 +1,39 @@ +interface PrimaryAlbumType { + id?: number; + name?: string; +} + +interface SecondaryAlbumType { + id?: number; + name?: string; +} + +interface ReleaseStatus { + id?: number; + name?: string; +} + +interface ProfilePrimaryAlbumTypeItem { + primaryAlbumType?: PrimaryAlbumType; + allowed: boolean; +} + +interface ProfileSecondaryAlbumTypeItem { + secondaryAlbumType?: SecondaryAlbumType; + allowed: boolean; +} + +interface ProfileReleaseStatusItem { + releaseStatus?: ReleaseStatus; + allowed: boolean; +} + +interface MetadataProfile { + name: string; + primaryAlbumTypes: ProfilePrimaryAlbumTypeItem[]; + secondaryAlbumTypes: ProfileSecondaryAlbumTypeItem[]; + ReleaseStatuses: ProfileReleaseStatusItem[]; + id: number; +} + +export default MetadataProfile; diff --git a/frontend/src/typings/QualityProfile.ts b/frontend/src/typings/QualityProfile.ts new file mode 100644 index 000000000..ec4e46648 --- /dev/null +++ b/frontend/src/typings/QualityProfile.ts @@ -0,0 +1,23 @@ +import Quality from 'Quality/Quality'; +import { QualityProfileFormatItem } from './CustomFormat'; + +export interface QualityProfileQualityItem { + id?: number; + quality?: Quality; + items: QualityProfileQualityItem[]; + allowed: boolean; + name?: string; +} + +interface QualityProfile { + name: string; + upgradeAllowed: boolean; + cutoff: number; + items: QualityProfileQualityItem[]; + minFormatScore: number; + cutoffFormatScore: number; + formatItems: QualityProfileFormatItem[]; + id: number; +} + +export default QualityProfile; diff --git a/package.json b/package.json index 0dc155f38..d0c9c9c1e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@fortawesome/free-regular-svg-icons": "6.4.0", "@fortawesome/free-solid-svg-icons": "6.4.0", "@fortawesome/react-fontawesome": "0.2.0", + "@juggle/resize-observer": "3.4.0", "@microsoft/signalr": "6.0.21", "@sentry/browser": "7.51.2", "@sentry/integrations": "7.51.2", @@ -76,7 +77,9 @@ "react-slider": "1.1.4", "react-tabs": "3.2.2", "react-text-truncate": "0.19.0", + "react-use-measure": "2.1.1", "react-virtualized": "9.21.1", + "react-window": "1.8.9", "redux": "4.1.0", "redux-actions": "2.6.5", "redux-batched-actions": "0.5.0", diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 04a66a2d2..80e2c3184 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -100,6 +100,7 @@ "ArtistFolderFormat": "Artist Folder Format", "ArtistName": "Artist Name", "ArtistNameHelpText": "The name of the artist/album to exclude (can be anything meaningful)", + "ArtistProgressBarText": "{trackFileCount} / {trackCount} (Total: {totalTrackCount})", "ArtistType": "Artist Type", "Artists": "Artists", "AudioInfo": "Audio Info", @@ -127,6 +128,8 @@ "BackupNow": "Backup Now", "BackupRetentionHelpText": "Automatic backups older than the retention period will be cleaned up automatically", "Backups": "Backups", + "BannerOptions": "Banner Options", + "Banners": "Banners", "BeforeUpdate": "Before Update", "BindAddress": "Bind Address", "BindAddressHelpText": "Valid IP address, localhost or '*' for all interfaces", @@ -185,7 +188,7 @@ "ContinuingAllTracksDownloaded": "Continuing (All tracks downloaded)", "ContinuingMoreAlbumsAreExpected": "More albums are expected", "ContinuingNoAdditionalAlbumsAreExpected": "No additional albums are expected", - "ContinuingOnly": "ContinuingOnly", + "ContinuingOnly": "Continuing Only", "CopyToClipboard": "Copy to clipboard", "CopyUsingHardlinksHelpText": "Hardlinks allow Lidarr to import seeding torrents to the the series folder without taking extra disk space or copying the entire contents of the file. Hardlinks will only work if the source and destination are on the same volume", "CopyUsingHardlinksHelpTextWarning": "Occasionally, file locks may prevent renaming files that are being seeded. You may temporarily disable seeding and use Lidarr's rename function as a work around.", @@ -684,6 +687,8 @@ "Original": "Original", "Other": "Other", "OutputPath": "Output Path", + "Overview": "Overview", + "OverviewOptions": "Overview Options", "PackageVersion": "Package Version", "PageSize": "Page Size", "PageSizeHelpText": "Number of items to show on each page", @@ -698,7 +703,9 @@ "Playlist": "Playlist", "Port": "Port", "PortNumber": "Port Number", + "PosterOptions": "Poster Options", "PosterSize": "Poster Size", + "Posters": "Posters", "PreferAndUpgrade": "Prefer and Upgrade", "PreferProtocol": "Prefer {preferredProtocol}", "PreferTorrent": "Prefer Torrent", @@ -964,6 +971,7 @@ "System": "System", "SystemTimeCheckMessage": "System time is off by more than 1 day. Scheduled tasks may not run correctly until the time is corrected", "TBA": "TBA", + "Table": "Table", "TagAudioFilesWithMetadata": "Tag Audio Files with Metadata", "TagIsNotUsedAndCanBeDeleted": "Tag is not used and can be deleted", "Tags": "Tags", @@ -997,7 +1005,6 @@ "TrackArtist": "Track Artist", "TrackCount": "Track Count", "TrackDownloaded": "Track Downloaded", - "TrackFileCountTrackCountTotalTotalTrackCountInterp": "{0} / {1} (Total: {2})", "TrackFileCounttotalTrackCountTracksDownloadedInterp": "{0}/{1} tracks downloaded", "TrackFiles": "Track Files", "TrackFilesCountMessage": "No track files", diff --git a/yarn.lock b/yarn.lock index 29128e81e..a59d73714 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1202,6 +1202,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@juggle/resize-observer@3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" + integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== + "@microsoft/signalr@6.0.21": version "6.0.21" resolved "https://registry.yarnpkg.com/@microsoft/signalr/-/signalr-6.0.21.tgz#b45f335df7011abba831cb3d7974b58da7e725c7" @@ -2634,6 +2639,11 @@ cuint@^0.2.2: resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b" integrity sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw== +debounce@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" + integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== + debug@^3.1.0, debug@^3.2.6, debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -4474,6 +4484,11 @@ memfs@^3.4.1: dependencies: fs-monkey "^1.0.4" +"memoize-one@>=3.1.1 <6": + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + meow@^9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/meow/-/meow-9.0.0.tgz#cd9510bc5cac9dee7d03c73ee1f9ad959f4ea364" @@ -5538,6 +5553,13 @@ react-themeable@^1.1.0: dependencies: object-assign "^3.0.0" +react-use-measure@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/react-use-measure/-/react-use-measure-2.1.1.tgz#5824537f4ee01c9469c45d5f7a8446177c6cc4ba" + integrity sha512-nocZhN26cproIiIduswYpV5y5lQpSQS1y/4KuvUCjSKmw7ZWIS/+g3aFnX3WdBkyuGUtTLif3UTqnLLhbDoQig== + dependencies: + debounce "^1.2.1" + react-virtualized@9.21.1: version "9.21.1" resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.21.1.tgz#4dbbf8f0a1420e2de3abf28fbb77120815277b3a" @@ -5551,6 +5573,14 @@ react-virtualized@9.21.1: prop-types "^15.6.0" react-lifecycles-compat "^3.0.4" +react-window@1.8.9: + version "1.8.9" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.9.tgz#24bc346be73d0468cdf91998aac94e32bc7fa6a8" + integrity sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" From 28ac6401033978ba1b3fa5d3e4ac22df1f44f852 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 31 Dec 2022 22:17:50 -0800 Subject: [PATCH 074/820] New: Remember add import list exclusion when removing artists (cherry picked from commit d8f6eaebdcf7e9b8170fe2baf1fe232fe02a1331) Closes #3260 Closes #326 --- .../Artist/Delete/DeleteArtistModalContent.js | 20 +++--- .../DeleteArtistModalContentConnector.js | 66 ++++++++----------- frontend/src/Store/Actions/artistActions.js | 24 ++++++- 3 files changed, 58 insertions(+), 52 deletions(-) diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Delete/DeleteArtistModalContent.js index 368c08107..10e19ae20 100644 --- a/frontend/src/Artist/Delete/DeleteArtistModalContent.js +++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.js @@ -23,8 +23,7 @@ class DeleteArtistModalContent extends Component { super(props, context); this.state = { - deleteFiles: false, - addImportListExclusion: false + deleteFiles: false }; } @@ -35,16 +34,11 @@ class DeleteArtistModalContent extends Component { this.setState({ deleteFiles: value }); }; - onAddImportListExclusionChange = ({ value }) => { - this.setState({ addImportListExclusion: value }); - }; - onDeleteArtistConfirmed = () => { const deleteFiles = this.state.deleteFiles; - const addImportListExclusion = this.state.addImportListExclusion; + const addImportListExclusion = this.props.deleteOptions.addImportListExclusion; this.setState({ deleteFiles: false }); - this.setState({ addImportListExclusion: false }); this.props.onDeletePress(deleteFiles, addImportListExclusion); }; @@ -56,7 +50,9 @@ class DeleteArtistModalContent extends Component { artistName, path, statistics, - onModalClose + deleteOptions, + onModalClose, + onDeleteOptionChange } = this.props; const { @@ -65,7 +61,7 @@ class DeleteArtistModalContent extends Component { } = statistics; const deleteFiles = this.state.deleteFiles; - const addImportListExclusion = this.state.addImportListExclusion; + const addImportListExclusion = deleteOptions.addImportListExclusion; let deleteFilesLabel = `Delete ${trackFileCount} Track Files`; let deleteFilesHelpText = translate('DeleteFilesHelpText'); @@ -117,7 +113,7 @@ class DeleteArtistModalContent extends Component { value={addImportListExclusion} helpText={translate('AddImportListExclusionArtistHelpText')} kind={kinds.DANGER} - onChange={this.onAddImportListExclusionChange} + onChange={onDeleteOptionChange} /> @@ -158,6 +154,8 @@ DeleteArtistModalContent.propTypes = { artistName: PropTypes.string.isRequired, path: PropTypes.string.isRequired, statistics: PropTypes.object.isRequired, + deleteOptions: PropTypes.object.isRequired, + onDeleteOptionChange: PropTypes.func.isRequired, onDeletePress: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js b/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js index 5b7fef377..321dc63a6 100644 --- a/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js +++ b/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js @@ -1,56 +1,44 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { deleteArtist } from 'Store/Actions/artistActions'; +import { deleteArtist, setDeleteOption } from 'Store/Actions/artistActions'; import createArtistSelector from 'Store/Selectors/createArtistSelector'; import DeleteArtistModalContent from './DeleteArtistModalContent'; function createMapStateToProps() { return createSelector( + (state) => state.artist.deleteOptions, createArtistSelector(), - (artist) => { - return artist; + (deleteOptions, artist) => { + return { + ...artist, + deleteOptions + }; } ); } -const mapDispatchToProps = { - deleteArtist -}; +function createMapDispatchToProps(dispatch, props) { + return { + onDeleteOptionChange(option) { + dispatch( + setDeleteOption({ + [option.name]: option.value + }) + ); + }, -class DeleteArtistModalContentConnector extends Component { + onDeletePress(deleteFiles, addImportListExclusion) { + dispatch( + deleteArtist({ + id: props.artistId, + deleteFiles, + addImportListExclusion + }) + ); - // - // Listeners - - onDeletePress = (deleteFiles, addImportListExclusion) => { - this.props.deleteArtist({ - id: this.props.artistId, - deleteFiles, - addImportListExclusion - }); - - this.props.onModalClose(true); + props.onModalClose(true); + } }; - - // - // Render - - render() { - return ( - - ); - } } -DeleteArtistModalContentConnector.propTypes = { - artistId: PropTypes.number.isRequired, - onModalClose: PropTypes.func.isRequired, - deleteArtist: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(DeleteArtistModalContentConnector); +export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteArtistModalContent); diff --git a/frontend/src/Store/Actions/artistActions.js b/frontend/src/Store/Actions/artistActions.js index ee81624e0..3a1a404d9 100644 --- a/frontend/src/Store/Actions/artistActions.js +++ b/frontend/src/Store/Actions/artistActions.js @@ -157,9 +157,16 @@ export const defaultState = { items: [], sortKey: 'sortName', sortDirection: sortDirections.ASCENDING, - pendingChanges: {} + pendingChanges: {}, + deleteOptions: { + addImportListExclusion: false + } }; +export const persistState = [ + 'artist.deleteOptions' +]; + // // Actions Types @@ -171,6 +178,8 @@ export const DELETE_ARTIST = 'artist/deleteArtist'; export const TOGGLE_ARTIST_MONITORED = 'artist/toggleArtistMonitored'; export const TOGGLE_ALBUM_MONITORED = 'artist/toggleAlbumMonitored'; +export const SET_DELETE_OPTION = 'artist/setDeleteOption'; + // // Action Creators @@ -211,6 +220,8 @@ export const setArtistValue = createAction(SET_ARTIST_VALUE, (payload) => { }; }); +export const setDeleteOption = createAction(SET_DELETE_OPTION); + // // Helpers @@ -340,6 +351,15 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ - [SET_ARTIST_VALUE]: createSetSettingValueReducer(section) + [SET_ARTIST_VALUE]: createSetSettingValueReducer(section), + + [SET_DELETE_OPTION]: (state, { payload }) => { + return { + ...state, + deleteOptions: { + ...payload + } + }; + } }, defaultState, section); From d7ccb66cf6d7c597a8ea8f1892dc832dbad598ba Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 24 Oct 2023 19:37:00 +0300 Subject: [PATCH 075/820] Use variable for App name in translations Towards #4125 --- frontend/src/Utilities/String/translate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/Utilities/String/translate.ts b/frontend/src/Utilities/String/translate.ts index 0ccfa839b..36a6093d7 100644 --- a/frontend/src/Utilities/String/translate.ts +++ b/frontend/src/Utilities/String/translate.ts @@ -25,7 +25,7 @@ export async function fetchTranslations(): Promise { export default function translate( key: string, - tokens?: Record + tokens: Record = { appName: 'Lidarr' } ) { const translation = translations[key] || key; From f63abedbaeb6ed620ed798b80d17664fbcdcd4f6 Mon Sep 17 00:00:00 2001 From: Weblate Date: Tue, 24 Oct 2023 16:13:21 +0000 Subject: [PATCH 076/820] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: Lizandra Candido da Silva Co-authored-by: Weblate Co-authored-by: bai0012 Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pt/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pt_BR/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/zh_CN/ Translation: Servarr/Lidarr --- src/NzbDrone.Core/Localization/Core/de.json | 1 - src/NzbDrone.Core/Localization/Core/el.json | 1 - src/NzbDrone.Core/Localization/Core/es.json | 1 - src/NzbDrone.Core/Localization/Core/fi.json | 1 - src/NzbDrone.Core/Localization/Core/fr.json | 1 - src/NzbDrone.Core/Localization/Core/hu.json | 1 - src/NzbDrone.Core/Localization/Core/it.json | 1 - src/NzbDrone.Core/Localization/Core/pt.json | 18 ++-- .../Localization/Core/pt_BR.json | 99 ++++++++++--------- src/NzbDrone.Core/Localization/Core/sv.json | 1 - .../Localization/Core/zh_CN.json | 44 ++++++--- 11 files changed, 91 insertions(+), 78 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/de.json b/src/NzbDrone.Core/Localization/Core/de.json index 7183b2cfe..a69571762 100644 --- a/src/NzbDrone.Core/Localization/Core/de.json +++ b/src/NzbDrone.Core/Localization/Core/de.json @@ -436,7 +436,6 @@ "TotalFileSize": "Gesamte Dateigröße", "Track": "Trace", "TrackFileCounttotalTrackCountTracksDownloadedInterp": "{0}/{1} Bücher heruntergeladen", - "TrackFileCountTrackCountTotalTotalTrackCountInterp": "{0} / {1} (Gesamt: {2})", "TrackMissingFromDisk": "Buch fehlt auf der Festplatte", "Type": "Typ", "UISettings": "Benutzeroberflächen Einstellungen", diff --git a/src/NzbDrone.Core/Localization/Core/el.json b/src/NzbDrone.Core/Localization/Core/el.json index 86275a99f..4c19ca821 100644 --- a/src/NzbDrone.Core/Localization/Core/el.json +++ b/src/NzbDrone.Core/Localization/Core/el.json @@ -795,7 +795,6 @@ "TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp": "{0} κομμάτια συνολικά. {1} κομμάτια με αρχεία.", "TrackCount": "Καταμέτρηση κομματιών", "TrackFileCounttotalTrackCountTracksDownloadedInterp": "Λήφθηκαν {0}/{1} κομμάτια", - "TrackFileCountTrackCountTotalTotalTrackCountInterp": "{0} / {1} (Σύνολο: {2})", "TrackFilesCountMessage": "Δεν υπάρχουν αρχεία κομματιών", "EnableRssHelpText": "Θα χρησιμοποιείται όταν το Lidarr αναζητά περιοδικά εκδόσεις μέσω RSS Sync", "EpisodeDoesNotHaveAnAbsoluteEpisodeNumber": "Το επεισόδιο δεν έχει απόλυτο αριθμό επεισοδίου", diff --git a/src/NzbDrone.Core/Localization/Core/es.json b/src/NzbDrone.Core/Localization/Core/es.json index c85ad220a..aa3ec308f 100644 --- a/src/NzbDrone.Core/Localization/Core/es.json +++ b/src/NzbDrone.Core/Localization/Core/es.json @@ -791,7 +791,6 @@ "Loading": "Cargando", "Priority": "Prioridad", "RecentChanges": "Cambios recientes", - "TrackFileCountTrackCountTotalTotalTrackCountInterp": "{0} / {1} (Total: {2})", "WriteMetadataToAudioFiles": "Escribir metadatos en archivos de audio", "ExpandAlbumByDefaultHelpText": "álbum", "AutomaticUpdatesDisabledDocker": "Las actualizaciones automáticas no son compatibles directamente al usar el mecanismo de actualización de Docker. Deberás actualizar la imagen del contenedor fuera de {appName} o utilizar un script", diff --git a/src/NzbDrone.Core/Localization/Core/fi.json b/src/NzbDrone.Core/Localization/Core/fi.json index abd50e8ad..697b0049e 100644 --- a/src/NzbDrone.Core/Localization/Core/fi.json +++ b/src/NzbDrone.Core/Localization/Core/fi.json @@ -684,7 +684,6 @@ "Release": " Julkaisu", "ShouldMonitorExisting": "Valvo olemassa olevia albumeita", "TrackFileCounttotalTrackCountTracksDownloadedInterp": "{0}/{1} kappaletta ladattu", - "TrackFileCountTrackCountTotalTotalTrackCountInterp": "{0}/{1} (yhteensä: {2})", "TrackFilesCountMessage": "Kappaletiedostoja ei ole", "TrackImported": "Kappale tuotiin", "TrackMissingFromDisk": "Levyltä puuttuu kappale", diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index ae21a3b87..8c09beb2c 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -519,7 +519,6 @@ "Country": "Pays", "ExistingAlbumsData": "Surveiller les livres qui ne sont pas encore sortis ou partiellement", "FirstAlbumData": "Surveiller le premier livre. Tous les autres livres seront ignorés", - "TrackFileCountTrackCountTotalTotalTrackCountInterp": "{0} / {1} (Total  : {2})", "AddedArtistSettings": "Ajout des paramètres de l'auteur", "MetadataProfile": "profil de métadonnées", "StatusEndedContinuing": "Continuant", diff --git a/src/NzbDrone.Core/Localization/Core/hu.json b/src/NzbDrone.Core/Localization/Core/hu.json index fa1a2a322..d3ae5e4a2 100644 --- a/src/NzbDrone.Core/Localization/Core/hu.json +++ b/src/NzbDrone.Core/Localization/Core/hu.json @@ -516,7 +516,6 @@ "TrackArtist": "Dal előadója", "TrackDownloaded": "Dal letöltve", "TrackFileCounttotalTrackCountTracksDownloadedInterp": "{0}/{1} dal letöltve", - "TrackFileCountTrackCountTotalTotalTrackCountInterp": "{0} / {1} (összesen: {2})", "TrackFilesCountMessage": "Nincsenek dalfájlok", "TrackMissingFromDisk": "Hiányzik dal a lemezről", "TrackNaming": "Dal elnevezése", diff --git a/src/NzbDrone.Core/Localization/Core/it.json b/src/NzbDrone.Core/Localization/Core/it.json index 3379d149e..53dcfcb9a 100644 --- a/src/NzbDrone.Core/Localization/Core/it.json +++ b/src/NzbDrone.Core/Localization/Core/it.json @@ -504,7 +504,6 @@ "MetadataProfile": "profilo metadati", "MetadataProfiles": "profilo metadati", "TrackFileCounttotalTrackCountTracksDownloadedInterp": "{0}/{1} libri scaricati", - "TrackFileCountTrackCountTotalTotalTrackCountInterp": "{0} / {1} (Totale: {2})", "Activity": "Attività", "AddDelayProfile": "Aggiungi Profilo di Ritardo", "Added": "Aggiunto", diff --git a/src/NzbDrone.Core/Localization/Core/pt.json b/src/NzbDrone.Core/Localization/Core/pt.json index a2cd1be8d..cd31dc82f 100644 --- a/src/NzbDrone.Core/Localization/Core/pt.json +++ b/src/NzbDrone.Core/Localization/Core/pt.json @@ -6,12 +6,12 @@ "DownloadClientSettings": "Definições do cliente de transferências", "DownloadWarningCheckDownloadClientForMoreDetails": "Alerta de transferência: verifique o cliente de transferências para obter mais detalhes", "EnableColorImpairedModeHelpText": "Estilo alterado para permitir que utilizadores com daltonismo possam distinguir melhor os códigos de cores", - "Exception": "Exceção", + "Exception": "Excepção", "Host": "Anfitrião", "IconForCutoffUnmet": "Ícone para Limite não correspondido", "IncludeHealthWarningsHelpText": "Incluir avisos de estado de funcionamento", "ChangeFileDate": "Modificar data do ficheiro", - "DeleteBackupMessageText": "Tem a certeza que quer eliminar a cópia de segurança \"{0}\"?", + "DeleteBackupMessageText": "Tem a certeza que quer eliminar a cópia de segurança \"{name}\"?", "DeleteDelayProfile": "Eliminar perfil de atraso", "DownloadFailedCheckDownloadClientForMoreDetails": "Falha na transferência: verifique o cliente de transferências para obter mais detalhes", "DownloadFailedInterp": "Falha na transferência: {0}", @@ -224,7 +224,7 @@ "ArtistAlbumClickToChangeTrack": "Clique para mudar o livro", "ArtistNameHelpText": "O nome do autor/livro a eliminar (pode ser qualquer palavra)", "Authentication": "Autenticação", - "AuthenticationMethodHelpText": "Solicitar Nome de Usuário e Senha para acessar o Lidarr", + "AuthenticationMethodHelpText": "Solicitar nome de utilizador e palavra-passe para acessar ao {appName}", "AutoRedownloadFailedHelpText": "Procurar automaticamente e tente baixar uma versão diferente", "BackupFolderHelpText": "Caminhos relativos estarão na pasta AppData do Lidarr", "BackupIntervalHelpText": "Intervalo para criar cópia de segurança das configurações e da base de dados do Lidarr", @@ -272,7 +272,7 @@ "DeleteBackup": "Eliminar cópia de segurança", "DeleteDelayProfileMessageText": "Tem a certeza que quer eliminar este perfil de atraso?", "DeleteDownloadClient": "Eliminar cliente de transferências", - "DeleteDownloadClientMessageText": "Tem a certeza que quer eliminar o cliente de transferências \"{0}\"?", + "DeleteDownloadClientMessageText": "Tem a certeza que quer eliminar o cliente de transferências \"{name}\"?", "DeleteEmptyFolders": "Eliminar pastas vazias", "DeleteFilesHelpText": "Eliminar os ficheiros do livro e a pasta do autor", "DeleteImportList": "Eliminar lista de importação", @@ -430,7 +430,7 @@ "DeleteIndexerMessageText": "Tem a certeza que quer eliminar o indexador \"{0}\"?", "DeleteMetadataProfileMessageText": "Tem a certeza que quer eliminar o perfil de qualidade \"{0}\"?", "DeleteNotification": "Eliminar notificação", - "DeleteNotificationMessageText": "Tem a certeza que quer eliminar a notificação \"{0}\"?", + "DeleteNotificationMessageText": "Tem a certeza que quer eliminar a notificação \"{name}\"?", "DeleteQualityProfile": "Eliminar perfil de qualidade", "DeleteRootFolderMessageText": "Tem a certeza que quer eliminar a pasta raiz \"{0}\"?", "DeleteSelectedTrackFiles": "Eliminar ficheiros de livro selecionados", @@ -693,7 +693,7 @@ "ThemeHelpText": "Alterar o tema da interface do usuário. O tema 'Auto' usará o tema do sistema operacional para definir o modo Claro ou Escuro. Inspirado por Theme.Park", "UnableToLoadCustomFormats": "Não foi possível carregar os formatos personalizados", "UnableToLoadInteractiveSearch": "Não foi possível carregar os resultados para esta pesquisa de filme. Tenta novamente mais tarde", - "MaintenanceRelease": "Versão de manutenção: correção de bugs e outras melhorias. Veja o Github Commit History para obter mais detalhes", + "MaintenanceRelease": "Versão de manutenção: reparações de erros e outras melhorias. Consulte o Histórico de Commits do Github para saber mais", "TheArtistFolderStrongpathstrongAndAllOfItsContentWillBeDeleted": "A pasta do filme \"{0}\" e todo o seu conteúdo serão eliminados.", "Theme": "Tema", "EnableRssHelpText": "Será usado quando o Lidarr procurar periodicamente releases via RSS Sync", @@ -713,7 +713,7 @@ "RemotePathMappingCheckWrongOSPath": "O cliente remoto {0} coloca as transferências em {1}, mas esse não é um caminho {2} válido. Revise os mapeamentos de caminho remoto e as definições do cliente de transferências.", "UpdateCheckStartupTranslocationMessage": "Não é possível instalar a atualização porque a pasta de arranque \"{0}\" está em uma pasta de transposição de aplicações.", "UpdateCheckUINotWritableMessage": "Não é possível instalar a atualização porque a pasta da IU \"{0}\" não tem permissões de escrita para o utilizador \"{1}\".", - "AppDataLocationHealthCheckMessage": "Não foi possível atualizar para prevenir apagar a AppData durante a atualização", + "AppDataLocationHealthCheckMessage": "Não foi possível actualizar para prevenir apagar a AppData durante a actualização", "ColonReplacement": "Substituição de dois-pontos", "Disabled": "Desativado", "DownloadClientCheckDownloadingToRoot": "O cliente {0} coloca as transferências na pasta raiz {1}. Não transfira para a pasta raiz.", @@ -788,9 +788,9 @@ "ApplyTagsHelpTextHowToApplyArtists": "Como aplicar etiquetas aos filmes selecionados", "ApplyTagsHelpTextHowToApplyImportLists": "Como aplicar etiquetas às listas de importação selecionadas", "ApplyTagsHelpTextHowToApplyIndexers": "Como aplicar etiquetas aos indexadores selecionados", - "DeleteSelectedDownloadClientsMessageText": "Tem a certeza que quer eliminar o indexador \"{0}\"?", + "DeleteSelectedDownloadClientsMessageText": "Tem a certeza que quer eliminar os {count} clientes de transferência seleccionados?", "DeleteSelectedImportListsMessageText": "Tem a certeza que quer eliminar o indexador \"{0}\"?", - "DeleteSelectedIndexersMessageText": "Tem a certeza que quer eliminar o indexador \"{0}\"?", + "DeleteSelectedIndexersMessageText": "Tem a certeza que quer eliminar os {count} indexadores seleccionados?", "SomeResultsAreHiddenByTheAppliedFilter": "Alguns resultados estão ocultos pelo filtro aplicado", "SuggestTranslationChange": "Sugerir mudança na tradução", "UpdateSelected": "Atualizar selecionado(s)", diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index f4a0d41ea..8b1357102 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -266,8 +266,8 @@ "GoToArtistListing": "Ir para listagem do autor", "GoToInterp": "Ir para {0}", "Grab": "Obter", - "GrabRelease": "Capturar Versão", - "GrabReleaseMessageText": "O Lidarr não conseguiu determinar a qual autor e livro esse lançamento está relacionado. O Lidarr pode não conseguir importar automaticamente este lançamento. Quer obter \"{0}\"?", + "GrabRelease": "Obter lançamento", + "GrabReleaseMessageText": "O Lidarr não conseguiu determinar para qual artista e álbum é este lançamento. O Lidarr pode não conseguir importar automaticamente este lançamento. Deseja obter \"{0}\"?", "GrabSelected": "Obter selecionado", "Group": "Grupo", "HasPendingChangesNoChanges": "Sem alterações", @@ -282,7 +282,7 @@ "IconForCutoffUnmet": "Ícone para limite não atendido", "IfYouDontAddAnImportListExclusionAndTheArtistHasAMetadataProfileOtherThanNoneThenThisAlbumMayBeReaddedDuringTheNextArtistRefresh": "Se você não adicionar uma exclusão à lista de importação e o autor tiver um perfil de metadados diferente de \"Nenhum\", este livro poderá ser adicionado novamente durante a próxima atualização do autor.", "IgnoredAddresses": "Endereços ignorados", - "IgnoredHelpText": "O lançamento será rejeitado se contiver um ou mais desses termos (não diferencia maiúsculas de minúsculas)", + "IgnoredHelpText": "O lançamento será rejeitado se contiver um ou mais destes termos (sem distinção entre maiúsculas e minúsculas)", "IgnoredPlaceHolder": "Adicionar nova restrição", "IllRestartLater": "Reiniciarei mais tarde", "ImportedTo": "Importado para", @@ -301,8 +301,8 @@ "IndexerIdHelpTextWarning": "Usar um indexador específico com as palavras preferidas pode acarretar na obtenção de lançamentos duplicados", "IndexerPriority": "Prioridade do indexador", "Indexers": "Indexadores", - "IndexerSettings": "Configurações do Indexador", - "InteractiveSearch": "Pesquisa Interativa", + "IndexerSettings": "Configurações do indexador", + "InteractiveSearch": "Pesquisa interativa", "Interval": "Intervalo", "IsCutoffCutoff": "Limite", "IsCutoffUpgradeUntilThisQualityIsMetOrExceeded": "Atualizar até que essa qualidade seja alcançada ou excedida", @@ -317,19 +317,19 @@ "LaunchBrowserHelpText": " Abrir o navegador Web e navegar até a página inicial do Lidarr ao iniciar o aplicativo.", "LidarrSupportsAnyDownloadClientThatUsesTheNewznabStandardAsWellAsOtherDownloadClientsListedBelow": "Lidarr suporta muitos clientes de download torrent e usenet.", "LidarrSupportsAnyIndexerThatUsesTheNewznabStandardAsWellAsOtherIndexersListedBelow": "O Lidarr oferece suporte a qualquer indexador que usa o padrão Newznab, além de outros indexadores, listados abaixo.", - "LogLevelvalueTraceTraceLoggingShouldOnlyBeEnabledTemporarily": "O registro de rastreamento deve ser ativado apenas temporariamente", - "LongDateFormat": "Formato de Data Longa", + "LogLevelvalueTraceTraceLoggingShouldOnlyBeEnabledTemporarily": "O registro em log deve ser habilitado apenas temporariamente", + "LongDateFormat": "Formato longo de data", "MaintenanceRelease": "Versão de manutenção: correções de bugs e outros aprimoramentos. Consulte o Histórico de Commit do Github para obter mais detalhes", "ManualDownload": "Download manual", - "ManualImport": "Importação Manual", - "MarkAsFailed": "Marcar como Falha", + "ManualImport": "Importação manual", + "MarkAsFailed": "Marcar como falha", "MarkAsFailedMessageText": "Tem certeza que deseja marcar \"{0}\" como falhado?", - "MaximumLimits": "Limites Máximos", - "MaximumSize": "Tamanho Máximo", - "MaximumSizeHelpText": "Tamanho máximo para um lançamento a ser obtido, em MB. Digite zero para definir como Ilimitado.", + "MaximumLimits": "Limites máximos", + "MaximumSize": "Tamanho máximo", + "MaximumSizeHelpText": "Tamanho máximo, em MB, para obter um lançamento. Zero significa ilimitado", "Mechanism": "Mecanismo", "MediaInfo": "Informações da mídia", - "MediaManagementSettings": "Configurações de Gerenciamento de Mídia", + "MediaManagementSettings": "Configurações de gerenciamento de mídia", "Medium": "Médio", "MediumFormat": "Formato médio", "Message": "Mensagem", @@ -337,21 +337,21 @@ "MetadataProfile": "Perfil de metadados", "MetadataProfileIdHelpText": "Adicionar itens da lista do Perfil de metadados com", "MetadataProfiles": "Perfis de metadados", - "MetadataSettings": "Configurações de Metadados", + "MetadataSettings": "Configurações de metadados", "MIA": "Desaparecidos", - "MinimumAge": "Idade Miníma", - "MinimumAgeHelpText": "Somente Usenet: Idade mínima em minutos dos NZBs antes de serem capturados. Use isso para dar aos novos lançamentos tempo para se propagar para seu provedor usenet.", - "MinimumFreeSpace": "Espaço Livre Mínimo", - "MustNotContain": "Não Deve Conter", - "NamingSettings": "Configurações de Nomes", + "MinimumAge": "Idade miníma", + "MinimumAgeHelpText": "Somente Usenet: idade mínima, em minutos, dos NZBs antes de serem capturados. Use isso para dar aos novos lançamentos tempo para se propagar para seu provedor de Usenet.", + "MinimumFreeSpace": "Mínimo de espaço livre", + "MustNotContain": "Não deve conter", + "NamingSettings": "Configurações de nomenclatura", "New": "Novo", "NoBackupsAreAvailable": "Não há backups disponíveis", - "NoHistory": "Sem histórico.", + "NoHistory": "Não há histórico.", "NoLeaveIt": "Não, deixe", - "NoLimitForAnyRuntime": "Sem limite para qualquer tempo de execução", + "NoLimitForAnyRuntime": "Sem limite para qualquer duração", "NoLogFiles": "Nenhum arquivo de registro", - "NoMinimumForAnyRuntime": "Sem mínimo para qualquer tempo de execução", - "None": "Vazio", + "NoMinimumForAnyRuntime": "Sem mínimo para qualquer duração", + "None": "Nenhum", "NotificationTriggers": "Gatilhos de Notificação", "NoUpdatesAreAvailable": "Não há atualizações disponíveis", "OnDownloadFailureHelpText": "Ao ocorrer falha no download", @@ -423,7 +423,7 @@ "Remove": "Remover", "RemoveCompletedDownloadsHelpText": "Remover downloads importados do histórico do cliente de download", "RemoveFailedDownloadsHelpText": "Remova downloads com falha do histórico do cliente de download", - "RequiredHelpText": "A versão deve conter pelo menos um desses termos (não diferencia maiúsculas de minúsculas)", + "RequiredHelpText": "O lançamento deve conter pelo menos um desses termos (sem distinção entre maiúsculas e minúsculas)", "RequiredPlaceHolder": "Adicionar nova restrição", "RequiresRestartToTakeEffect": "Requer reiniciar para ter efeito", "RescanAfterRefreshHelpText": "Verificar novamente a pasta de autor após atualizar o autor", @@ -491,7 +491,6 @@ "TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp": "{0} livro(s) no total. {1} livro(s) com arquivos.", "Track": "Rastreamento", "TrackFileCounttotalTrackCountTracksDownloadedInterp": "{0}/{1} livros baixados", - "TrackFileCountTrackCountTotalTotalTrackCountInterp": "{0}/{1} (Total: {2})", "TrackMissingFromDisk": "Livro ausente do disco", "TrackNumber": "Número da faixa", "DelayingDownloadUntilInterp": "Atrasando o download até {0} às {1}", @@ -546,24 +545,24 @@ "LidarrTags": "Tags do Lidarr", "LoadingTrackFilesFailed": "Falha ao carregar arquivos do livro", "Local": "Local", - "LocalPath": "Caminho Local", + "LocalPath": "Caminho local", "LocalPathHelpText": "Caminho que o Lidarr deve usar para acessar o caminho remoto localmente", "LogFiles": "Arquivos de registro", - "Logging": "Registrando", - "LogLevel": "Nível de Registro", + "Logging": "Registro em log", + "LogLevel": "Nível de registro", "Logs": "Registros", "MinimumFreeSpaceWhenImportingHelpText": "Impedir a importação se deixar menos do que esta quantidade de espaço em disco disponível", - "MinimumLimits": "Limites Mínimos", + "MinimumLimits": "Limites mínimos", "Missing": "Ausente", "Mode": "Modo", "Monitored": "Monitorado", - "MonitoringOptions": "Opções de Monitoramento", + "MonitoringOptions": "Opções de monitoramento", "MoreInfo": "Mais informações", "MusicBrainzArtistID": "ID do autor no MusicBrainz", "MusicBrainzRecordingID": "ID da gravação no MusicBrainz", "MusicBrainzReleaseID": "ID do lançamento no MusicBrainz", "MusicBrainzTrackID": "ID da faixa no MusicBrainz", - "MustContain": "Deve Conter", + "MustContain": "Deve conter", "Name": "Nome", "Profiles": "Perfis", "Proper": "Proper", @@ -624,7 +623,7 @@ "MissingTracksArtistMonitored": "Faixas Ausentes (Artista monitorado)", "MissingTracksArtistNotMonitored": "Faixas Ausentes (Artista não monitorado)", "MonitorArtist": "Monitorar Artista", - "MonitoredHelpText": "Baixar álbuns monitorados para este artista", + "MonitoredHelpText": "Baixar álbuns monitorados deste artista", "MultiDiscTrackFormat": "Formato de Faixa Multi-Disco", "MusicBrainzAlbumID": "ID do Álbum no MusicBrainz", "NoneData": "Nenhum álbum irá ser monitorado", @@ -723,10 +722,10 @@ "HideAdvanced": "Ocultar opções avançadas", "Ignored": "Ignorado", "IndexerDownloadClientHelpText": "Especifique qual cliente de download é usado para baixar deste indexador", - "IndexerTagHelpText": "Use este indexador apenas para artistas com pelo menos uma tag correspondente. Deixe em branco para usar com todos os artistas.", - "InstanceName": "Nome da Instância", + "IndexerTagHelpText": "Usar este indexador apenas para artistas com pelo menos uma tag correspondente. Deixe em branco para usar com todos os artistas.", + "InstanceName": "Nome da instância", "InstanceNameHelpText": "Nome da instância na aba e para o nome do aplicativo Syslog", - "InteractiveImport": "Importação Interativa", + "InteractiveImport": "Importação interativa", "LastAlbum": "Último Álbum", "LastDuration": "Última Duração", "LastExecution": "Última Execução", @@ -738,9 +737,9 @@ "MassEditor": "Editor em Massa", "MediaManagement": "Gerenciamento de mídia", "Metadata": "Metadados", - "MonitoredOnly": "Somente monitorado", - "MoveAutomatically": "Mover Automaticamente", - "MoveFiles": "Mover Arquivos", + "MonitoredOnly": "Somente monitorados", + "MoveAutomatically": "Mover automaticamente", + "MoveFiles": "Mover arquivos", "Never": "Nunca", "NextExecution": "Próxima Execução", "NoTagsHaveBeenAddedYet": "Nenhuma tag foi adicionada ainda", @@ -792,7 +791,7 @@ "Import": "Importar", "Activity": "Atividade", "Always": "Sempre", - "Info": "Info", + "Info": "Informações", "AddConnection": "Adicionar conexão", "EditMetadataProfile": "Editar perfil de metadados", "AddReleaseProfile": "Adicionar perfil de lançamento", @@ -858,7 +857,7 @@ "BypassIfHighestQuality": "Ignorar se a qualidade é mais alta", "BypassIfHighestQualityHelpText": "Ignorar atraso quando o lançamento tiver a qualidade mais alta habilitada no perfil de qualidade com o protocolo preferido", "CustomFormatScore": "Pontuação do formato personalizado", - "MinimumCustomFormatScore": "Pontuação Mínima de Formato Personalizado", + "MinimumCustomFormatScore": "Pontuação mínima de formato personalizado", "MinimumCustomFormatScoreHelpText": "Pontuação mínima de formato personalizado necessária para ignorar o atraso do protocolo preferido", "UnableToLoadInteractiveSearch": "Não foi possível carregar os resultados desta pesquisa de álbum. Tente mais tarde", "UnableToLoadCustomFormats": "Não foi possível carregar os formatos personalizados", @@ -977,7 +976,7 @@ "DeleteCondition": "Excluir condição", "DeleteConditionMessageText": "Tem certeza de que deseja excluir a condição '{name}'?", "Negated": "Negado", - "NoHistoryBlocklist": "Sem histórico na lista de bloqueio", + "NoHistoryBlocklist": "Não há lista de bloqueio no histórico", "RemoveSelectedItemBlocklistMessageText": "Tem certeza de que deseja remover os itens selecionados da lista de bloqueio?", "RemoveSelectedItem": "Remover item selecionado", "RemoveSelectedItems": "Remover itens selecionados", @@ -988,9 +987,9 @@ "BlocklistReleaseHelpText": "Impede que o Lidarr obtenha automaticamente esses arquivos novamente", "FailedToLoadQueue": "Falha ao carregar a fila", "QueueIsEmpty": "A fila está vazia", - "NoCutoffUnmetItems": "Nenhum item de corte não atendido", + "NoCutoffUnmetItems": "Nenhum item com limite não atendido", "NoEventsFound": "Não foram encontrados eventos", - "NoMissingItems": "Nenhum item faltando", + "NoMissingItems": "Nenhum item ausente", "DownloadClientSortingCheckMessage": "O cliente de download {0} tem classificação {1} habilitada para a categoria de Lidarr. Você deve desativar a classificação em seu cliente de download para evitar problemas de importação.", "CountIndexersSelected": "{selectedCount} indexador(es) selecionado(s)", "ManageImportLists": "Gerenciar listas de importação", @@ -1081,11 +1080,11 @@ "ImportListRootFolderMissingRootHealthCheckMessage": "Pasta raiz ausente para lista(s) de importação: {0}", "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Múltiplas pastas raiz estão faltando nas listas de importação: {0}", "PreferProtocol": "Preferir {preferredProtocol}", - "HealthMessagesInfoBox": "Você pode encontrar mais informações sobre a causa dessas mensagens de verificação de integridade clicando no link da wiki (ícone do livro) no final da linha ou verificando seus [logs]({link}). Se tiver dificuldade em interpretar essas mensagens, você pode entrar em contato com nosso suporte, nos links abaixo.", + "HealthMessagesInfoBox": "Para saber mais sobre a causa dessas mensagens de verificação de integridade, clique no link da wiki (ícone de livro) no final da linha ou verifique os [logs]({link}). Se tiver dificuldade em interpretar essas mensagens, entre em contato com nosso suporte nos links abaixo.", "DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "O cliente de download {0} está configurado para remover downloads concluídos. Isso pode resultar na remoção dos downloads do seu cliente antes que {1} possa importá-los.", "InfoUrl": "URL da info", "GrabId": "Obter ID", - "InvalidUILanguage": "Sua IU está configurada com um idioma inválido, corrija-a e salve suas configurações", + "InvalidUILanguage": "A interface está configurada com um idioma inválido, corrija-o e salve as configurações", "AuthBasic": "Básico (pop-up do navegador)", "AuthenticationRequired": "Autenticação exigida", "AuthenticationMethod": "Método de autenticação", @@ -1097,5 +1096,13 @@ "AuthForm": "Formulário (página de login)", "AuthenticationMethodHelpTextWarning": "Selecione um método de autenticação válido", "AuthenticationRequiredPasswordHelpTextWarning": "Digite uma nova senha", - "Auto": "Automático" + "Auto": "Automático", + "BannerOptions": "Opções do banner", + "Banners": "Banners", + "Overview": "Visão geral", + "OverviewOptions": "Opções da visão geral", + "PosterOptions": "Opções do pôster", + "Posters": "Pôsteres", + "ArtistProgressBarText": "{trackFileCount}/{trackCount} (Total: {totalTrackCount})", + "Table": "Tabela" } diff --git a/src/NzbDrone.Core/Localization/Core/sv.json b/src/NzbDrone.Core/Localization/Core/sv.json index e4eb26576..de71b96a2 100644 --- a/src/NzbDrone.Core/Localization/Core/sv.json +++ b/src/NzbDrone.Core/Localization/Core/sv.json @@ -292,7 +292,6 @@ "DeleteRootFolderMessageText": "Är du säker på att du vill ta bort indexeraren '{0}'?", "DeleteTrackFileMessageText": "Är du säker på att du vill radera {0}?", "ResetAPIKeyMessageText": "Är du säker på att du vill nollställa din API-nyckel?", - "TrackFileCountTrackCountTotalTotalTrackCountInterp": "{0} / {1} (Totalt: {2})", "MaintenanceRelease": "Underhållsutgåva", "Usenet": "Usenet", "MaximumSizeHelpText": "Maximal storlek för att en utgåva ska fångas i MB. Ställ in på noll för att ställa in på obegränsad.", diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json index 8bdd6cf75..46cc42f18 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_CN.json +++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json @@ -141,7 +141,7 @@ "SSLCertPath": "SSL证书路径", "SSLPort": "SSL端口", "Settings": "设置", - "ShowSearch": "显示搜索按钮", + "ShowSearch": "显示搜索", "Source": "来源", "StartupDirectory": "启动目录", "Status": "状态", @@ -174,7 +174,7 @@ "Automatic": "自动化", "AlreadyInYourLibrary": "已经在你的库中", "Actions": "动作", - "AddListExclusion": "添加排除列表", + "AddListExclusion": "添加列表例外", "MinimumAge": "最低间隔", "MinimumFreeSpaceWhenImportingHelpText": "如果导入的磁盘空间不足,则禁止导入", "Absolute": "绝对", @@ -196,7 +196,7 @@ "AllowFingerprinting": "允许指纹识别", "AllowFingerprintingHelpText": "利用指纹技术提高航迹匹配的准确性", "AllowFingerprintingHelpTextWarning": "这需要读取部分文件,这将减慢扫描速度,并可能导致磁盘或网络活动频繁。", - "AlternateTitles": "替代名称", + "AlternateTitles": "备选标题", "MinimumAgeHelpText": "仅限Usenet:抓取NewzBin文件的最小时间间隔(分钟)。开启此功能会让新版本有时间传播到你的usenet提供商。", "OnApplicationUpdateHelpText": "在程序更新时", "OnApplicationUpdate": "程序更新时", @@ -218,7 +218,7 @@ "AutomaticallySwitchRelease": "自动切换发行版本", "ApiKeyHelpTextWarning": "需重启以生效", "Artist": "艺术家", - "AuthenticationMethodHelpText": "需要用户名和密码以访问 Lidarr", + "AuthenticationMethodHelpText": "需要用户名和密码以访问 {appName}", "Artists": "艺术家", "ArtistFolderFormat": "艺术家文件夹格式", "ArtistEditor": "艺术家编辑器", @@ -255,7 +255,7 @@ "OnUpgrade": "升级中", "OnUpgradeHelpText": "升级中", "Original": "原始的", - "PosterSize": "海报尺寸", + "PosterSize": "海报大小", "PriorityHelpText": "搜刮器优先级从1(最高)到50(最低),默认25,优先级当搜刮结果评分相同时使用,并不是搜刮器的启动顺序。", "Proper": "合适的", "PropersAndRepacks": "适合的和重封装的Propers and Repacks", @@ -306,7 +306,7 @@ "RSSSyncInterval": "RSS同步间隔", "RssSyncIntervalHelpText": "间隔时间以分钟为单位,设置为0则关闭该功能(会停止所有歌曲的自动抓取下载)", "SearchAll": "搜索全部", - "ShowQualityProfile": "显示媒体质量配置", + "ShowQualityProfile": "显示质量配置文件", "ShowQualityProfileHelpText": "在海报下方显示媒体质量配置", "ShowRelativeDates": "显示相对日期", "ShowRelativeDatesHelpText": "显示相对日期(今天昨天等)或绝对日期", @@ -335,7 +335,7 @@ "TimeFormat": "时间格式", "TorrentDelay": "Torrent延时", "TorrentDelayHelpText": "延迟几分钟等待获取洪流", - "TotalFileSize": "总文件体积", + "TotalFileSize": "文件总大小", "Track": "追踪", "UILanguageHelpTextWarning": "浏览器需重新加载", "UnableToAddANewImportListExclusionPleaseTryAgain": "无法添加新排除列表,请再试一次。", @@ -367,7 +367,7 @@ "RescanArtistFolderAfterRefresh": "刷新后重新扫描歌手文件夹", "ResetAPIKeyMessageText": "您确定要重置您的 API 密钥吗?", "SearchForMissing": "搜索缺少", - "ShowDateAdded": "显示添加日期", + "ShowDateAdded": "显示加入时间", "ShowMonitored": "显示监控中的歌曲", "ShowMonitoredHelpText": "在海报下显示监控状态", "ShownAboveEachColumnWhenWeekIsTheActiveView": "当使用周视图时显示上面的每一列", @@ -444,10 +444,10 @@ "Label": "标签", "LaunchBrowserHelpText": " 启动浏览器时导航到Radarr主页。", "Level": "等级", - "ShowSizeOnDisk": "显示占用磁盘体积", + "ShowSizeOnDisk": "显示已用空间", "UnableToLoadHistory": "无法加载历史记录。", "Folders": "文件夹", - "PreviewRename": "预览重命名", + "PreviewRename": "重命名预览", "Profiles": "配置", "Real": "真的", "SslPortHelpTextWarning": "需重启以生效", @@ -530,7 +530,6 @@ "SearchMonitored": "搜索已监控", "ShowTitleHelpText": "在海报下显示作者姓名", "TrackFileCounttotalTrackCountTracksDownloadedInterp": "{0}/{1} 书籍已下载", - "TrackFileCountTrackCountTotalTotalTrackCountInterp": "{0} / {1} (全部: {2})", "UnableToLoadMetadataProviderSettings": "无法加载元数据源设置", "UnmappedFiles": "未映射文件", "UpdatingIsDisabledInsideADockerContainerUpdateTheContainerImageInstead": "更新在docker容器内被禁用. 改为更新容器映像。", @@ -1000,7 +999,7 @@ "SomeResultsAreHiddenByTheAppliedFilter": "部分结果已被过滤隐藏", "SuggestTranslationChange": "建议翻译改变 Suggest translation change", "BlocklistReleaseHelpText": "防止Radarr再次自动抓取此版本", - "UpdateSelected": "更新已选", + "UpdateSelected": "更新选择的内容", "DeleteConditionMessageText": "您确定要删除条件“{name}”吗?", "DownloadClientSortingCheckMessage": "下载客户端 {0} 为 Radarr 的类别启用了 {1} 排序。 您应该在下载客户端中禁用排序以避免导入问题。", "FailedToLoadQueue": "读取队列失败", @@ -1056,7 +1055,7 @@ "Enabled": "已启用", "NoMissingItems": "没有缺失的项目", "Priority": "优先级", - "RemotePathMappingsInfo": "很少需要远程路径映射,如果{app}和您的下载客户端在同一系统上,最好匹配您的路径。有关详细信息,请参阅[wiki]({wikiLink})", + "RemotePathMappingsInfo": "很少需要远程路径映射,如果{app}和您的下载客户端在同一系统上,则最好匹配您的路径。更多信息,请参阅[wiki]({wikiLink})", "Total": "全部的", "MinimumCustomFormatScoreHelpText": "跳过首选协议延迟所需的最低自定义格式分数", "SmartReplace": "智能替换", @@ -1082,8 +1081,23 @@ "AddNewArtist": "添加新艺术家", "DeleteSelected": "删除所选", "FilterArtistPlaceholder": "过滤艺术家", - "DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "下载客户端{0}设置为删除已完成的下载。这可能导致在{1}可以导入下载之前从您的客户端删除下载。", + "DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "下载客户端 {0} 已被设置为删除已完成的下载。这可能导致在 {1} 导入之前,已下载的文件会被从您的客户端中移除。", "InfoUrl": "信息 URL", "GrabId": "抓取ID", - "InvalidUILanguage": "您的UI设置的语言无效,请纠正它并保存设置" + "InvalidUILanguage": "您的UI设置的语言无效,请纠正它并保存设置", + "AuthenticationMethod": "认证方式", + "AuthenticationMethodHelpTextWarning": "请选择一个有效的身份验证方式", + "AuthenticationRequired": "需要身份验证", + "Auto": "自动", + "Banners": "横幅", + "BannerOptions": "横幅选项", + "AuthenticationRequiredPasswordHelpTextWarning": "请输入新密码", + "AuthenticationRequiredUsernameHelpTextWarning": "请输入新用户名", + "AuthenticationRequiredHelpText": "更改身份验证的请求。除非您了解风险,否则请勿更改。", + "AuthenticationRequiredWarning": "为了防止未经身份验证的远程访问,{appName} 现在需要启用身份验证。您可以禁用本地地址的身份验证。", + "DisabledForLocalAddresses": "在本地地址上禁用", + "Overview": "概览", + "OverviewOptions": "概览选项", + "PosterOptions": "海报选项", + "Posters": "海报" } From 897357ff3129a9b53b89e62418c4ac4c6176b198 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 24 Oct 2023 22:43:47 +0300 Subject: [PATCH 077/820] Fixed: Don't die when the artist is missing for leftover albums --- src/Lidarr.Api.V1/Albums/AlbumController.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Lidarr.Api.V1/Albums/AlbumController.cs b/src/Lidarr.Api.V1/Albums/AlbumController.cs index 321d8f500..7028e8d6d 100644 --- a/src/Lidarr.Api.V1/Albums/AlbumController.cs +++ b/src/Lidarr.Api.V1/Albums/AlbumController.cs @@ -73,15 +73,13 @@ namespace Lidarr.Api.V1.Albums foreach (var album in albums) { - album.Artist = artists[album.ArtistMetadataId]; - if (releases.TryGetValue(album.Id, out var albumReleases)) + if (!artists.TryGetValue(album.ArtistMetadataId, out var albumArtist)) { - album.AlbumReleases = albumReleases; - } - else - { - album.AlbumReleases = new List(); + continue; } + + album.Artist = albumArtist; + album.AlbumReleases = releases.TryGetValue(album.Id, out var albumReleases) ? albumReleases : new List(); } return MapToResource(albums, false); From 6a65539ae6609b268762f8e0d71b88b55bf380b4 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 25 Oct 2023 11:03:04 +0300 Subject: [PATCH 078/820] Fix loading album studio page with react-window --- frontend/src/AlbumStudio/AlbumStudio.js | 23 ++++++++----------- .../src/UnmappedFiles/UnmappedFilesTable.js | 1 - 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/frontend/src/AlbumStudio/AlbumStudio.js b/frontend/src/AlbumStudio/AlbumStudio.js index fb4d1fa4d..200b7ffe4 100644 --- a/frontend/src/AlbumStudio/AlbumStudio.js +++ b/frontend/src/AlbumStudio/AlbumStudio.js @@ -52,9 +52,10 @@ class AlbumStudio extends Component { constructor(props, context) { super(props, context); + this.scrollerRef = React.createRef(); + this.state = { estimatedRowSize: 100, - scroller: null, jumpBarItems: { order: [] }, scrollIndex: null, jumpCount: 0, @@ -111,13 +112,6 @@ class AlbumStudio extends Component { } } - // - // Control - - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - setJumpBarItems() { const { items, @@ -325,7 +319,6 @@ class AlbumStudio extends Component { allSelected, allUnselected, estimatedRowSize, - scroller, jumpBarItems, scrollIndex } = this.state; @@ -348,7 +341,7 @@ class AlbumStudio extends Component {
@@ -363,13 +356,16 @@ class AlbumStudio extends Component { } { - !error && isPopulated && !!items.length && + !error && + isPopulated && + !!items.length && + this.scrollerRef.current ?
-
+
: + null } { diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTable.js b/frontend/src/UnmappedFiles/UnmappedFilesTable.js index 75a4d1d06..a326f91e0 100644 --- a/frontend/src/UnmappedFiles/UnmappedFilesTable.js +++ b/frontend/src/UnmappedFiles/UnmappedFilesTable.js @@ -30,7 +30,6 @@ class UnmappedFilesTable extends Component { this.scrollerRef = React.createRef(); this.state = { - scroller: null, allSelected: false, allUnselected: false, lastToggled: null, From 690b2c72c8c21344d867a63cb783b0e6df452874 Mon Sep 17 00:00:00 2001 From: Robin Dadswell Date: Sun, 13 Dec 2020 17:58:56 +0000 Subject: [PATCH 079/820] New: Added Artist Monitoring Toggle to Artist Details (cherry picked from commit 0ff889c3be1e8ee48759b233aeabf1bf1c4b4ff4) --- .../ArtistMonitoringOptionsPopoverContent.css | 5 ++ ...stMonitoringOptionsPopoverContent.css.d.ts | 7 ++ .../ArtistMonitoringOptionsPopoverContent.js | 5 +- frontend/src/Artist/Details/ArtistDetails.js | 24 ++++++ .../MonitoringOptionModalConnector.js | 39 ++++++++++ .../MonitoringOptionsModal.js | 25 ++++++ .../MonitoringOptionsModalContent.css | 9 +++ .../MonitoringOptionsModalContent.css.d.ts | 8 ++ .../MonitoringOptionsModalContent.js | 43 ++++++----- .../MonitoringOptionsModalContentConnector.js | 76 +++++++++++++++++++ .../src/Store/Actions/albumStudioActions.js | 12 +-- frontend/src/Store/Actions/artistActions.js | 60 ++++++++++++++- src/NzbDrone.Core/Localization/Core/en.json | 1 + 13 files changed, 288 insertions(+), 26 deletions(-) create mode 100644 frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css create mode 100644 frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css.d.ts create mode 100644 frontend/src/Artist/MonitoringOptions/MonitoringOptionModalConnector.js create mode 100644 frontend/src/Artist/MonitoringOptions/MonitoringOptionsModal.js create mode 100644 frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css create mode 100644 frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css.d.ts create mode 100644 frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContentConnector.js diff --git a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css new file mode 100644 index 000000000..7393b9c35 --- /dev/null +++ b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css @@ -0,0 +1,5 @@ +.message { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css.d.ts b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css.d.ts new file mode 100644 index 000000000..65c237dff --- /dev/null +++ b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js index 653e313e1..d53bda8e3 100644 --- a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js +++ b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js @@ -2,14 +2,17 @@ import React from 'react'; import Alert from 'Components/Alert'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; +import styles from './ArtistMonitoringOptionsPopoverContent.css'; function ArtistMonitoringOptionsPopoverContent() { return ( <> - + This is a one time adjustment to set which albums are monitored + { + this.setState({ isMonitorOptionsModalOpen: true }); + }; + + onMonitorOptionsClose = () => { + this.setState({ isMonitorOptionsModalOpen: false }); + }; + onExpandAllPress = () => { const { allExpanded, @@ -224,6 +234,7 @@ class ArtistDetails extends Component { isArtistHistoryModalOpen, isInteractiveImportModalOpen, isInteractiveSearchModalOpen, + isMonitorOptionsModalOpen, allExpanded, allCollapsed, expandedState @@ -319,6 +330,12 @@ class ArtistDetails extends Component { + + + @@ -689,6 +707,12 @@ class ArtistDetails extends Component { artistId={id} onModalClose={this.onInteractiveSearchModalClose} /> + +
); diff --git a/frontend/src/Artist/MonitoringOptions/MonitoringOptionModalConnector.js b/frontend/src/Artist/MonitoringOptions/MonitoringOptionModalConnector.js new file mode 100644 index 000000000..6ea9e8290 --- /dev/null +++ b/frontend/src/Artist/MonitoringOptions/MonitoringOptionModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import MonitoringOptionsModal from './MonitoringOptionsModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class MonitoringOptionsModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'artist' }); + this.props.onModalClose(); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +MonitoringOptionsModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(MonitoringOptionsModalConnector); diff --git a/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModal.js b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModal.js new file mode 100644 index 000000000..4071e7f9a --- /dev/null +++ b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import MonitoringOptionsModalContentConnector from './MonitoringOptionsModalContentConnector'; + +function MonitoringOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +MonitoringOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default MonitoringOptionsModal; diff --git a/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css new file mode 100644 index 000000000..3e9e3ffd0 --- /dev/null +++ b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css @@ -0,0 +1,9 @@ +.labelIcon { + margin-left: 8px; +} + +.message { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css.d.ts b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css.d.ts new file mode 100644 index 000000000..af0f6cd46 --- /dev/null +++ b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'labelIcon': string; + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.js b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.js index 230b22f2a..b1550d39e 100644 --- a/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.js +++ b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.js @@ -1,18 +1,22 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent'; import Alert from 'Components/Alert'; import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; import FormLabel from 'Components/Form/FormLabel'; +import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; 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 Popover from 'Components/Tooltip/Popover'; +import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; +import styles from './MonitoringOptionsModalContent.css'; const NO_CHANGE = 'noChange'; @@ -51,8 +55,7 @@ class MonitoringOptionsModalContent extends Component { onSavePress = () => { const { - onSavePress, - isSaving + onSavePress } = this.props; const { monitor @@ -61,14 +64,6 @@ class MonitoringOptionsModalContent extends Component { if (monitor !== NO_CHANGE) { onSavePress({ monitor }); } - - if (!isSaving) { - this.onModalClose(); - } - }; - - onModalClose = () => { - this.props.onModalClose(); }; // @@ -89,19 +84,31 @@ class MonitoringOptionsModalContent extends Component { return ( - {translate('MonitorAlbum')} + {translate('MonitorArtist')} - -
- {translate('MonitorAlbumExistingOnlyWarning')} -
+ + {translate('MonitorAlbumExistingOnlyWarning')}
- {translate('Monitoring')} + + {translate('MonitorExistingAlbums')} + + + } + title={translate('MonitoringOptions')} + body={} + position={tooltipPositions.RIGHT} + /> + state.artist, + (artistState) => { + const { + isSaving, + saveError + } = artistState; + + return { + isSaving, + saveError + }; + } + ); +} + +const mapDispatchToProps = { + dispatchUpdateMonitoringOptions: updateArtistsMonitor +}; + +class MonitoringOptionsModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(true); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ name, value }); + }; + + onSavePress = ({ monitor }) => { + this.props.dispatchUpdateMonitoringOptions({ + artistIds: [this.props.artistId], + monitor + }); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +MonitoringOptionsModalContentConnector.propTypes = { + artistId: PropTypes.number.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchUpdateMonitoringOptions: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MonitoringOptionsModalContentConnector); diff --git a/frontend/src/Store/Actions/albumStudioActions.js b/frontend/src/Store/Actions/albumStudioActions.js index 0f543a7d6..39fbe8d20 100644 --- a/frontend/src/Store/Actions/albumStudioActions.js +++ b/frontend/src/Store/Actions/albumStudioActions.js @@ -102,21 +102,21 @@ export const actionHandlers = handleThunks({ [SAVE_ALBUM_STUDIO]: function(getState, payload, dispatch) { const { artistIds, - monitored, monitor, + monitored, monitorNewItems } = payload; - const artist = []; + const artists = []; artistIds.forEach((id) => { - const artistToUpdate = { id }; + const artistsToUpdate = { id }; if (payload.hasOwnProperty('monitored')) { - artistToUpdate.monitored = monitored; + artistsToUpdate.monitored = monitored; } - artist.push(artistToUpdate); + artists.push(artistsToUpdate); }); dispatch(set({ @@ -128,7 +128,7 @@ export const actionHandlers = handleThunks({ url: '/albumStudio', method: 'POST', data: JSON.stringify({ - artist, + artist: artists, monitoringOptions: { monitor }, monitorNewItems }), diff --git a/frontend/src/Store/Actions/artistActions.js b/frontend/src/Store/Actions/artistActions.js index 3a1a404d9..97b30972b 100644 --- a/frontend/src/Store/Actions/artistActions.js +++ b/frontend/src/Store/Actions/artistActions.js @@ -2,11 +2,12 @@ import _ from 'lodash'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props'; +import { fetchAlbums } from 'Store/Actions/albumActions'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate'; import translate from 'Utilities/String/translate'; -import { updateItem } from './baseActions'; +import { set, updateItem } from './baseActions'; import createFetchHandler from './Creators/createFetchHandler'; import createHandleActions from './Creators/createHandleActions'; import createRemoveItemHandler from './Creators/createRemoveItemHandler'; @@ -177,6 +178,7 @@ export const DELETE_ARTIST = 'artist/deleteArtist'; export const TOGGLE_ARTIST_MONITORED = 'artist/toggleArtistMonitored'; export const TOGGLE_ALBUM_MONITORED = 'artist/toggleAlbumMonitored'; +export const UPDATE_ARTISTS_MONITOR = 'artist/updateArtistsMonitor'; export const SET_DELETE_OPTION = 'artist/setDeleteOption'; @@ -212,6 +214,7 @@ export const deleteArtist = createThunk(DELETE_ARTIST, (payload) => { export const toggleArtistMonitored = createThunk(TOGGLE_ARTIST_MONITORED); export const toggleAlbumMonitored = createThunk(TOGGLE_ALBUM_MONITORED); +export const updateArtistsMonitor = createThunk(UPDATE_ARTISTS_MONITOR); export const setArtistValue = createAction(SET_ARTIST_VALUE, (payload) => { return { @@ -342,6 +345,61 @@ export const actionHandlers = handleThunks({ seasons: artist.seasons })); }); + }, + + [UPDATE_ARTISTS_MONITOR]: function(getState, payload, dispatch) { + const { + artistIds, + monitor, + monitored, + monitorNewItems + } = payload; + + const artists = []; + + artistIds.forEach((id) => { + const artistsToUpdate = { id }; + + if (monitored != null) { + artistsToUpdate.monitored = monitored; + } + + artists.push(artistsToUpdate); + }); + + dispatch(set({ + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: '/albumStudio', + method: 'POST', + data: JSON.stringify({ + artist: artists, + monitoringOptions: { monitor }, + monitorNewItems + }), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(fetchAlbums({ artistId: artistIds[0] })); + + dispatch(set({ + section, + isSaving: false, + saveError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); } }); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 80e2c3184..84f7161e6 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -98,6 +98,7 @@ "ArtistClickToChangeAlbum": "Click to change album", "ArtistEditor": "Artist Editor", "ArtistFolderFormat": "Artist Folder Format", + "ArtistMonitoring": "Artist Monitoring", "ArtistName": "Artist Name", "ArtistNameHelpText": "The name of the artist/album to exclude (can be anything meaningful)", "ArtistProgressBarText": "{trackFileCount} / {trackCount} (Total: {totalTrackCount})", From 661338f5b19c6d106ce174155979b98c78a0c362 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 27 Oct 2023 14:36:45 +0300 Subject: [PATCH 080/820] Allow changing monitoring the artist without the albums --- .../Music/Services/AlbumMonitoredService.cs | 132 ++++++++++-------- 1 file changed, 70 insertions(+), 62 deletions(-) diff --git a/src/NzbDrone.Core/Music/Services/AlbumMonitoredService.cs b/src/NzbDrone.Core/Music/Services/AlbumMonitoredService.cs index b715cb80d..db3fe1dd5 100644 --- a/src/NzbDrone.Core/Music/Services/AlbumMonitoredService.cs +++ b/src/NzbDrone.Core/Music/Services/AlbumMonitoredService.cs @@ -25,71 +25,79 @@ namespace NzbDrone.Core.Music public void SetAlbumMonitoredStatus(Artist artist, MonitoringOptions monitoringOptions) { - if (monitoringOptions != null) + // Update the artist without changing the albums + if (monitoringOptions == null) { - _logger.Debug("[{0}] Setting album monitored status.", artist.Name); - - var albums = _albumService.GetAlbumsByArtist(artist.Id); - - var albumsWithFiles = _albumService.GetArtistAlbumsWithFiles(artist); - - var albumsWithoutFiles = albums.Where(c => !albumsWithFiles.Select(e => e.Id).Contains(c.Id) && c.ReleaseDate <= DateTime.UtcNow).ToList(); - - var monitoredAlbums = monitoringOptions.AlbumsToMonitor; - - // If specific albums are passed use those instead of the monitoring options. - if (monitoredAlbums.Any()) - { - ToggleAlbumsMonitoredState(albums.Where(s => monitoredAlbums.Contains(s.ForeignAlbumId)), true); - ToggleAlbumsMonitoredState(albums.Where(s => !monitoredAlbums.Contains(s.ForeignAlbumId)), false); - } - else - { - switch (monitoringOptions.Monitor) - { - case MonitorTypes.All: - ToggleAlbumsMonitoredState(albums, true); - break; - case MonitorTypes.Future: - _logger.Debug("Unmonitoring Albums with Files"); - ToggleAlbumsMonitoredState(albums.Where(e => albumsWithFiles.Select(c => c.Id).Contains(e.Id)), false); - _logger.Debug("Unmonitoring Albums without Files"); - ToggleAlbumsMonitoredState(albums.Where(e => albumsWithoutFiles.Select(c => c.Id).Contains(e.Id)), false); - break; - case MonitorTypes.None: - ToggleAlbumsMonitoredState(albums, false); - break; - case MonitorTypes.Missing: - _logger.Debug("Unmonitoring Albums with Files"); - ToggleAlbumsMonitoredState(albums.Where(e => albumsWithFiles.Select(c => c.Id).Contains(e.Id)), false); - _logger.Debug("Monitoring Albums without Files"); - ToggleAlbumsMonitoredState(albums.Where(e => albumsWithoutFiles.Select(c => c.Id).Contains(e.Id)), true); - break; - case MonitorTypes.Existing: - _logger.Debug("Monitoring Albums with Files"); - ToggleAlbumsMonitoredState(albums.Where(e => albumsWithFiles.Select(c => c.Id).Contains(e.Id)), true); - _logger.Debug("Unmonitoring Albums without Files"); - ToggleAlbumsMonitoredState(albums.Where(e => albumsWithoutFiles.Select(c => c.Id).Contains(e.Id)), false); - break; - case MonitorTypes.Latest: - ToggleAlbumsMonitoredState(albums, false); - ToggleAlbumsMonitoredState(albums.OrderByDescending(e => e.ReleaseDate).Take(1), true); - break; - case MonitorTypes.First: - ToggleAlbumsMonitoredState(albums, false); - ToggleAlbumsMonitoredState(albums.OrderBy(e => e.ReleaseDate).Take(1), true); - break; - case MonitorTypes.Unknown: - // Ignoring, it's the default value - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - _albumService.UpdateMany(albums); + _artistService.UpdateArtist(artist); + return; } + var monitoredAlbums = monitoringOptions.AlbumsToMonitor; + + if (monitoringOptions.Monitor == MonitorTypes.Unknown && monitoredAlbums is not { Count: not 0 }) + { + return; + } + + _logger.Debug("[{0}] Setting album monitored status.", artist.Name); + + var albums = _albumService.GetAlbumsByArtist(artist.Id); + + // If specific albums are passed use those instead of the monitoring options. + if (monitoredAlbums.Any()) + { + ToggleAlbumsMonitoredState(albums.Where(s => monitoredAlbums.Contains(s.ForeignAlbumId)), true); + ToggleAlbumsMonitoredState(albums.Where(s => !monitoredAlbums.Contains(s.ForeignAlbumId)), false); + } + else + { + var albumsWithFiles = _albumService.GetArtistAlbumsWithFiles(artist); + var albumsWithoutFiles = albums.Where(c => !albumsWithFiles.Select(e => e.Id).Contains(c.Id) && c.ReleaseDate <= DateTime.UtcNow).ToList(); + + switch (monitoringOptions.Monitor) + { + case MonitorTypes.All: + _logger.Debug("Monitoring all albums"); + ToggleAlbumsMonitoredState(albums, true); + break; + case MonitorTypes.Future: + _logger.Debug("Unmonitoring Albums with Files"); + ToggleAlbumsMonitoredState(albums.Where(e => albumsWithFiles.Select(c => c.Id).Contains(e.Id)), false); + _logger.Debug("Unmonitoring Albums without Files"); + ToggleAlbumsMonitoredState(albums.Where(e => albumsWithoutFiles.Select(c => c.Id).Contains(e.Id)), false); + break; + case MonitorTypes.Missing: + _logger.Debug("Unmonitoring Albums with Files"); + ToggleAlbumsMonitoredState(albums.Where(e => albumsWithFiles.Select(c => c.Id).Contains(e.Id)), false); + _logger.Debug("Monitoring Albums without Files"); + ToggleAlbumsMonitoredState(albums.Where(e => albumsWithoutFiles.Select(c => c.Id).Contains(e.Id)), true); + break; + case MonitorTypes.Existing: + _logger.Debug("Monitoring Albums with Files"); + ToggleAlbumsMonitoredState(albums.Where(e => albumsWithFiles.Select(c => c.Id).Contains(e.Id)), true); + _logger.Debug("Unmonitoring Albums without Files"); + ToggleAlbumsMonitoredState(albums.Where(e => albumsWithoutFiles.Select(c => c.Id).Contains(e.Id)), false); + break; + case MonitorTypes.Latest: + _logger.Debug("Monitoring latest album"); + ToggleAlbumsMonitoredState(albums, false); + ToggleAlbumsMonitoredState(albums.OrderByDescending(e => e.ReleaseDate).Take(1), true); + break; + case MonitorTypes.First: + _logger.Debug("Monitoring first album"); + ToggleAlbumsMonitoredState(albums, false); + ToggleAlbumsMonitoredState(albums.OrderBy(e => e.ReleaseDate).Take(1), true); + break; + case MonitorTypes.None: + _logger.Debug("Unmonitoring all albums"); + ToggleAlbumsMonitoredState(albums, false); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + _albumService.UpdateMany(albums); _artistService.UpdateArtist(artist); } From 7e100c806d4dadcd50016401451e02298fd47f8c Mon Sep 17 00:00:00 2001 From: Weblate Date: Fri, 27 Oct 2023 12:57:58 +0000 Subject: [PATCH 081/820] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: Havok Dan Co-authored-by: ID-86 Co-authored-by: Jordy Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/cs/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/nl/ Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/pt_BR/ Translation: Servarr/Lidarr --- src/NzbDrone.Core/Localization/Core/cs.json | 3 ++- src/NzbDrone.Core/Localization/Core/nl.json | 6 ++++-- src/NzbDrone.Core/Localization/Core/pt_BR.json | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/cs.json b/src/NzbDrone.Core/Localization/Core/cs.json index f2c2dc694..3a664a4dd 100644 --- a/src/NzbDrone.Core/Localization/Core/cs.json +++ b/src/NzbDrone.Core/Localization/Core/cs.json @@ -736,5 +736,6 @@ "CatalogNumber": "katalogové číslo", "Album": "album", "DeleteCondition": "Odstranit podmínku", - "EditMetadataProfile": "profil metadat" + "EditMetadataProfile": "profil metadat", + "IndexerDownloadClientHelpText": "Zvolte, který klient pro stahování bude použit pro zachytávání z toho indexeru" } diff --git a/src/NzbDrone.Core/Localization/Core/nl.json b/src/NzbDrone.Core/Localization/Core/nl.json index 92c46de55..63fd1e101 100644 --- a/src/NzbDrone.Core/Localization/Core/nl.json +++ b/src/NzbDrone.Core/Localization/Core/nl.json @@ -650,7 +650,7 @@ "IndexerRssHealthCheckNoIndexers": "Geen indexeerders beschikbaar met \"RSS Synchronisatie\" ingeschakeld, Lidarr zal niet automatisch nieuwe uitgaves ophalen", "IndexerStatusCheckSingleClientMessage": "Indexeerders onbeschikbaar wegens fouten: {0}", "RemotePathMappingCheckFilesLocalWrongOSPath": "Lokale downloadclient {0} rapporteerde bestanden in {1}, maar dit is geen geldig {2}-pad. Controleer de instellingen van uw downloadclient.", - "Album": "album", + "Album": "Album", "AppDataLocationHealthCheckMessage": "Updaten zal niet mogelijk zijn om het verwijderen van AppData te voorkomen", "ColonReplacement": "Dubbelepunt Vervanging", "DownloadClientStatusCheckAllClientMessage": "Alle downloaders zijn onbeschikbaar wegens fouten", @@ -763,5 +763,7 @@ "AddConditionImplementation": "Voeg voorwaarde toe - {implementationName}", "AddConnectionImplementation": "Voeg connectie toe - {implementationName}", "AddDownloadClientImplementation": "Voeg Downloadclient toe - {implementationName}", - "AddIndexerImplementation": "Indexeerder toevoegen - {implementationName}" + "AddIndexerImplementation": "Indexeerder toevoegen - {implementationName}", + "AppUpdated": "{appName} is geüpdatet", + "AppUpdatedVersion": "{appName} is geüpdatet naar versie '{version}', om de laatste wijzigingen door te voeren moet je mogelijk {appName} herstarten" } diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index 8b1357102..d490fed0f 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -231,7 +231,7 @@ "EnabledHelpText": "Marque para habilitar o perfil de lançamento", "EnableHelpText": "Habilitar criação de arquivo de metadados para este tipo de metadados", "EnableInteractiveSearch": "Ativar pesquisa interativa", - "EnableProfile": "Habilitar perfil", + "EnableProfile": "Habilitar Perfil", "EnableRSS": "Habilitar RSS", "EnableSSL": "Habilitar SSL", "EnableSslHelpText": " Requer a reinicialização com a execução como administrador para fazer efeito", @@ -326,7 +326,7 @@ "MarkAsFailedMessageText": "Tem certeza que deseja marcar \"{0}\" como falhado?", "MaximumLimits": "Limites máximos", "MaximumSize": "Tamanho máximo", - "MaximumSizeHelpText": "Tamanho máximo, em MB, para obter um lançamento. Zero significa ilimitado", + "MaximumSizeHelpText": "Tamanho máximo para um lançamento ser baixado, em MB. Defina como zero para definir como ilimitado.", "Mechanism": "Mecanismo", "MediaInfo": "Informações da mídia", "MediaManagementSettings": "Configurações de gerenciamento de mídia", From 3790c65c9f2d8025b42f8828016319a5500f8474 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Thu, 19 Oct 2023 13:34:43 +0300 Subject: [PATCH 082/820] Fix typo in visible for Queue size (cherry picked from commit a171132f989437e3cd85a9689d42e862c6335d1b) --- frontend/src/Store/Actions/queueActions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js index dc20da73d..076635259 100644 --- a/frontend/src/Store/Actions/queueActions.js +++ b/frontend/src/Store/Actions/queueActions.js @@ -132,7 +132,7 @@ export const defaultState = { name: 'size', label: () => translate('Size'), isSortable: true, - isVisibile: false + isVisible: false }, { name: 'outputPath', From ed597522686af12ac8526a9a75b97a589466df3d Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sat, 21 Oct 2023 10:06:22 +0300 Subject: [PATCH 083/820] Sort Custom Formats by name (cherry picked from commit e9bb1d52a72b20a58d1a672ecfa3797eda6f081a) --- .../CustomFormats/CustomFormatCalculationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs b/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs index c535d6bfc..a6ef214fc 100644 --- a/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs +++ b/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs @@ -142,7 +142,7 @@ namespace NzbDrone.Core.CustomFormats } } - return matches; + return matches.OrderBy(x => x.Name).ToList(); } private static List ParseCustomFormat(TrackFile trackFile, Artist artist, List allCustomFormats) From bba85f11e64f3c2f25c7a203cc7c1bc59485fe78 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 25 Oct 2023 18:28:57 +0300 Subject: [PATCH 084/820] Allow 0 as valid value in QualityProfileExistsValidator (cherry picked from commit 36ca24e55a5eda859047d82855f65c401cc0b30f) --- src/NzbDrone.Core/Validation/QualityProfileExistsValidator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Validation/QualityProfileExistsValidator.cs b/src/NzbDrone.Core/Validation/QualityProfileExistsValidator.cs index f6aae29f8..7505684c7 100644 --- a/src/NzbDrone.Core/Validation/QualityProfileExistsValidator.cs +++ b/src/NzbDrone.Core/Validation/QualityProfileExistsValidator.cs @@ -16,7 +16,7 @@ namespace NzbDrone.Core.Validation protected override bool IsValid(PropertyValidatorContext context) { - if (context.PropertyValue == null) + if (context?.PropertyValue == null || (int)context.PropertyValue == 0) { return true; } From 6224fe95b55894caba57c23e549f4167d9e44181 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 17 Oct 2023 18:47:12 +0300 Subject: [PATCH 085/820] Add default value for Queue count to avoid failed prop type (cherry picked from commit 43ed7730f08de7baddbdafcccd99370258593221) --- frontend/src/Activity/Queue/Queue.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js index 27d5f9e26..13ee03f10 100644 --- a/frontend/src/Activity/Queue/Queue.js +++ b/frontend/src/Activity/Queue/Queue.js @@ -338,4 +338,8 @@ Queue.propTypes = { onRemoveSelectedPress: PropTypes.func.isRequired }; +Queue.defaultProps = { + count: 0 +}; + export default Queue; From bdd9628122596559b2a8675900812faf14786cde Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 27 Oct 2023 20:23:34 +0300 Subject: [PATCH 086/820] Use the correct property for quality profile on overview (cherry picked from commit b06d5fb07bc7ab73ff3705410d8993ef7e040852) --- frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx index c0c62ba84..33c4ad2a9 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx @@ -21,7 +21,7 @@ const rows = [ { name: 'qualityProfileId', showProp: 'showQualityProfile', - valueProp: 'qualityProfileId', + valueProp: 'qualityProfile', }, { name: 'lastAlbum', From 9ccb6af61b4cd95ffcbde3b3cc2fe5fe4847c26c Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 25 Oct 2023 15:11:41 +0300 Subject: [PATCH 087/820] New: Add Download Client validation for indexers (cherry picked from commit e53b7f8c945e3597ca1719961e82540f1f01f0e9) Closes #4246 --- .../Indexers/IndexerController.cs | 4 ++- .../DownloadClientExistsValidator.cs | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/NzbDrone.Core/Validation/DownloadClientExistsValidator.cs diff --git a/src/Lidarr.Api.V1/Indexers/IndexerController.cs b/src/Lidarr.Api.V1/Indexers/IndexerController.cs index 101faf018..2ebcd3f29 100644 --- a/src/Lidarr.Api.V1/Indexers/IndexerController.cs +++ b/src/Lidarr.Api.V1/Indexers/IndexerController.cs @@ -1,5 +1,6 @@ using Lidarr.Http; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Validation; namespace Lidarr.Api.V1.Indexers { @@ -9,9 +10,10 @@ namespace Lidarr.Api.V1.Indexers public static readonly IndexerResourceMapper ResourceMapper = new (); public static readonly IndexerBulkResourceMapper BulkResourceMapper = new (); - public IndexerController(IndexerFactory indexerFactory) + public IndexerController(IndexerFactory indexerFactory, DownloadClientExistsValidator downloadClientExistsValidator) : base(indexerFactory, "indexer", ResourceMapper, BulkResourceMapper) { + SharedValidator.RuleFor(c => c.DownloadClientId).SetValidator(downloadClientExistsValidator); } } } diff --git a/src/NzbDrone.Core/Validation/DownloadClientExistsValidator.cs b/src/NzbDrone.Core/Validation/DownloadClientExistsValidator.cs new file mode 100644 index 000000000..cf021f464 --- /dev/null +++ b/src/NzbDrone.Core/Validation/DownloadClientExistsValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation.Validators; +using NzbDrone.Core.Download; + +namespace NzbDrone.Core.Validation +{ + public class DownloadClientExistsValidator : PropertyValidator + { + private readonly IDownloadClientFactory _downloadClientFactory; + + public DownloadClientExistsValidator(IDownloadClientFactory downloadClientFactory) + { + _downloadClientFactory = downloadClientFactory; + } + + protected override string GetDefaultMessageTemplate() => "Download Client does not exist"; + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context?.PropertyValue == null || (int)context.PropertyValue == 0) + { + return true; + } + + return _downloadClientFactory.Exists((int)context.PropertyValue); + } + } +} From 96aecf7e32960d47598f065f1576a5da481a0b36 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sun, 29 Oct 2023 00:52:30 +0300 Subject: [PATCH 088/820] New: Set busy timeout for SQLite (cherry picked from commit 192eb7b62ae60f300a9371ce3ed2e0056b5a1f4d) Closes #4252 --- .../Datastore/ConnectionStringFactory.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/NzbDrone.Core/Datastore/ConnectionStringFactory.cs b/src/NzbDrone.Core/Datastore/ConnectionStringFactory.cs index bf53f907c..4989309e9 100644 --- a/src/NzbDrone.Core/Datastore/ConnectionStringFactory.cs +++ b/src/NzbDrone.Core/Datastore/ConnectionStringFactory.cs @@ -41,14 +41,16 @@ namespace NzbDrone.Core.Datastore private static string GetConnectionString(string dbPath) { - var connectionBuilder = new SQLiteConnectionStringBuilder(); - - connectionBuilder.DataSource = dbPath; - connectionBuilder.CacheSize = -10000; - connectionBuilder.DateTimeKind = DateTimeKind.Utc; - connectionBuilder.JournalMode = OsInfo.IsOsx ? SQLiteJournalModeEnum.Truncate : SQLiteJournalModeEnum.Wal; - connectionBuilder.Pooling = true; - connectionBuilder.Version = 3; + var connectionBuilder = new SQLiteConnectionStringBuilder + { + DataSource = dbPath, + CacheSize = -20000, + DateTimeKind = DateTimeKind.Utc, + JournalMode = OsInfo.IsOsx ? SQLiteJournalModeEnum.Truncate : SQLiteJournalModeEnum.Wal, + Pooling = true, + Version = 3, + BusyTimeout = 100 + }; if (OsInfo.IsOsx) { From d31c323f3c6953c9898d6962dc02be1625ab1e09 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sun, 29 Oct 2023 10:36:44 +0200 Subject: [PATCH 089/820] Bump version to 2.0.1 --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index b33b758fc..e1ea77a5e 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,7 +9,7 @@ variables: testsFolder: './_tests' yarnCacheFolder: $(Pipeline.Workspace)/.yarn nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages - majorVersion: '2.0.0' + majorVersion: '2.0.1' minorVersion: $[counter('minorVersion', 1076)] lidarrVersion: '$(majorVersion).$(minorVersion)' buildName: '$(Build.SourceBranchName).$(lidarrVersion)' From 84d5f2bcee9b63239705742d6aee53382e7d33b6 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Thu, 12 Jan 2023 09:00:37 -0800 Subject: [PATCH 090/820] Added artists index selection (cherry picked from commit 815a16d5cfced17ca4db7f1b66991c5cc9f3b719) --- frontend/src/App/SelectContext.tsx | 170 ++++++++++++ frontend/src/Artist/Index/ArtistIndex.tsx | 248 ++++++++++-------- .../Index/Banners/ArtistIndexBanner.tsx | 6 +- .../Index/Banners/ArtistIndexBanners.tsx | 17 +- .../Index/Overview/ArtistIndexOverview.tsx | 7 + .../Index/Overview/ArtistIndexOverviews.tsx | 12 +- .../Index/Posters/ArtistIndexPoster.tsx | 6 +- .../Index/Posters/ArtistIndexPosters.tsx | 17 +- .../Index/Select/ArtistIndexPosterSelect.css | 36 +++ .../Select/ArtistIndexPosterSelect.css.d.ts | 10 + .../Index/Select/ArtistIndexPosterSelect.tsx | 41 +++ .../Select/ArtistIndexSelectAllButton.tsx | 35 +++ .../src/Artist/Index/Table/ArtistIndexRow.tsx | 27 +- .../Artist/Index/Table/ArtistIndexTable.tsx | 8 +- .../Index/Table/ArtistIndexTableHeader.tsx | 24 +- frontend/src/Helpers/Props/icons.js | 8 +- .../src/Utilities/Table/toggleSelected.js | 16 +- 17 files changed, 553 insertions(+), 135 deletions(-) create mode 100644 frontend/src/App/SelectContext.tsx create mode 100644 frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.css create mode 100644 frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.css.d.ts create mode 100644 frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.tsx create mode 100644 frontend/src/Artist/Index/Select/ArtistIndexSelectAllButton.tsx diff --git a/frontend/src/App/SelectContext.tsx b/frontend/src/App/SelectContext.tsx new file mode 100644 index 000000000..05ee42791 --- /dev/null +++ b/frontend/src/App/SelectContext.tsx @@ -0,0 +1,170 @@ +import { cloneDeep } from 'lodash'; +import React, { useEffect } from 'react'; +import areAllSelected from 'Utilities/Table/areAllSelected'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import ModelBase from './ModelBase'; + +export enum SelectActionType { + Reset, + SelectAll, + UnselectAll, + ToggleSelected, + RemoveItem, + UpdateItems, +} + +type SelectedState = Record; + +interface SelectState { + selectedState: SelectedState; + lastToggled: number | null; + allSelected: boolean; + allUnselected: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + items: any[]; +} + +type SelectAction = + | { type: SelectActionType.Reset } + | { type: SelectActionType.SelectAll } + | { type: SelectActionType.UnselectAll } + | { + type: SelectActionType.ToggleSelected; + id: number; + isSelected: boolean; + shiftKey: boolean; + } + | { + type: SelectActionType.RemoveItem; + id: number; + } + | { + type: SelectActionType.UpdateItems; + items: ModelBase[]; + }; + +type Dispatch = (action: SelectAction) => void; + +const initialState = { + selectedState: {}, + lastToggled: null, + allSelected: false, + allUnselected: true, + items: [], +}; + +interface SelectProviderOptions { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children: any; + isSelectMode: boolean; + items: Array; +} + +function getSelectedState(items: ModelBase[], existingState: SelectedState) { + return items.reduce((acc: SelectedState, item) => { + const id = item.id; + + acc[id] = existingState[id] ?? false; + + return acc; + }, {}); +} + +// TODO: Can this be reused? + +const SelectContext = React.createContext<[SelectState, Dispatch] | undefined>( + cloneDeep(undefined) +); + +function selectReducer(state: SelectState, action: SelectAction): SelectState { + const { items, selectedState } = state; + + switch (action.type) { + case SelectActionType.Reset: { + return cloneDeep(initialState); + } + case SelectActionType.SelectAll: { + return { + items, + ...selectAll(selectedState, true), + }; + } + case SelectActionType.UnselectAll: { + return { + items, + ...selectAll(selectedState, false), + }; + } + case SelectActionType.ToggleSelected: { + var result = { + items, + ...toggleSelected( + state, + items, + action.id, + action.isSelected, + action.shiftKey + ), + }; + + return result; + } + case SelectActionType.UpdateItems: { + const nextSelectedState = getSelectedState(action.items, selectedState); + + return { + ...state, + ...areAllSelected(nextSelectedState), + selectedState: nextSelectedState, + items, + }; + } + default: { + throw new Error(`Unhandled action type: ${action.type}`); + } + } +} + +export function SelectProvider( + props: SelectProviderOptions +) { + const { isSelectMode, items } = props; + const selectedState = getSelectedState(items, {}); + + const [state, dispatch] = React.useReducer(selectReducer, { + selectedState, + lastToggled: null, + allSelected: false, + allUnselected: true, + items, + }); + + const value: [SelectState, Dispatch] = [state, dispatch]; + + useEffect(() => { + if (!isSelectMode) { + dispatch({ type: SelectActionType.Reset }); + } + }, [isSelectMode]); + + useEffect(() => { + dispatch({ type: SelectActionType.UpdateItems, items }); + }, [items]); + + return ( + + {props.children} + + ); +} + +export function useSelect() { + const context = React.useContext(SelectContext); + + if (context === undefined) { + throw new Error('useSelect must be used within a SelectProvider'); + } + + return context; +} diff --git a/frontend/src/Artist/Index/ArtistIndex.tsx b/frontend/src/Artist/Index/ArtistIndex.tsx index 604b905a2..345ce10e7 100644 --- a/frontend/src/Artist/Index/ArtistIndex.tsx +++ b/frontend/src/Artist/Index/ArtistIndex.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { SelectProvider } from 'App/SelectContext'; import NoArtist from 'Artist/NoArtist'; import { REFRESH_ARTIST, RSS_SYNC } from 'Commands/commandNames'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; @@ -37,6 +38,7 @@ import ArtistIndexOverviews from './Overview/ArtistIndexOverviews'; import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal'; import ArtistIndexPosters from './Posters/ArtistIndexPosters'; import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal'; +import ArtistIndexSelectAllButton from './Select/ArtistIndexSelectAllButton'; import ArtistIndexTable from './Table/ArtistIndexTable'; import ArtistIndexTableOptions from './Table/ArtistIndexTableOptions'; import styles from './ArtistIndex.css'; @@ -88,6 +90,7 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => { const scrollerRef = useRef(); const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); const [jumpToCharacter, setJumpToCharacter] = useState(null); + const [isSelectMode, setIsSelectMode] = useState(false); const onRefreshArtistPress = useCallback(() => { dispatch( @@ -105,6 +108,10 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => { ); }, [dispatch]); + const onSelectModePress = useCallback(() => { + setIsSelectMode(!isSelectMode); + }, [isSelectMode, setIsSelectMode]); + const onTableOptionChange = useCallback( (payload) => { dispatch(setArtistTableOption(payload)); @@ -202,131 +209,150 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => { const hasNoArtist = !totalItems; return ( - - - - + + + + + - - + - - {view === 'table' ? ( - + + + + + {isSelectMode ? : null} + + + + {view === 'table' ? ( + + + + ) : ( - - ) : ( - + + - )} - + - + + + +
+ + {isFetching && !isPopulated ? : null} - + {!isFetching && !!error ? ( +
+ {getErrorMessage(error, 'Failed to load artist from API')} +
+ ) : null} - - - -
- - {isFetching && !isPopulated ? : null} + {isLoaded ? ( +
+ - {!isFetching && !!error ? ( -
- {getErrorMessage(error, 'Failed to load artist from API')} -
+ +
+ ) : null} + + {!error && isPopulated && !items.length ? ( + + ) : null} +
+ + {isLoaded && !!jumpBarItems.order.length ? ( + ) : null} - - {isLoaded ? ( -
- - - -
- ) : null} - - {!error && isPopulated && !items.length ? ( - - ) : null} - - - {isLoaded && !!jumpBarItems.order.length ? ( - +
+ {view === 'posters' ? ( + ) : null} -
- {view === 'posters' ? ( - - ) : null} - {view === 'banners' ? ( - - ) : null} - {view === 'overview' ? ( - - ) : null} -
+ {view === 'banners' ? ( + + ) : null} + {view === 'overview' ? ( + + ) : null} +
+ ); }, 'artistIndex'); diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx index 1ab5171ad..d59ea0187 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx @@ -7,6 +7,7 @@ import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; import ArtistIndexBannerInfo from 'Artist/Index/Banners/ArtistIndexBannerInfo'; import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector'; import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import ArtistIndexPosterSelect from 'Artist/Index/Select/ArtistIndexPosterSelect'; import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; import Label from 'Components/Label'; import IconButton from 'Components/Link/IconButton'; @@ -23,12 +24,13 @@ import styles from './ArtistIndexBanner.css'; interface ArtistIndexBannerProps { artistId: number; sortKey: string; + isSelectMode: boolean; bannerWidth: number; bannerHeight: number; } function ArtistIndexBanner(props: ArtistIndexBannerProps) { - const { artistId, sortKey, bannerWidth, bannerHeight } = props; + const { artistId, sortKey, isSelectMode, bannerWidth, bannerHeight } = props; const { artist, @@ -130,6 +132,8 @@ function ArtistIndexBanner(props: ArtistIndexBannerProps) { return (
+ {isSelectMode ? : null} +