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();