diff --git a/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx index ec026ae92..b90f40d37 100644 --- a/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx +++ b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx @@ -16,13 +16,19 @@ interface BlocklistDetailsModalProps { protocol: DownloadProtocol; indexer?: string; message?: string; + source?: string; onModalClose: () => void; } -function BlocklistDetailsModal(props: BlocklistDetailsModalProps) { - const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } = - props; - +function BlocklistDetailsModal({ + isOpen, + sourceTitle, + protocol, + indexer, + message, + source, + onModalClose, +}: BlocklistDetailsModalProps) { return ( @@ -50,6 +56,9 @@ function BlocklistDetailsModal(props: BlocklistDetailsModalProps) { data={message} /> ) : null} + {source ? ( + + ) : null} diff --git a/frontend/src/Activity/Blocklist/BlocklistRow.tsx b/frontend/src/Activity/Blocklist/BlocklistRow.tsx index c7410320d..8db01a1e0 100644 --- a/frontend/src/Activity/Blocklist/BlocklistRow.tsx +++ b/frontend/src/Activity/Blocklist/BlocklistRow.tsx @@ -37,6 +37,7 @@ function BlocklistRow(props: BlocklistRowProps) { protocol, indexer, message, + source, isSelected, columns, onSelectedChange, @@ -154,6 +155,7 @@ function BlocklistRow(props: BlocklistRowProps) { protocol={protocol} indexer={indexer} message={message} + source={source} onModalClose={handleDetailsModalClose} /> diff --git a/frontend/src/Activity/History/Details/HistoryDetails.tsx b/frontend/src/Activity/History/Details/HistoryDetails.tsx index 588bd7482..c3c3ea7f9 100644 --- a/frontend/src/Activity/History/Details/HistoryDetails.tsx +++ b/frontend/src/Activity/History/Details/HistoryDetails.tsx @@ -174,7 +174,7 @@ function HistoryDetails(props: HistoryDetailsProps) { } if (eventType === 'downloadFailed') { - const { message, indexer } = data as DownloadFailedHistory; + const { indexer, message, source } = data as DownloadFailedHistory; return ( @@ -195,6 +195,10 @@ function HistoryDetails(props: HistoryDetailsProps) { {message ? ( ) : null} + + {source ? ( + + ) : null} ); } diff --git a/frontend/src/Helpers/Hooks/useApiMutation.ts b/frontend/src/Helpers/Hooks/useApiMutation.ts index f3aeb0d01..c3fe85958 100644 --- a/frontend/src/Helpers/Hooks/useApiMutation.ts +++ b/frontend/src/Helpers/Hooks/useApiMutation.ts @@ -20,6 +20,7 @@ function useApiMutation(options: MutationOptions) { headers: { ...options.headers, 'X-Api-Key': window.Sonarr.apiKey, + 'X-Sonarr-Client': 'Sonarr', }, }; }, [options]); diff --git a/frontend/src/Helpers/Hooks/useApiQuery.ts b/frontend/src/Helpers/Hooks/useApiQuery.ts index ec4304627..bdeab78bf 100644 --- a/frontend/src/Helpers/Hooks/useApiQuery.ts +++ b/frontend/src/Helpers/Hooks/useApiQuery.ts @@ -26,6 +26,7 @@ const useApiQuery = (options: QueryOptions) => { headers: { ...options.headers, 'X-Api-Key': window.Sonarr.apiKey, + 'X-Sonarr-Client': 'Sonarr', }, }, }; diff --git a/frontend/src/Helpers/Hooks/usePagedApiQuery.ts b/frontend/src/Helpers/Hooks/usePagedApiQuery.ts index a1ce29470..5fceedbb1 100644 --- a/frontend/src/Helpers/Hooks/usePagedApiQuery.ts +++ b/frontend/src/Helpers/Hooks/usePagedApiQuery.ts @@ -64,6 +64,7 @@ const usePagedApiQuery = (options: PagedQueryOptions) => { headers: { ...options.headers, 'X-Api-Key': window.Sonarr.apiKey, + 'X-Sonarr-Client': 'Sonarr', }, }, }; diff --git a/frontend/src/typings/Blocklist.ts b/frontend/src/typings/Blocklist.ts index bbf4cacae..b0b0e8d9e 100644 --- a/frontend/src/typings/Blocklist.ts +++ b/frontend/src/typings/Blocklist.ts @@ -15,6 +15,7 @@ interface Blocklist extends ModelBase { seriesId?: number; indexer?: string; message?: string; + source?: string; } export default Blocklist; diff --git a/frontend/src/typings/History.ts b/frontend/src/typings/History.ts index c4d7f7644..cdd1ae9f1 100644 --- a/frontend/src/typings/History.ts +++ b/frontend/src/typings/History.ts @@ -37,6 +37,7 @@ export interface GrabbedHistoryData { export interface DownloadFailedHistory { message: string; indexer?: string; + source?: string; } export interface DownloadFolderImportedHistory { diff --git a/src/NzbDrone.Common.Test/Http/UserAgentParserFixture.cs b/src/NzbDrone.Common.Test/Http/UserAgentParserFixture.cs new file mode 100644 index 000000000..161f7b7c8 --- /dev/null +++ b/src/NzbDrone.Common.Test/Http/UserAgentParserFixture.cs @@ -0,0 +1,23 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Test.Common; + +namespace NzbDrone.Common.Test.Http; + +[TestFixture] +public class UserAgentParserFixture : TestBase +{ + // Ref *Arr `_userAgent = $"{BuildInfo.AppName}/{BuildInfo.Version} ({osName} {osVersion})";` + // Ref Mylar `Mylar3/' +str(hash) +'(' +vers +') +http://www.github.com/mylar3/mylar3/` + [TestCase("Mylar3/ 3ee23rh23irqfq (13123123) http://www.github.com/mylar3/mylar3/", "Mylar3")] + [TestCase("Lidarr/1.0.0.2300 (ubuntu 20.04)", "Lidarr")] + [TestCase("Radarr/1.0.0.2300 (ubuntu 20.04)", "Radarr")] + [TestCase("Readarr/1.0.0.2300 (ubuntu 20.04)", "Readarr")] + [TestCase("Sonarr/3.0.6.9999 (ubuntu 20.04)", "Sonarr")] + [TestCase("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36", "Other")] + public void should_parse_user_agent(string userAgent, string parsedAgent) + { + UserAgentParser.ParseSource(userAgent).Should().Be(parsedAgent); + } +} diff --git a/src/NzbDrone.Common/Http/UserAgentParser.cs b/src/NzbDrone.Common/Http/UserAgentParser.cs index 0a31410f2..6a481a5dd 100644 --- a/src/NzbDrone.Common/Http/UserAgentParser.cs +++ b/src/NzbDrone.Common/Http/UserAgentParser.cs @@ -1,7 +1,12 @@ +using System.Text.RegularExpressions; + namespace NzbDrone.Common.Http { public static class UserAgentParser { + private static readonly Regex AppSourceRegex = new(@"(?[a-z0-9]*)\/.*(?:\(.*\))?", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static string SimplifyUserAgent(string userAgent) { if (userAgent == null || userAgent.StartsWith("Mozilla/5.0")) @@ -11,5 +16,17 @@ namespace NzbDrone.Common.Http return userAgent; } + + public static string ParseSource(string userAgent) + { + var match = AppSourceRegex.Match(SimplifyUserAgent(userAgent) ?? string.Empty); + + if (match.Groups["agent"].Success) + { + return match.Groups["agent"].Value; + } + + return "Other"; + } } } diff --git a/src/NzbDrone.Core/Blocklisting/Blocklist.cs b/src/NzbDrone.Core/Blocklisting/Blocklist.cs index 8941f42fd..f26d980a0 100644 --- a/src/NzbDrone.Core/Blocklisting/Blocklist.cs +++ b/src/NzbDrone.Core/Blocklisting/Blocklist.cs @@ -24,6 +24,7 @@ namespace NzbDrone.Core.Blocklisting public IndexerFlags IndexerFlags { get; set; } public ReleaseType ReleaseType { get; set; } public string Message { get; set; } + public string Source { get; set; } public string TorrentInfoHash { get; set; } public List Languages { get; set; } } diff --git a/src/NzbDrone.Core/Blocklisting/BlocklistService.cs b/src/NzbDrone.Core/Blocklisting/BlocklistService.cs index f6aa6ceef..52ad1f8a5 100644 --- a/src/NzbDrone.Core/Blocklisting/BlocklistService.cs +++ b/src/NzbDrone.Core/Blocklisting/BlocklistService.cs @@ -17,7 +17,7 @@ namespace NzbDrone.Core.Blocklisting bool Blocklisted(int seriesId, ReleaseInfo release); bool BlocklistedTorrentHash(int seriesId, string hash); PagingSpec Paged(PagingSpec pagingSpec); - void Block(RemoteEpisode remoteEpisode, string message); + void Block(RemoteEpisode remoteEpisode, string message, string source); void Delete(int id); void Delete(List ids); } @@ -71,7 +71,7 @@ namespace NzbDrone.Core.Blocklisting return _blocklistRepository.GetPaged(pagingSpec); } - public void Block(RemoteEpisode remoteEpisode, string message) + public void Block(RemoteEpisode remoteEpisode, string message, string source) { var blocklist = new Blocklist { @@ -85,6 +85,7 @@ namespace NzbDrone.Core.Blocklisting Indexer = remoteEpisode.Release.Indexer, Protocol = remoteEpisode.Release.DownloadProtocol, Message = message, + Source = source, Languages = remoteEpisode.ParsedEpisodeInfo.Languages }; @@ -185,6 +186,7 @@ namespace NzbDrone.Core.Blocklisting Indexer = message.Data.GetValueOrDefault("indexer"), Protocol = (DownloadProtocol)Convert.ToInt32(message.Data.GetValueOrDefault("protocol")), Message = message.Message, + Source = message.Source, Languages = message.Languages, TorrentInfoHash = message.TrackedDownload?.Protocol == DownloadProtocol.Torrent ? message.TrackedDownload.DownloadItem.DownloadId diff --git a/src/NzbDrone.Core/Datastore/Migration/223_add_source_to_blocklist.cs b/src/NzbDrone.Core/Datastore/Migration/223_add_source_to_blocklist.cs new file mode 100644 index 000000000..39e63425f --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/223_add_source_to_blocklist.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(223)] + public class add_source_to_blocklist : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Blocklist").AddColumn("Source").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadFailedEvent.cs b/src/NzbDrone.Core/Download/DownloadFailedEvent.cs index 7a42b7e18..c6e0bd7e8 100644 --- a/src/NzbDrone.Core/Download/DownloadFailedEvent.cs +++ b/src/NzbDrone.Core/Download/DownloadFailedEvent.cs @@ -21,6 +21,7 @@ namespace NzbDrone.Core.Download public string DownloadClient { get; set; } public string DownloadId { get; set; } public string Message { get; set; } + public string Source { get; set; } public Dictionary Data { get; set; } public TrackedDownload TrackedDownload { get; set; } public List Languages { get; set; } diff --git a/src/NzbDrone.Core/Download/FailedDownloadService.cs b/src/NzbDrone.Core/Download/FailedDownloadService.cs index 0851ae794..37e05e099 100644 --- a/src/NzbDrone.Core/Download/FailedDownloadService.cs +++ b/src/NzbDrone.Core/Download/FailedDownloadService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.History; @@ -11,8 +12,8 @@ namespace NzbDrone.Core.Download { public interface IFailedDownloadService { - void MarkAsFailed(int historyId, bool skipRedownload = false); - void MarkAsFailed(TrackedDownload trackedDownload, bool skipRedownload = false); + void MarkAsFailed(int historyId, string message = null, string source = null, bool skipRedownload = false); + void MarkAsFailed(TrackedDownload trackedDownload, string message = null, string source = null, bool skipRedownload = false); void Check(TrackedDownload trackedDownload); void ProcessFailed(TrackedDownload trackedDownload); } @@ -30,15 +31,16 @@ namespace NzbDrone.Core.Download _eventAggregator = eventAggregator; } - public void MarkAsFailed(int historyId, bool skipRedownload = false) + public void MarkAsFailed(int historyId, string message, string source = null, bool skipRedownload = false) { - var history = _historyService.Get(historyId); + message ??= "Manually marked as failed"; + var history = _historyService.Get(historyId); var downloadId = history.DownloadId; if (downloadId.IsNullOrWhiteSpace()) { - PublishDownloadFailedEvent(history, new List { history.EpisodeId }, "Manually marked as failed", skipRedownload: skipRedownload); + PublishDownloadFailedEvent(history, new List { history.EpisodeId }, message, source, skipRedownload: skipRedownload); return; } @@ -55,16 +57,16 @@ namespace NzbDrone.Core.Download grabbedHistory.AddRange(GetGrabbedHistory(downloadId)); grabbedHistory = grabbedHistory.DistinctBy(h => h.Id).ToList(); - PublishDownloadFailedEvent(history, GetEpisodeIds(grabbedHistory), "Manually marked as failed"); + PublishDownloadFailedEvent(history, GetEpisodeIds(grabbedHistory), message, source); } - public void MarkAsFailed(TrackedDownload trackedDownload, bool skipRedownload = false) + public void MarkAsFailed(TrackedDownload trackedDownload, string message, string source = null, bool skipRedownload = false) { var history = GetGrabbedHistory(trackedDownload.DownloadItem.DownloadId); if (history.Any()) { - PublishDownloadFailedEvent(history.First(), GetEpisodeIds(history), "Manually marked as failed", trackedDownload, skipRedownload: skipRedownload); + PublishDownloadFailedEvent(history.First(), GetEpisodeIds(history), message ?? "Manually marked as failed", source, trackedDownload, skipRedownload: skipRedownload); } } @@ -117,10 +119,10 @@ namespace NzbDrone.Core.Download } trackedDownload.State = TrackedDownloadState.Failed; - PublishDownloadFailedEvent(grabbedItems.First(), GetEpisodeIds(grabbedItems), failure, trackedDownload); + PublishDownloadFailedEvent(grabbedItems.First(), GetEpisodeIds(grabbedItems), failure, $"{BuildInfo.AppName} Failed Download Handling", trackedDownload); } - private void PublishDownloadFailedEvent(EpisodeHistory historyItem, List episodeIds, string message, TrackedDownload trackedDownload = null, bool skipRedownload = false) + private void PublishDownloadFailedEvent(EpisodeHistory historyItem, List episodeIds, string message, string source, TrackedDownload trackedDownload = null, bool skipRedownload = false) { Enum.TryParse(historyItem.Data.GetValueOrDefault(EpisodeHistory.RELEASE_SOURCE, ReleaseSourceType.Unknown.ToString()), out ReleaseSourceType releaseSource); @@ -133,6 +135,7 @@ namespace NzbDrone.Core.Download DownloadClient = historyItem.Data.GetValueOrDefault(EpisodeHistory.DOWNLOAD_CLIENT), DownloadId = historyItem.DownloadId, Message = message, + Source = source, Data = historyItem.Data, TrackedDownload = trackedDownload, Languages = historyItem.Languages, diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index 6bef7b722..65a64852f 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -249,6 +249,7 @@ namespace NzbDrone.Core.History history.Data.Add("DownloadClient", message.DownloadClient); history.Data.Add("DownloadClientName", message.TrackedDownload?.DownloadItem.DownloadClientInfo.Name); history.Data.Add("Message", message.Message); + history.Data.Add("Source", message.Source); history.Data.Add("ReleaseGroup", message.TrackedDownload?.RemoteEpisode?.ParsedEpisodeInfo?.ReleaseGroup ?? message.Data.GetValueOrDefault(EpisodeHistory.RELEASE_GROUP)); history.Data.Add("Size", message.TrackedDownload?.DownloadItem.TotalSize.ToString() ?? message.Data.GetValueOrDefault(EpisodeHistory.SIZE)); history.Data.Add("Indexer", message.TrackedDownload?.RemoteEpisode?.Release?.Indexer ?? message.Data.GetValueOrDefault(EpisodeHistory.INDEXER)); diff --git a/src/Sonarr.Api.V3/Queue/QueueController.cs b/src/Sonarr.Api.V3/Queue/QueueController.cs index 633424fda..454b497fc 100644 --- a/src/Sonarr.Api.V3/Queue/QueueController.cs +++ b/src/Sonarr.Api.V3/Queue/QueueController.cs @@ -323,7 +323,7 @@ namespace Sonarr.Api.V3.Queue { if (blocklist) { - _blocklistService.Block(pendingRelease.RemoteEpisode, "Pending release manually blocklisted"); + _blocklistService.Block(pendingRelease.RemoteEpisode, "Pending release manually blocklisted", null); } _pendingReleaseService.RemovePendingQueueItemsObsolete(pendingRelease.Id); @@ -356,7 +356,7 @@ namespace Sonarr.Api.V3.Queue if (blocklist) { - _failedDownloadService.MarkAsFailed(trackedDownload, skipRedownload); + _failedDownloadService.MarkAsFailed(trackedDownload, null, null, skipRedownload); } if (!removeFromClient && !blocklist && !changeCategory) diff --git a/src/Sonarr.Api.V5/Queue/QueueController.cs b/src/Sonarr.Api.V5/Queue/QueueController.cs index 67bdca9b6..784f3b121 100644 --- a/src/Sonarr.Api.V5/Queue/QueueController.cs +++ b/src/Sonarr.Api.V5/Queue/QueueController.cs @@ -68,13 +68,13 @@ namespace Sonarr.Api.V5.Queue } [RestDeleteById] - public ActionResult RemoveAction(int id, bool removeFromClient = true, bool blocklist = false, bool skipRedownload = false, bool changeCategory = false) + public ActionResult RemoveAction(int id, string? message = null, bool removeFromClient = true, bool blocklist = false, bool skipRedownload = false, bool changeCategory = false) { var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); if (pendingRelease != null) { - Remove(pendingRelease, blocklist); + Remove(pendingRelease, message, blocklist); return Deleted(); } @@ -86,14 +86,14 @@ namespace Sonarr.Api.V5.Queue throw new NotFoundException(); } - Remove(trackedDownload, removeFromClient, blocklist, skipRedownload, changeCategory); + Remove(trackedDownload, message, removeFromClient, blocklist, skipRedownload, changeCategory); _trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId); return Deleted(); } [HttpDelete("bulk")] - public object RemoveMany([FromBody] QueueBulkResource resource, [FromQuery] bool removeFromClient = true, [FromQuery] bool blocklist = false, [FromQuery] bool skipRedownload = false, [FromQuery] bool changeCategory = false) + public object RemoveMany([FromBody] QueueBulkResource resource, [FromQuery] string? message, [FromQuery] bool removeFromClient = true, [FromQuery] bool blocklist = false, [FromQuery] bool skipRedownload = false, [FromQuery] bool changeCategory = false) { var trackedDownloadIds = new List(); var pendingToRemove = new List(); @@ -119,12 +119,12 @@ namespace Sonarr.Api.V5.Queue foreach (var pendingRelease in pendingToRemove.DistinctBy(p => p.Id)) { - Remove(pendingRelease, blocklist); + Remove(pendingRelease, message, blocklist); } foreach (var trackedDownload in trackedToRemove.DistinctBy(t => t.DownloadItem.DownloadId)) { - Remove(trackedDownload, removeFromClient, blocklist, skipRedownload, changeCategory); + Remove(trackedDownload, message, removeFromClient, blocklist, skipRedownload, changeCategory); trackedDownloadIds.Add(trackedDownload.DownloadItem.DownloadId); } @@ -314,17 +314,17 @@ namespace Sonarr.Api.V5.Queue } } - private void Remove(NzbDrone.Core.Queue.Queue pendingRelease, bool blocklist) + private void Remove(NzbDrone.Core.Queue.Queue pendingRelease, string? message, bool blocklist) { if (blocklist) { - _blocklistService.Block(pendingRelease.RemoteEpisode, "Pending release manually blocklisted"); + _blocklistService.Block(pendingRelease.RemoteEpisode, message ?? "Pending release manually blocklisted", Request.GetSource()); } _pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id); } - private TrackedDownload? Remove(TrackedDownload trackedDownload, bool removeFromClient, bool blocklist, bool skipRedownload, bool changeCategory) + private TrackedDownload? Remove(TrackedDownload trackedDownload, string? message, bool removeFromClient, bool blocklist, bool skipRedownload, bool changeCategory) { if (removeFromClient) { @@ -351,7 +351,7 @@ namespace Sonarr.Api.V5.Queue if (blocklist) { - _failedDownloadService.MarkAsFailed(trackedDownload, skipRedownload); + _failedDownloadService.MarkAsFailed(trackedDownload, message, Request.GetSource(), skipRedownload); } if (!removeFromClient && !blocklist && !changeCategory) diff --git a/src/Sonarr.Http/Extensions/RequestExtensions.cs b/src/Sonarr.Http/Extensions/RequestExtensions.cs index 772264dfb..4689e40a0 100644 --- a/src/Sonarr.Http/Extensions/RequestExtensions.cs +++ b/src/Sonarr.Http/Extensions/RequestExtensions.cs @@ -97,6 +97,16 @@ namespace Sonarr.Http.Extensions return remoteIP.ToString(); } + public static string GetSource(this HttpRequest request) + { + if (request.Headers.TryGetValue("X-Sonarr-Client", out var source)) + { + return source; + } + + return NzbDrone.Common.Http.UserAgentParser.ParseSource(request.Headers["User-Agent"]); + } + public static void DisableCache(this IHeaderDictionary headers) { headers.Remove("Last-Modified");