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 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.Q, TvSearchParam.Season, TvSearchParam.Ep }, MovieSearchParams = new List { MovieSearchParam.Q }, MusicSearchParams = new List { MusicSearchParam.Q }, BookSearchParams = new List { 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 { 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 { 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 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}"); } } } }