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,
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;

View file

@ -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';

View file

@ -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;

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 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) {
<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.peers}>

View file

@ -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'),

View file

@ -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',

View file

@ -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<int> 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)

View file

@ -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",

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.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<ReleaseResource>
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<List<ReleaseResource>> 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<List<ReleaseResource>> 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<List<ReleaseResource>> GetRss()
var decisions = _downloadDecisionMaker.GetRssDecision(reports);
var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions);
return MapDecisions(prioritizedDecisions);
return MapDecisions(prioritizedDecisions, new List<EpisodeHistory>());
}
private string GetCacheKey(ReleaseResource resource)
@ -257,7 +263,7 @@ private string GetCacheKey(ReleaseGrabResource resource)
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>();
@ -265,9 +271,70 @@ private List<ReleaseResource> MapDecisions(IEnumerable<DownloadDecision> 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<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 ReleaseInfoResource? Release { get; set; }
public ReleaseDecisionResource? Decision { get; set; }
public ReleaseHistoryResource? History { get; set; }
public int QualityWeight { get; set; }
public List<Language> Languages { 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)
{
var release = decision.ToResource();