diff --git a/frontend/src/Helpers/Props/icons.ts b/frontend/src/Helpers/Props/icons.ts index 32d0ce55d..4f0f24f73 100644 --- a/frontend/src/Helpers/Props/icons.ts +++ b/frontend/src/Helpers/Props/icons.ts @@ -26,6 +26,7 @@ import { faArrowCircleRight as fasArrowCircleRight, faAsterisk as fasAsterisk, faBackward as fasBackward, + faBan as fasBan, faBars as fasBars, faBolt as fasBolt, faBookmark as fasBookmark, @@ -124,6 +125,7 @@ export const ADVANCED_SETTINGS = fasCog; export const ARROW_LEFT = fasArrowCircleLeft; export const ARROW_RIGHT = fasArrowCircleRight; export const BACKUP = farFileArchive; +export const BLOCKLIST = fasBan; export const BUG = fasBug; export const CALENDAR = fasCalendarAlt; export const CALENDAR_O = farCalendar; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css index 03f454da2..adade0935 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -51,6 +51,16 @@ width: 50px; } +.history { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 75px; +} + +.blocklistIconContainer { + margin-left: 5px; +} + .age, .size { composes: cell from '~Components/Table/Cells/TableRowCell.css'; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts index fd4007966..1ae29abb0 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts @@ -2,9 +2,11 @@ // Please do not change this file! interface CssExports { 'age': string; + 'blocklistIconContainer': string; 'customFormatScore': string; 'download': string; 'downloadIcon': string; + 'history': string; 'indexer': string; 'indexerFlags': string; 'interactiveIcon': string; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx index 63bf9cfc7..2fbc18f21 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import Icon from 'Components/Icon'; @@ -78,6 +78,7 @@ interface InteractiveSearchRowProps extends Release { function InteractiveSearchRow(props: InteractiveSearchRowProps) { const { decision, + history, parsedInfo, release, publishDate, @@ -129,6 +130,12 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) { const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false); const { isGrabbing, isGrabbed, grabError, grabRelease } = useGrabRelease(); + const isBlocklisted = useMemo(() => { + return ( + decision.rejections.findIndex((r) => r.reason === 'blocklisted') >= 0 + ); + }, [decision]); + const handleGrabPress = useCallback(() => { if (downloadAllowed) { grabRelease({ @@ -206,6 +213,56 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) { {indexer} + + {history ? ( + + ) : null} + + {isBlocklisted ? ( + + ) : null} + + {formatBytes(size)} diff --git a/frontend/src/InteractiveSearch/releaseOptionsStore.ts b/frontend/src/InteractiveSearch/releaseOptionsStore.ts index 31a370f0c..80ac76262 100644 --- a/frontend/src/InteractiveSearch/releaseOptionsStore.ts +++ b/frontend/src/InteractiveSearch/releaseOptionsStore.ts @@ -50,6 +50,13 @@ const { useOptions, useOption, getOptions, getOption, setOptions, setOption } = isSortable: true, isVisible: true, }, + { + name: 'history', + label: translate('History'), + isSortable: true, + fixedSortDirection: 'ascending', + isVisible: true, + }, { name: 'size', label: () => translate('Size'), diff --git a/frontend/src/InteractiveSearch/useReleases.ts b/frontend/src/InteractiveSearch/useReleases.ts index 927a327bf..5b5ddf660 100644 --- a/frontend/src/InteractiveSearch/useReleases.ts +++ b/frontend/src/InteractiveSearch/useReleases.ts @@ -42,6 +42,7 @@ export interface Release extends ModelBase { parsedInfo: ParsedInfo; release: ReleaseInfo; decision: Decision; + history?: ReleaseHistory; qualityWeight: number; languages: Language[]; mappedSeriesId?: number; @@ -104,6 +105,11 @@ export interface Decision { rejections: Rejection[]; } +export interface ReleaseHistory { + grabbed: string; + failed: string; +} + export const FILTERS: Filter[] = [ { key: 'all', diff --git a/src/NzbDrone.Core/Blocklisting/BlocklistService.cs b/src/NzbDrone.Core/Blocklisting/BlocklistService.cs index 52ad1f8a5..cbea38f6b 100644 --- a/src/NzbDrone.Core/Blocklisting/BlocklistService.cs +++ b/src/NzbDrone.Core/Blocklisting/BlocklistService.cs @@ -7,6 +7,7 @@ using NzbDrone.Core.Indexers; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Tv.Events; @@ -109,62 +110,12 @@ public void Delete(List ids) private bool SameNzb(Blocklist item, ReleaseInfo release) { - if (item.PublishedDate == release.PublishDate) - { - return true; - } - - if (!HasSameIndexer(item, release.Indexer) && - HasSamePublishedDate(item, release.PublishDate) && - HasSameSize(item, release.Size)) - { - return true; - } - - return false; + return ReleaseComparer.SameNzb(new ReleaseComparerModel(item), release); } private bool SameTorrent(Blocklist item, TorrentInfo release) { - if (release.InfoHash.IsNotNullOrWhiteSpace()) - { - return release.InfoHash.Equals(item.TorrentInfoHash, StringComparison.InvariantCultureIgnoreCase); - } - - return HasSameIndexer(item, release.Indexer); - } - - private bool HasSameIndexer(Blocklist item, string indexer) - { - if (item.Indexer.IsNullOrWhiteSpace()) - { - return true; - } - - return item.Indexer.Equals(indexer, StringComparison.InvariantCultureIgnoreCase); - } - - private bool HasSamePublishedDate(Blocklist item, DateTime publishedDate) - { - if (!item.PublishedDate.HasValue) - { - return true; - } - - return item.PublishedDate.Value.AddMinutes(-2) <= publishedDate && - item.PublishedDate.Value.AddMinutes(2) >= publishedDate; - } - - private bool HasSameSize(Blocklist item, long size) - { - if (!item.Size.HasValue) - { - return true; - } - - var difference = Math.Abs(item.Size.Value - size); - - return difference <= 2.Megabytes(); + return ReleaseComparer.SameTorrent(new ReleaseComparerModel(item), release); } public void Execute(ClearBlocklistCommand message) diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 39ac24daa..5f4b7ffb1 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -156,6 +156,7 @@ "BlackholeWatchFolder": "Watch Folder", "BlackholeWatchFolderHelpText": "Folder from which {appName} should import completed downloads", "Blocklist": "Blocklist", + "BlockListedAt": "Blocklisted at {date}", "BlocklistAndSearch": "Blocklist and Search", "BlocklistAndSearchHint": "Start a search for a replacement after blocklisting", "BlocklistAndSearchMultipleHint": "Start searches for replacements after blocklisting", @@ -706,6 +707,7 @@ "ExtraFileExtensionsHelpText": "Comma separated list of extra files to import (.nfo will be imported as .nfo-orig)", "ExtraFileExtensionsHelpTextsExamples": "Examples: '.sub, .nfo' or 'sub,nfo'", "Failed": "Failed", + "FailedAt": "Failed at: {date}", "FailedToFetchSettings": "Failed to fetch settings", "FailedToFetchUpdates": "Failed to fetch updates", "FailedToLoadCustomFiltersFromApi": "Failed to load custom filters from API", @@ -795,6 +797,7 @@ "GrabReleaseUnknownSeriesOrEpisodeMessageText": "{appName} was unable to determine which series and episode this release was for. {appName} may be unable to automatically import this release. Do you want to grab '{title}'?", "GrabSelected": "Grab Selected", "Grabbed": "Grabbed", + "GrabbedAt": "Grabbed at: {date}", "Group": "Group", "HardlinkCopyFiles": "Hardlink/Copy Files", "HasMissingSeason": "Has Missing Season", diff --git a/src/NzbDrone.Core/Parser/Model/ReleaseComparerModel.cs b/src/NzbDrone.Core/Parser/Model/ReleaseComparerModel.cs new file mode 100644 index 000000000..2bcb3773c --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/ReleaseComparerModel.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.Blocklisting; +using NzbDrone.Core.History; + +namespace NzbDrone.Core.Parser.Model; + +public class ReleaseComparerModel +{ + public string Title { get; set; } + public string TorrentInfoHash { get; set; } + public DateTime? PublishedDate { get; set; } + public string Indexer { get; set; } + public long Size { get; set; } + + public ReleaseComparerModel(Blocklist blocklist) + { + Title = blocklist.SourceTitle; + TorrentInfoHash = blocklist.TorrentInfoHash; + PublishedDate = blocklist.PublishedDate; + Indexer = blocklist.Indexer; + Size = blocklist.Size ?? 0; + } + + public ReleaseComparerModel(EpisodeHistory history) + { + Title = history.SourceTitle; + PublishedDate = history.Date; + Indexer = history.Data.GetValueOrDefault("indexer"); + Size = long.Parse(history.Data.GetValueOrDefault("size", "0")); + } +} diff --git a/src/NzbDrone.Core/Parser/ReleaseComparer.cs b/src/NzbDrone.Core/Parser/ReleaseComparer.cs new file mode 100644 index 000000000..7aa0a226e --- /dev/null +++ b/src/NzbDrone.Core/Parser/ReleaseComparer.cs @@ -0,0 +1,68 @@ +using System; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Parser; + +public static class ReleaseComparer +{ + public static bool SameNzb(ReleaseComparerModel item, ReleaseInfo release) + { + if (item.PublishedDate == release.PublishDate) + { + return true; + } + + if (!HasSameIndexer(item, release.Indexer) && + HasSamePublishedDate(item, release.PublishDate) && + HasSameSize(item, release.Size)) + { + return true; + } + + return false; + } + + public static bool SameTorrent(ReleaseComparerModel item, TorrentInfo release) + { + if (release.InfoHash.IsNotNullOrWhiteSpace()) + { + return release.InfoHash.Equals(item.TorrentInfoHash, StringComparison.InvariantCultureIgnoreCase); + } + + return HasSameIndexer(item, release.Indexer); + } + + private static bool HasSameIndexer(ReleaseComparerModel item, string indexer) + { + if (item.Indexer.IsNullOrWhiteSpace()) + { + return true; + } + + return item.Indexer.Equals(indexer, StringComparison.InvariantCultureIgnoreCase); + } + + private static bool HasSamePublishedDate(ReleaseComparerModel item, DateTime publishedDate) + { + if (!item.PublishedDate.HasValue) + { + return true; + } + + return item.PublishedDate.Value.AddMinutes(-2) <= publishedDate && + item.PublishedDate.Value.AddMinutes(2) >= publishedDate; + } + + private static bool HasSameSize(ReleaseComparerModel item, long size) + { + if (item.Size == 0) + { + return true; + } + + var difference = Math.Abs(item.Size - size); + + return difference <= 2.Megabytes(); + } +} diff --git a/src/Sonarr.Api.V5/Release/ReleaseController.cs b/src/Sonarr.Api.V5/Release/ReleaseController.cs index c1725acb6..a1748e138 100644 --- a/src/Sonarr.Api.V5/Release/ReleaseController.cs +++ b/src/Sonarr.Api.V5/Release/ReleaseController.cs @@ -7,6 +7,7 @@ using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.Exceptions; +using NzbDrone.Core.History; using NzbDrone.Core.Indexers; using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.Parser; @@ -31,6 +32,7 @@ public class ReleaseController : RestController private readonly ISeriesService _seriesService; private readonly IEpisodeService _episodeService; private readonly IParsingService _parsingService; + private readonly IHistoryService _historyService; private readonly Logger _logger; private readonly QualityProfile _qualityProfile; @@ -44,6 +46,7 @@ public ReleaseController(IFetchAndParseRss rssFetcherAndParser, ISeriesService seriesService, IEpisodeService episodeService, IParsingService parsingService, + IHistoryService historyService, ICacheManager cacheManager, IQualityProfileService qualityProfileService, Logger logger) @@ -56,6 +59,7 @@ public ReleaseController(IFetchAndParseRss rssFetcherAndParser, _seriesService = seriesService; _episodeService = episodeService; _parsingService = parsingService; + _historyService = historyService; _logger = logger; _qualityProfile = qualityProfileService.GetDefaultProfile(string.Empty); @@ -204,8 +208,9 @@ private async Task> GetEpisodeReleases(int episodeId) { var decisions = await _releaseSearchService.EpisodeSearch(episodeId, true, true); var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); + var history = _historyService.FindByEpisodeId(episodeId); - return MapDecisions(prioritizedDecisions); + return MapDecisions(prioritizedDecisions, history); } catch (SearchFailedException ex) { @@ -224,8 +229,9 @@ private async Task> GetSeasonReleases(int seriesId, int se { var decisions = await _releaseSearchService.SeasonSearch(seriesId, seasonNumber, false, false, true, true); var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); + var history = _historyService.GetBySeason(seriesId, seasonNumber, null); - return MapDecisions(prioritizedDecisions); + return MapDecisions(prioritizedDecisions, history); } catch (SearchFailedException ex) { @@ -244,7 +250,7 @@ private async Task> GetRss() var decisions = _downloadDecisionMaker.GetRssDecision(reports); var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); - return MapDecisions(prioritizedDecisions); + return MapDecisions(prioritizedDecisions, new List()); } private string GetCacheKey(ReleaseResource resource) @@ -257,7 +263,7 @@ private string GetCacheKey(ReleaseGrabResource resource) return string.Concat(resource.IndexerId, "_", resource.Guid); } - private List MapDecisions(IEnumerable decisions) + private List MapDecisions(IEnumerable decisions, List history) { var result = new List(); @@ -265,9 +271,70 @@ private List MapDecisions(IEnumerable decisio { var release = downloadDecision.MapDecision(result.Count, _qualityProfile); + release.History = AddHistory(downloadDecision.RemoteEpisode.Release, history); + result.Add(release); } return result; } + + private ReleaseHistoryResource? AddHistory(ReleaseInfo release, List history) + { + var grabbed = history.FirstOrDefault(h => h.EventType == EpisodeHistoryEventType.Grabbed && + h.Data.TryGetValue("guid", out var guid) && + guid == release.Guid); + + if (grabbed == null && release.DownloadProtocol == DownloadProtocol.Torrent) + { + if (release is not TorrentInfo torrentInfo) + { + return null; + } + + if (torrentInfo.InfoHash.IsNotNullOrWhiteSpace()) + { + grabbed = history.FirstOrDefault(h => h.EventType == EpisodeHistoryEventType.Grabbed && + ReleaseComparer.SameTorrent(new ReleaseComparerModel(h), + torrentInfo)); + } + + if (grabbed == null) + { + grabbed = history.FirstOrDefault(h => h.EventType == EpisodeHistoryEventType.Grabbed && + h.SourceTitle == release.Title && + (DownloadProtocol)Convert.ToInt32( + h.Data.GetValueOrDefault("protocol")) == + DownloadProtocol.Torrent && + ReleaseComparer.SameTorrent(new ReleaseComparerModel(h), + torrentInfo)); + } + } + else if (grabbed == null) + { + grabbed = history.FirstOrDefault(h => h.EventType == EpisodeHistoryEventType.Grabbed && + ReleaseComparer.SameNzb(new ReleaseComparerModel(h), + release)); + } + + if (grabbed != null) + { + var resource = new ReleaseHistoryResource + { + Grabbed = grabbed.Date, + }; + + var failedHistory = history.FirstOrDefault(h => h.EventType == EpisodeHistoryEventType.DownloadFailed && + h.DownloadId == grabbed.DownloadId); + + if (failedHistory != null) + { + resource.Failed = failedHistory.Date; + } + + return resource; + } + + return null; + } } diff --git a/src/Sonarr.Api.V5/Release/ReleaseHistoryResource.cs b/src/Sonarr.Api.V5/Release/ReleaseHistoryResource.cs new file mode 100644 index 000000000..4c762e397 --- /dev/null +++ b/src/Sonarr.Api.V5/Release/ReleaseHistoryResource.cs @@ -0,0 +1,7 @@ +namespace Sonarr.Api.V5.Release; + +public class ReleaseHistoryResource +{ + public DateTime? Grabbed { get; set; } + public DateTime? Failed { get; set; } +} diff --git a/src/Sonarr.Api.V5/Release/ReleaseResource.cs b/src/Sonarr.Api.V5/Release/ReleaseResource.cs index 33e1ad35c..1f7407e78 100644 --- a/src/Sonarr.Api.V5/Release/ReleaseResource.cs +++ b/src/Sonarr.Api.V5/Release/ReleaseResource.cs @@ -13,6 +13,7 @@ public class ReleaseResource : RestResource public ParsedEpisodeInfoResource? ParsedInfo { get; set; } public ReleaseInfoResource? Release { get; set; } public ReleaseDecisionResource? Decision { get; set; } + public ReleaseHistoryResource? History { get; set; } public int QualityWeight { get; set; } public List Languages { get; set; } = []; public int? MappedSeasonNumber { get; set; } @@ -56,20 +57,6 @@ public static ReleaseResource ToResource(this DownloadDecision model) }; } - public static List MapDecisions(this IEnumerable decisions, QualityProfile profile) - { - var result = new List(); - - foreach (var downloadDecision in decisions) - { - var release = MapDecision(downloadDecision, result.Count, profile); - - result.Add(release); - } - - return result; - } - public static ReleaseResource MapDecision(this DownloadDecision decision, int initialWeight, QualityProfile profile) { var release = decision.ToResource();