You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
343 lines
14 KiB
343 lines
14 KiB
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using System.Xml.Linq;
|
|
using Microsoft.AspNetCore.Cors;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using NLog;
|
|
using NzbDrone.Common.Extensions;
|
|
using NzbDrone.Common.Http;
|
|
using NzbDrone.Core.Download;
|
|
using NzbDrone.Core.Exceptions;
|
|
using NzbDrone.Core.Indexers;
|
|
using NzbDrone.Core.IndexerSearch;
|
|
using NzbDrone.Core.Parser.Model;
|
|
using NzbDrone.Core.ThingiProvider.Status;
|
|
using Prowlarr.Http.Extensions;
|
|
using Prowlarr.Http.REST;
|
|
using BadRequestException = NzbDrone.Core.Exceptions.BadRequestException;
|
|
|
|
namespace NzbDrone.Api.V1.Indexers
|
|
{
|
|
[Route("")]
|
|
[EnableCors("ApiCorsPolicy")]
|
|
[ApiController]
|
|
public class NewznabController : Controller
|
|
{
|
|
private IIndexerFactory _indexerFactory { get; set; }
|
|
private IReleaseSearchService _releaseSearchService { get; set; }
|
|
private IIndexerLimitService _indexerLimitService { get; set; }
|
|
private IIndexerStatusService _indexerStatusService;
|
|
private IDownloadMappingService _downloadMappingService { get; set; }
|
|
private IDownloadService _downloadService { get; set; }
|
|
private readonly Logger _logger;
|
|
|
|
public NewznabController(IndexerFactory indexerFactory,
|
|
IReleaseSearchService releaseSearchService,
|
|
IIndexerLimitService indexerLimitService,
|
|
IIndexerStatusService indexerStatusService,
|
|
IDownloadMappingService downloadMappingService,
|
|
IDownloadService downloadService,
|
|
Logger logger)
|
|
{
|
|
_indexerFactory = indexerFactory;
|
|
_releaseSearchService = releaseSearchService;
|
|
_indexerLimitService = indexerLimitService;
|
|
_indexerStatusService = indexerStatusService;
|
|
_downloadMappingService = downloadMappingService;
|
|
_downloadService = downloadService;
|
|
_logger = logger;
|
|
}
|
|
|
|
[HttpGet("/api/v1/indexer/{id:int}/newznab")]
|
|
[HttpGet("{id:int}/api")]
|
|
public async Task<IActionResult> GetNewznabResponse(int id, [FromQuery] NewznabRequest request)
|
|
{
|
|
var requestType = request.t;
|
|
request.source = Request.GetSource();
|
|
request.server = Request.GetServerUrl();
|
|
request.host = Request.GetHostName();
|
|
|
|
if (requestType.IsNullOrWhiteSpace())
|
|
{
|
|
return CreateResponse(CreateErrorXML(200, "Missing parameter (t)"), statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
request.imdbid = request.imdbid?.TrimStart('t') ?? null;
|
|
|
|
if (request.imdbid.IsNotNullOrWhiteSpace())
|
|
{
|
|
if (!int.TryParse(request.imdbid, out var imdb) || imdb == 0)
|
|
{
|
|
return CreateResponse(CreateErrorXML(201, "Incorrect parameter (imdbid)"), statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
}
|
|
|
|
if (id == 0)
|
|
{
|
|
switch (requestType)
|
|
{
|
|
case "caps":
|
|
var caps = new IndexerCapabilities
|
|
{
|
|
TvSearchParams = new List<TvSearchParam>
|
|
{
|
|
TvSearchParam.Q, TvSearchParam.Season, TvSearchParam.Ep
|
|
},
|
|
MovieSearchParams = new List<MovieSearchParam>
|
|
{
|
|
MovieSearchParam.Q
|
|
},
|
|
MusicSearchParams = new List<MusicSearchParam>
|
|
{
|
|
MusicSearchParam.Q
|
|
},
|
|
BookSearchParams = new List<BookSearchParam>
|
|
{
|
|
BookSearchParam.Q
|
|
}
|
|
};
|
|
|
|
foreach (var cat in NewznabStandardCategory.AllCats)
|
|
{
|
|
caps.Categories.AddCategoryMapping(1, cat);
|
|
}
|
|
|
|
return CreateResponse(caps.ToXml());
|
|
case "search":
|
|
case "tvsearch":
|
|
case "music":
|
|
case "book":
|
|
case "movie":
|
|
var results = new NewznabResults
|
|
{
|
|
Releases = new List<ReleaseInfo>
|
|
{
|
|
new ()
|
|
{
|
|
Title = "Test Release",
|
|
Guid = "https://prowlarr.com",
|
|
DownloadUrl = "https://prowlarr.com",
|
|
PublishDate = DateTime.Now
|
|
}
|
|
}
|
|
};
|
|
|
|
return CreateResponse(results.ToXml(DownloadProtocol.Usenet));
|
|
}
|
|
}
|
|
|
|
var indexerDef = _indexerFactory.Get(id);
|
|
|
|
if (indexerDef == null)
|
|
{
|
|
throw new NotFoundException("Indexer Not Found");
|
|
}
|
|
|
|
if (!indexerDef.Enable)
|
|
{
|
|
return CreateResponse(CreateErrorXML(410, "Indexer is disabled"), statusCode: StatusCodes.Status410Gone);
|
|
}
|
|
|
|
var indexer = _indexerFactory.GetInstance(indexerDef);
|
|
|
|
var blockedIndexerStatus = GetBlockedIndexerStatus(indexer);
|
|
|
|
if (blockedIndexerStatus?.DisabledTill != null)
|
|
{
|
|
var retryAfterDisabledTill = Convert.ToInt32(blockedIndexerStatus.DisabledTill.Value.ToLocalTime().Subtract(DateTime.Now).TotalSeconds);
|
|
AddRetryAfterHeader(retryAfterDisabledTill);
|
|
|
|
return CreateResponse(CreateErrorXML(429, $"Indexer is disabled till {blockedIndexerStatus.DisabledTill.Value.ToLocalTime()} due to recent failures."), statusCode: StatusCodes.Status429TooManyRequests);
|
|
}
|
|
|
|
// TODO Optimize this so it's not called here and in ReleaseSearchService (for manual search)
|
|
if (_indexerLimitService.AtQueryLimit(indexerDef))
|
|
{
|
|
var retryAfterQueryLimit = _indexerLimitService.CalculateRetryAfterQueryLimit(indexerDef);
|
|
AddRetryAfterHeader(retryAfterQueryLimit);
|
|
|
|
var queryLimit = ((IIndexerSettings)indexer.Definition.Settings).BaseSettings.QueryLimit;
|
|
var intervalLimitHours = _indexerLimitService.CalculateIntervalLimitHours(indexerDef);
|
|
|
|
return CreateResponse(CreateErrorXML(429, $"User configurable Indexer Query Limit of {queryLimit} in last {intervalLimitHours} hour(s) reached."), statusCode: StatusCodes.Status429TooManyRequests);
|
|
}
|
|
|
|
switch (requestType)
|
|
{
|
|
case "caps":
|
|
var caps = indexer.GetCapabilities();
|
|
return CreateResponse(caps.ToXml());
|
|
case "search":
|
|
case "tvsearch":
|
|
case "music":
|
|
case "book":
|
|
case "movie":
|
|
var results = await _releaseSearchService.Search(request, new List<int> { indexerDef.Id }, false);
|
|
|
|
foreach (var result in results.Releases)
|
|
{
|
|
result.DownloadUrl = result.DownloadUrl.IsNotNullOrWhiteSpace() ? _downloadMappingService.ConvertToProxyLink(new Uri(result.DownloadUrl), request.server, indexerDef.Id, result.Title).AbsoluteUri : null;
|
|
|
|
if (result.DownloadProtocol == DownloadProtocol.Torrent &&
|
|
result is TorrentInfo torrentRelease &&
|
|
torrentRelease.MagnetUrl.IsNotNullOrWhiteSpace())
|
|
{
|
|
result.DownloadUrl ??= _downloadMappingService.ConvertToProxyLink(new Uri(torrentRelease.MagnetUrl), request.server, indexerDef.Id, torrentRelease.Title).AbsoluteUri;
|
|
}
|
|
}
|
|
|
|
return CreateResponse(results.ToXml(indexer.Protocol));
|
|
default:
|
|
return CreateResponse(CreateErrorXML(202, $"No such function ({requestType})"), statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
}
|
|
|
|
[HttpGet("/api/v1/indexer/{id:int}/download")]
|
|
[HttpGet("{id:int}/download")]
|
|
public async Task<object> GetDownload(int id, string link, string file)
|
|
{
|
|
var indexerDef = _indexerFactory.Get(id);
|
|
|
|
if (indexerDef == null)
|
|
{
|
|
throw new NotFoundException("Indexer Not Found");
|
|
}
|
|
|
|
if (!indexerDef.Enable)
|
|
{
|
|
return CreateResponse(CreateErrorXML(410, "Indexer is disabled"), statusCode: StatusCodes.Status410Gone);
|
|
}
|
|
|
|
var indexer = _indexerFactory.GetInstance(indexerDef);
|
|
|
|
var blockedIndexerStatus = GetBlockedIndexerStatus(indexer);
|
|
|
|
if (blockedIndexerStatus?.DisabledTill != null)
|
|
{
|
|
var retryAfterDisabledTill = Convert.ToInt32(blockedIndexerStatus.DisabledTill.Value.ToLocalTime().Subtract(DateTime.Now).TotalSeconds);
|
|
AddRetryAfterHeader(retryAfterDisabledTill);
|
|
|
|
return CreateResponse(CreateErrorXML(429, $"Indexer is disabled till {blockedIndexerStatus.DisabledTill.Value.ToLocalTime()} due to recent failures."), statusCode: StatusCodes.Status429TooManyRequests);
|
|
}
|
|
|
|
if (_indexerLimitService.AtDownloadLimit(indexerDef))
|
|
{
|
|
var retryAfterDownloadLimit = _indexerLimitService.CalculateRetryAfterDownloadLimit(indexerDef);
|
|
AddRetryAfterHeader(retryAfterDownloadLimit);
|
|
|
|
var grabLimit = ((IIndexerSettings)indexer.Definition.Settings).BaseSettings.GrabLimit;
|
|
var intervalLimitHours = _indexerLimitService.CalculateIntervalLimitHours(indexerDef);
|
|
|
|
return CreateResponse(CreateErrorXML(429, $"User configurable Indexer Grab Limit of {grabLimit} in last {intervalLimitHours} hour(s) reached."), statusCode: StatusCodes.Status429TooManyRequests);
|
|
}
|
|
|
|
if (link.IsNullOrWhiteSpace() || file.IsNullOrWhiteSpace())
|
|
{
|
|
throw new BadRequestException("Invalid Prowlarr link");
|
|
}
|
|
|
|
file = WebUtility.UrlDecode(file);
|
|
|
|
var source = Request.GetSource();
|
|
var host = Request.GetHostName();
|
|
|
|
var unprotectedlLink = _downloadMappingService.ConvertToNormalLink(link);
|
|
|
|
// If Indexer is set to download via Redirect then just redirect to the link
|
|
if (indexer.SupportsRedirect && indexerDef.Redirect)
|
|
{
|
|
_downloadService.RecordRedirect(unprotectedlLink, id, source, host, file);
|
|
return RedirectPermanent(unprotectedlLink);
|
|
}
|
|
|
|
byte[] downloadBytes;
|
|
|
|
try
|
|
{
|
|
downloadBytes = await _downloadService.DownloadReport(unprotectedlLink, id, source, host, file);
|
|
}
|
|
catch (ReleaseUnavailableException ex)
|
|
{
|
|
return CreateResponse(CreateErrorXML(410, ex.Message), statusCode: StatusCodes.Status410Gone);
|
|
}
|
|
catch (ReleaseDownloadException ex) when (ex.InnerException is TooManyRequestsException http429)
|
|
{
|
|
var http429RetryAfter = Convert.ToInt32(http429.RetryAfter.TotalSeconds);
|
|
AddRetryAfterHeader(http429RetryAfter);
|
|
|
|
return CreateResponse(CreateErrorXML(429, ex.Message), statusCode: StatusCodes.Status429TooManyRequests);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error(ex);
|
|
|
|
return CreateResponse(CreateErrorXML(500, ex.Message), statusCode: StatusCodes.Status500InternalServerError);
|
|
}
|
|
|
|
// handle magnet URLs
|
|
if (downloadBytes.Length >= 7
|
|
&& downloadBytes[0] == 0x6d
|
|
&& downloadBytes[1] == 0x61
|
|
&& downloadBytes[2] == 0x67
|
|
&& downloadBytes[3] == 0x6e
|
|
&& downloadBytes[4] == 0x65
|
|
&& downloadBytes[5] == 0x74
|
|
&& downloadBytes[6] == 0x3a)
|
|
{
|
|
var magnetUrl = Encoding.UTF8.GetString(downloadBytes);
|
|
return RedirectPermanent(magnetUrl);
|
|
}
|
|
|
|
var contentType = indexer.Protocol == DownloadProtocol.Torrent ? "application/x-bittorrent" : "application/x-nzb";
|
|
var extension = indexer.Protocol == DownloadProtocol.Torrent ? "torrent" : "nzb";
|
|
var filename = $"{file}.{extension}";
|
|
|
|
return File(downloadBytes, contentType, filename);
|
|
}
|
|
|
|
public static string CreateErrorXML(int code, string description)
|
|
{
|
|
var xdoc = new XDocument(
|
|
new XDeclaration("1.0", "UTF-8", null),
|
|
new XElement("error",
|
|
new XAttribute("code", code.ToString()),
|
|
new XAttribute("description", description)));
|
|
|
|
return xdoc.Declaration + Environment.NewLine + xdoc;
|
|
}
|
|
|
|
private ContentResult CreateResponse(string content, string contentType = "application/rss+xml", int statusCode = StatusCodes.Status200OK)
|
|
{
|
|
var mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(contentType);
|
|
|
|
return new ContentResult
|
|
{
|
|
StatusCode = statusCode,
|
|
Content = content,
|
|
ContentType = mediaTypeHeaderValue.ToString()
|
|
};
|
|
}
|
|
|
|
private ProviderStatusBase GetBlockedIndexerStatus(IIndexer indexer)
|
|
{
|
|
var blockedIndexers = _indexerStatusService.GetBlockedProviders().ToDictionary(v => v.ProviderId, v => v);
|
|
|
|
return blockedIndexers.TryGetValue(indexer.Definition.Id, out var blockedIndexerStatus) ? blockedIndexerStatus : null;
|
|
}
|
|
|
|
private void AddRetryAfterHeader(int retryAfterSeconds)
|
|
{
|
|
if (!HttpContext.Response.Headers.ContainsKey("Retry-After") && retryAfterSeconds > 0)
|
|
{
|
|
HttpContext.Response.Headers.Add("Retry-After", $"{retryAfterSeconds}");
|
|
}
|
|
}
|
|
}
|
|
}
|