New: Show grabbed/blocklisted releases in Interactive Search

Closes #3955
This commit is contained in:
Mark McDowall 2025-11-16 16:36:42 -08:00
parent 10964b625e
commit e8c63405d2
No known key found for this signature in database
13 changed files with 270 additions and 71 deletions

View file

@ -26,6 +26,7 @@ import {
faArrowCircleRight as fasArrowCircleRight, faArrowCircleRight as fasArrowCircleRight,
faAsterisk as fasAsterisk, faAsterisk as fasAsterisk,
faBackward as fasBackward, faBackward as fasBackward,
faBan as fasBan,
faBars as fasBars, faBars as fasBars,
faBolt as fasBolt, faBolt as fasBolt,
faBookmark as fasBookmark, faBookmark as fasBookmark,
@ -124,6 +125,7 @@ export const ADVANCED_SETTINGS = fasCog;
export const ARROW_LEFT = fasArrowCircleLeft; export const ARROW_LEFT = fasArrowCircleLeft;
export const ARROW_RIGHT = fasArrowCircleRight; export const ARROW_RIGHT = fasArrowCircleRight;
export const BACKUP = farFileArchive; export const BACKUP = farFileArchive;
export const BLOCKLIST = fasBan;
export const BUG = fasBug; export const BUG = fasBug;
export const CALENDAR = fasCalendarAlt; export const CALENDAR = fasCalendarAlt;
export const CALENDAR_O = farCalendar; export const CALENDAR_O = farCalendar;

View file

@ -51,6 +51,16 @@
width: 50px; width: 50px;
} }
.history {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 75px;
}
.blocklistIconContainer {
margin-left: 5px;
}
.age, .age,
.size { .size {
composes: cell from '~Components/Table/Cells/TableRowCell.css'; composes: cell from '~Components/Table/Cells/TableRowCell.css';

View file

@ -2,9 +2,11 @@
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'age': string; 'age': string;
'blocklistIconContainer': string;
'customFormatScore': string; 'customFormatScore': string;
'download': string; 'download': string;
'downloadIcon': string; 'downloadIcon': string;
'history': string;
'indexer': string; 'indexer': string;
'indexerFlags': string; 'indexerFlags': string;
'interactiveIcon': string; 'interactiveIcon': string;

View file

@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
@ -78,6 +78,7 @@ interface InteractiveSearchRowProps extends Release {
function InteractiveSearchRow(props: InteractiveSearchRowProps) { function InteractiveSearchRow(props: InteractiveSearchRowProps) {
const { const {
decision, decision,
history,
parsedInfo, parsedInfo,
release, release,
publishDate, publishDate,
@ -129,6 +130,12 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false); const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false);
const { isGrabbing, isGrabbed, grabError, grabRelease } = useGrabRelease(); const { isGrabbing, isGrabbed, grabError, grabRelease } = useGrabRelease();
const isBlocklisted = useMemo(() => {
return (
decision.rejections.findIndex((r) => r.reason === 'blocklisted') >= 0
);
}, [decision]);
const handleGrabPress = useCallback(() => { const handleGrabPress = useCallback(() => {
if (downloadAllowed) { if (downloadAllowed) {
grabRelease({ grabRelease({
@ -206,6 +213,56 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
<TableRowCell className={styles.indexer}>{indexer}</TableRowCell> <TableRowCell className={styles.indexer}>{indexer}</TableRowCell>
<TableRowCell className={styles.history}>
{history ? (
<Icon
name={icons.DOWNLOADING}
kind={history.failed ? kinds.DANGER : kinds.DEFAULT}
title={`${
history.failed
? translate('FailedAt', {
date: formatDateTime(
history.failed,
longDateFormat,
timeFormat,
{ includeSeconds: true }
),
})
: translate('GrabbedAt', {
date: formatDateTime(
history.grabbed,
longDateFormat,
timeFormat,
{ includeSeconds: true }
),
})
}`}
/>
) : null}
{isBlocklisted ? (
<Icon
containerClassName={
history ? styles.blocklistIconContainer : undefined
}
name={icons.BLOCKLIST}
kind={kinds.DANGER}
title={
history?.failed
? `${translate('BlockListedAt', {
date: formatDateTime(
history.failed,
longDateFormat,
timeFormat,
{ includeSeconds: true }
),
})}`
: translate('Blocklisted')
}
/>
) : null}
</TableRowCell>
<TableRowCell className={styles.size}>{formatBytes(size)}</TableRowCell> <TableRowCell className={styles.size}>{formatBytes(size)}</TableRowCell>
<TableRowCell className={styles.peers}> <TableRowCell className={styles.peers}>

View file

@ -50,6 +50,13 @@ const { useOptions, useOption, getOptions, getOption, setOptions, setOption } =
isSortable: true, isSortable: true,
isVisible: true, isVisible: true,
}, },
{
name: 'history',
label: translate('History'),
isSortable: true,
fixedSortDirection: 'ascending',
isVisible: true,
},
{ {
name: 'size', name: 'size',
label: () => translate('Size'), label: () => translate('Size'),

View file

@ -42,6 +42,7 @@ export interface Release extends ModelBase {
parsedInfo: ParsedInfo; parsedInfo: ParsedInfo;
release: ReleaseInfo; release: ReleaseInfo;
decision: Decision; decision: Decision;
history?: ReleaseHistory;
qualityWeight: number; qualityWeight: number;
languages: Language[]; languages: Language[];
mappedSeriesId?: number; mappedSeriesId?: number;
@ -104,6 +105,11 @@ export interface Decision {
rejections: Rejection[]; rejections: Rejection[];
} }
export interface ReleaseHistory {
grabbed: string;
failed: string;
}
export const FILTERS: Filter[] = [ export const FILTERS: Filter[] = [
{ {
key: 'all', key: 'all',

View file

@ -7,6 +7,7 @@
using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers;
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Tv.Events; using NzbDrone.Core.Tv.Events;
@ -109,62 +110,12 @@ public void Delete(List<int> ids)
private bool SameNzb(Blocklist item, ReleaseInfo release) private bool SameNzb(Blocklist item, ReleaseInfo release)
{ {
if (item.PublishedDate == release.PublishDate) return ReleaseComparer.SameNzb(new ReleaseComparerModel(item), release);
{
return true;
}
if (!HasSameIndexer(item, release.Indexer) &&
HasSamePublishedDate(item, release.PublishDate) &&
HasSameSize(item, release.Size))
{
return true;
}
return false;
} }
private bool SameTorrent(Blocklist item, TorrentInfo release) private bool SameTorrent(Blocklist item, TorrentInfo release)
{ {
if (release.InfoHash.IsNotNullOrWhiteSpace()) return ReleaseComparer.SameTorrent(new ReleaseComparerModel(item), release);
{
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();
} }
public void Execute(ClearBlocklistCommand message) public void Execute(ClearBlocklistCommand message)

View file

@ -156,6 +156,7 @@
"BlackholeWatchFolder": "Watch Folder", "BlackholeWatchFolder": "Watch Folder",
"BlackholeWatchFolderHelpText": "Folder from which {appName} should import completed downloads", "BlackholeWatchFolderHelpText": "Folder from which {appName} should import completed downloads",
"Blocklist": "Blocklist", "Blocklist": "Blocklist",
"BlockListedAt": "Blocklisted at {date}",
"BlocklistAndSearch": "Blocklist and Search", "BlocklistAndSearch": "Blocklist and Search",
"BlocklistAndSearchHint": "Start a search for a replacement after blocklisting", "BlocklistAndSearchHint": "Start a search for a replacement after blocklisting",
"BlocklistAndSearchMultipleHint": "Start searches for replacements 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)", "ExtraFileExtensionsHelpText": "Comma separated list of extra files to import (.nfo will be imported as .nfo-orig)",
"ExtraFileExtensionsHelpTextsExamples": "Examples: '.sub, .nfo' or 'sub,nfo'", "ExtraFileExtensionsHelpTextsExamples": "Examples: '.sub, .nfo' or 'sub,nfo'",
"Failed": "Failed", "Failed": "Failed",
"FailedAt": "Failed at: {date}",
"FailedToFetchSettings": "Failed to fetch settings", "FailedToFetchSettings": "Failed to fetch settings",
"FailedToFetchUpdates": "Failed to fetch updates", "FailedToFetchUpdates": "Failed to fetch updates",
"FailedToLoadCustomFiltersFromApi": "Failed to load custom filters from API", "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}'?", "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", "GrabSelected": "Grab Selected",
"Grabbed": "Grabbed", "Grabbed": "Grabbed",
"GrabbedAt": "Grabbed at: {date}",
"Group": "Group", "Group": "Group",
"HardlinkCopyFiles": "Hardlink/Copy Files", "HardlinkCopyFiles": "Hardlink/Copy Files",
"HasMissingSeason": "Has Missing Season", "HasMissingSeason": "Has Missing Season",

View file

@ -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"));
}
}

View file

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

View file

@ -7,6 +7,7 @@
using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Download; using NzbDrone.Core.Download;
using NzbDrone.Core.Exceptions; using NzbDrone.Core.Exceptions;
using NzbDrone.Core.History;
using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers;
using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.Parser; using NzbDrone.Core.Parser;
@ -31,6 +32,7 @@ public class ReleaseController : RestController<ReleaseResource>
private readonly ISeriesService _seriesService; private readonly ISeriesService _seriesService;
private readonly IEpisodeService _episodeService; private readonly IEpisodeService _episodeService;
private readonly IParsingService _parsingService; private readonly IParsingService _parsingService;
private readonly IHistoryService _historyService;
private readonly Logger _logger; private readonly Logger _logger;
private readonly QualityProfile _qualityProfile; private readonly QualityProfile _qualityProfile;
@ -44,6 +46,7 @@ public ReleaseController(IFetchAndParseRss rssFetcherAndParser,
ISeriesService seriesService, ISeriesService seriesService,
IEpisodeService episodeService, IEpisodeService episodeService,
IParsingService parsingService, IParsingService parsingService,
IHistoryService historyService,
ICacheManager cacheManager, ICacheManager cacheManager,
IQualityProfileService qualityProfileService, IQualityProfileService qualityProfileService,
Logger logger) Logger logger)
@ -56,6 +59,7 @@ public ReleaseController(IFetchAndParseRss rssFetcherAndParser,
_seriesService = seriesService; _seriesService = seriesService;
_episodeService = episodeService; _episodeService = episodeService;
_parsingService = parsingService; _parsingService = parsingService;
_historyService = historyService;
_logger = logger; _logger = logger;
_qualityProfile = qualityProfileService.GetDefaultProfile(string.Empty); _qualityProfile = qualityProfileService.GetDefaultProfile(string.Empty);
@ -204,8 +208,9 @@ private async Task<List<ReleaseResource>> GetEpisodeReleases(int episodeId)
{ {
var decisions = await _releaseSearchService.EpisodeSearch(episodeId, true, true); var decisions = await _releaseSearchService.EpisodeSearch(episodeId, true, true);
var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions);
var history = _historyService.FindByEpisodeId(episodeId);
return MapDecisions(prioritizedDecisions); return MapDecisions(prioritizedDecisions, history);
} }
catch (SearchFailedException ex) catch (SearchFailedException ex)
{ {
@ -224,8 +229,9 @@ private async Task<List<ReleaseResource>> GetSeasonReleases(int seriesId, int se
{ {
var decisions = await _releaseSearchService.SeasonSearch(seriesId, seasonNumber, false, false, true, true); var decisions = await _releaseSearchService.SeasonSearch(seriesId, seasonNumber, false, false, true, true);
var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions);
var history = _historyService.GetBySeason(seriesId, seasonNumber, null);
return MapDecisions(prioritizedDecisions); return MapDecisions(prioritizedDecisions, history);
} }
catch (SearchFailedException ex) catch (SearchFailedException ex)
{ {
@ -244,7 +250,7 @@ private async Task<List<ReleaseResource>> GetRss()
var decisions = _downloadDecisionMaker.GetRssDecision(reports); var decisions = _downloadDecisionMaker.GetRssDecision(reports);
var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions);
return MapDecisions(prioritizedDecisions); return MapDecisions(prioritizedDecisions, new List<EpisodeHistory>());
} }
private string GetCacheKey(ReleaseResource resource) private string GetCacheKey(ReleaseResource resource)
@ -257,7 +263,7 @@ private string GetCacheKey(ReleaseGrabResource resource)
return string.Concat(resource.IndexerId, "_", resource.Guid); return string.Concat(resource.IndexerId, "_", resource.Guid);
} }
private List<ReleaseResource> MapDecisions(IEnumerable<DownloadDecision> decisions) private List<ReleaseResource> MapDecisions(IEnumerable<DownloadDecision> decisions, List<EpisodeHistory> history)
{ {
var result = new List<ReleaseResource>(); var result = new List<ReleaseResource>();
@ -265,9 +271,70 @@ private List<ReleaseResource> MapDecisions(IEnumerable<DownloadDecision> decisio
{ {
var release = downloadDecision.MapDecision(result.Count, _qualityProfile); var release = downloadDecision.MapDecision(result.Count, _qualityProfile);
release.History = AddHistory(downloadDecision.RemoteEpisode.Release, history);
result.Add(release); result.Add(release);
} }
return result; return result;
} }
private ReleaseHistoryResource? AddHistory(ReleaseInfo release, List<EpisodeHistory> 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;
}
} }

View file

@ -0,0 +1,7 @@
namespace Sonarr.Api.V5.Release;
public class ReleaseHistoryResource
{
public DateTime? Grabbed { get; set; }
public DateTime? Failed { get; set; }
}

View file

@ -13,6 +13,7 @@ public class ReleaseResource : RestResource
public ParsedEpisodeInfoResource? ParsedInfo { get; set; } public ParsedEpisodeInfoResource? ParsedInfo { get; set; }
public ReleaseInfoResource? Release { get; set; } public ReleaseInfoResource? Release { get; set; }
public ReleaseDecisionResource? Decision { get; set; } public ReleaseDecisionResource? Decision { get; set; }
public ReleaseHistoryResource? History { get; set; }
public int QualityWeight { get; set; } public int QualityWeight { get; set; }
public List<Language> Languages { get; set; } = []; public List<Language> Languages { get; set; } = [];
public int? MappedSeasonNumber { get; set; } public int? MappedSeasonNumber { get; set; }
@ -56,20 +57,6 @@ public static ReleaseResource ToResource(this DownloadDecision model)
}; };
} }
public static List<ReleaseResource> MapDecisions(this IEnumerable<DownloadDecision> decisions, QualityProfile profile)
{
var result = new List<ReleaseResource>();
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) public static ReleaseResource MapDecision(this DownloadDecision decision, int initialWeight, QualityProfile profile)
{ {
var release = decision.ToResource(); var release = decision.ToResource();