diff --git a/schemas/torznab.xsd b/schemas/torznab.xsd
new file mode 100644
index 000000000..810711ee6
--- /dev/null
+++ b/schemas/torznab.xsd
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/NzbDrone.Core.Test/Files/RSS/torznab_hdaccess_net.xml b/src/NzbDrone.Core.Test/Files/RSS/torznab_hdaccess_net.xml
new file mode 100644
index 000000000..98b284bf5
--- /dev/null
+++ b/src/NzbDrone.Core.Test/Files/RSS/torznab_hdaccess_net.xml
@@ -0,0 +1,143 @@
+
+
+
+
+ HDAccess
+ HDAccess API
+ https://hdaccess.net
+ en-us
+ ($email) (HDA Invites)
+ search
+
+ https://hdaccess.net/logo_small.png
+ HDAccess
+ https://hdaccess.net
+ HDAccess API
+
+
+ -
+ Better Call Saul S01E05 Alpine Shepherd 1080p NF WEBRip DD5.1 x264
+ https://hdaccess.net/details.php?id=11515
+ https://hdaccess.net/download.php?torrent=11515&passkey=123456
+ https://hdaccess.net/details.php?id=11515&hit=1#comments
+ Sat, 14 Mar 2015 17:10:42 -0400
+ HDTV 1080p
+ 2538463390
+ Better.Call.Saul.S01E05.Alpine.Shepherd.1080p.NF.WEBRip.DD5.1.x264.torrent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ Ocean Giants 2013 1080p 3D BluRay Remux MVC DTS-HD MA 5.1-HDAccess
+ https://hdaccess.net/details.php?id=11511
+ https://hdaccess.net/download.php?torrent=11511&passkey=123456
+ https://hdaccess.net/details.php?id=11511&hit=1#comments
+ Sat, 14 Mar 2015 16:33:42 -0400
+ CUSTOM 3D BD
+ 15330508800
+ Ocean Giants 2013 1080p 3D BluRay Remux MVC DTS-HD MA 5.1-HDAccess.torrent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ Wild 2014 720p BluRay DTS x264-HDAccess
+ https://hdaccess.net/details.php?id=11506
+ https://hdaccess.net/download.php?torrent=11506&passkey=123456
+ https://hdaccess.net/details.php?id=11506&hit=1#comments
+ Sat, 14 Mar 2015 14:28:43 -0400
+ 720p
+ 6501510357
+ Wild.2014.720p.BluRay.DTS.x264-HDAccess.torrent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ Absolute Power 1997.1080p BluRay Remux AVC DTS-HD MA 5.1-HDX
+ https://hdaccess.net/details.php?id=11504
+ https://hdaccess.net/download.php?torrent=11504&passkey=123456
+ https://hdaccess.net/details.php?id=11504&hit=1#comments
+ Sat, 14 Mar 2015 13:34:08 -0400
+ REMUX
+ 25267070253
+ Absolute.Power.1997.1080p.BluRay.Remux.AVC.DTS-HD.MA.5.1-HDX.mkv.torrent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ 12 Monkeys S01E09 Tomorrow 720p WEB-DL DD5.1 H.264-BS
+ https://hdaccess.net/details.php?id=11501
+ https://hdaccess.net/download.php?torrent=11501&passkey=123456
+ https://hdaccess.net/details.php?id=11501&hit=1#comments
+ Sat, 14 Mar 2015 12:42:19 -0400
+ TV 720p WEB-DL
+ 1397243303
+ 12.Monkeys.S01E09.Tomorrow.720p.WEB-DL.DD5.1.H.264-BS.torrent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs
new file mode 100644
index 000000000..d4b456d16
--- /dev/null
+++ b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Linq;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using NzbDrone.Common.Http;
+using NzbDrone.Core.Indexers;
+using NzbDrone.Core.Indexers.Torznab;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Core.Test.Framework;
+
+namespace NzbDrone.Core.Test.IndexerTests.TorznabTests
+{
+ [TestFixture]
+ public class TorznabFixture : CoreTest
+ {
+ [SetUp]
+ public void Setup()
+ {
+ Subject.Definition = new IndexerDefinition()
+ {
+ Name = "Torznab",
+ Settings = new TorznabSettings()
+ {
+ Url = "http://indexer.local/",
+ Categories = new Int32[] { 1 }
+ }
+ };
+ }
+
+ [Test]
+ public void should_parse_recent_feed_from_torznab_hdaccess_net()
+ {
+ var recentFeed = ReadAllText(@"Files/RSS/torznab_hdaccess_net.xml");
+
+ Mocker.GetMock()
+ .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET)))
+ .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed));
+
+ var releases = Subject.FetchRecent();
+
+ releases.Should().HaveCount(5);
+
+ releases.First().Should().BeOfType();
+ var releaseInfo = releases.First() as TorrentInfo;
+
+ releaseInfo.Title.Should().Be("Better Call Saul S01E05 Alpine Shepherd 1080p NF WEBRip DD5.1 x264");
+ releaseInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent);
+ releaseInfo.DownloadUrl.Should().Be("https://hdaccess.net/download.php?torrent=11515&passkey=123456");
+ releaseInfo.InfoUrl.Should().Be("https://hdaccess.net/details.php?id=11515&hit=1");
+ releaseInfo.CommentUrl.Should().Be("https://hdaccess.net/details.php?id=11515&hit=1#comments");
+ releaseInfo.Indexer.Should().Be(Subject.Definition.Name);
+ releaseInfo.PublishDate.Should().Be(DateTime.Parse("2015/03/14 21:10:42"));
+ releaseInfo.Size.Should().Be(2538463390);
+ releaseInfo.TvRageId.Should().Be(37780);
+ releaseInfo.InfoHash.Should().Be("63e07ff523710ca268567dad344ce1e0e6b7e8a3");
+ releaseInfo.Seeders.Should().Be(7);
+ releaseInfo.Peers.Should().Be(7);
+ }
+ }
+}
diff --git a/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabRequestGeneratorFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabRequestGeneratorFixture.cs
new file mode 100644
index 000000000..27dc4392e
--- /dev/null
+++ b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabRequestGeneratorFixture.cs
@@ -0,0 +1,122 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using FluentAssertions;
+using NUnit.Framework;
+using NzbDrone.Core.Indexers.Torznab;
+using NzbDrone.Core.IndexerSearch.Definitions;
+using NzbDrone.Core.Test.Framework;
+
+namespace NzbDrone.Core.Test.IndexerTests.TorznabTests
+{
+ public class TorznabRequestGeneratorFixture : CoreTest
+ {
+ AnimeEpisodeSearchCriteria _animeSearchCriteria;
+
+ [SetUp]
+ public void SetUp()
+ {
+ Subject.Settings = new TorznabSettings()
+ {
+ Url = "http://127.0.0.1:1234/",
+ Categories = new [] { 1, 2 },
+ AnimeCategories = new [] { 3, 4 },
+ ApiKey = "abcd",
+ };
+
+ _animeSearchCriteria = new AnimeEpisodeSearchCriteria()
+ {
+ SceneTitles = new List() { "Monkey+Island" },
+ AbsoluteEpisodeNumber = 100
+ };
+ }
+
+ [Test]
+ public void should_return_one_page_for_feed()
+ {
+ var results = Subject.GetRecentRequests();
+
+ results.Should().HaveCount(1);
+
+ var pages = results.First().Take(10).ToList();
+
+ pages.Should().HaveCount(1);
+ }
+
+ [Test]
+ public void should_use_all_categories_for_feed()
+ {
+ var results = Subject.GetRecentRequests();
+
+ results.Should().HaveCount(1);
+
+ var page = results.First().First();
+
+ page.Url.Query.Should().Contain("&cat=1,2,3,4&");
+ }
+
+ [Test]
+ public void should_not_have_duplicate_categories()
+ {
+ Subject.Settings.Categories = new[] { 1, 2, 3 };
+
+ var results = Subject.GetRecentRequests();
+
+ results.Should().HaveCount(1);
+
+ var page = results.First().First();
+
+ page.Url.Query.Should().Contain("&cat=1,2,3,4&");
+ }
+
+ [Test]
+ public void should_use_only_anime_categories_for_anime_search()
+ {
+ var results = Subject.GetSearchRequests(_animeSearchCriteria);
+
+ results.Should().HaveCount(1);
+
+ var page = results.First().First();
+
+ page.Url.Query.Should().Contain("&cat=3,4&");
+ }
+
+ [Test]
+ public void should_use_mode_search_for_anime()
+ {
+ var results = Subject.GetSearchRequests(_animeSearchCriteria);
+
+ results.Should().HaveCount(1);
+
+ var page = results.First().First();
+
+ page.Url.Query.Should().Contain("?t=search&");
+ }
+
+ [Test]
+ public void should_return_subsequent_pages()
+ {
+ var results = Subject.GetSearchRequests(_animeSearchCriteria);
+
+ results.Should().HaveCount(1);
+
+ var pages = results.First().Take(3).ToList();
+
+ pages[0].Url.Query.Should().Contain("&offset=0&");
+ pages[1].Url.Query.Should().Contain("&offset=100&");
+ pages[2].Url.Query.Should().Contain("&offset=200&");
+ }
+
+ [Test]
+ public void should_not_get_unlimited_pages()
+ {
+ var results = Subject.GetSearchRequests(_animeSearchCriteria);
+
+ results.Should().HaveCount(1);
+
+ var pages = results.First().Take(500).ToList();
+
+ pages.Count.Should().BeLessThan(500);
+ }
+ }
+}
diff --git a/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabSettingFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabSettingFixture.cs
new file mode 100644
index 000000000..2fc06ba66
--- /dev/null
+++ b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabSettingFixture.cs
@@ -0,0 +1,58 @@
+using FluentAssertions;
+using NUnit.Framework;
+using NzbDrone.Core.Indexers.Torznab;
+using NzbDrone.Core.Test.Framework;
+
+namespace NzbDrone.Core.Test.IndexerTests.TorznabTests
+{
+ public class TorznabSettingFixture : CoreTest
+ {
+
+ [TestCase("http://hdaccess.net")]
+ public void requires_apikey(string url)
+ {
+ var setting = new TorznabSettings()
+ {
+ ApiKey = "",
+ Url = url
+ };
+
+
+ setting.Validate().IsValid.Should().BeFalse();
+ setting.Validate().Errors.Should().Contain(c => c.PropertyName == "ApiKey");
+
+ }
+
+ [TestCase("")]
+ [TestCase(" ")]
+ [TestCase(null)]
+ public void invalid_url_should_not_apikey(string url)
+ {
+ var setting = new TorznabSettings
+ {
+ ApiKey = "",
+ Url = url
+ };
+
+
+ setting.Validate().IsValid.Should().BeFalse();
+ setting.Validate().Errors.Should().NotContain(c => c.PropertyName == "ApiKey");
+ setting.Validate().Errors.Should().Contain(c => c.PropertyName == "Url");
+
+ }
+
+
+ [TestCase("http://myfancytracker.net")]
+ public void doesnt_requires_apikey(string url)
+ {
+ var setting = new TorznabSettings()
+ {
+ ApiKey = "",
+ Url = url
+ };
+
+
+ setting.Validate().IsValid.Should().BeTrue();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj
index 0bf5d926a..4a8a38fae 100644
--- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj
+++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj
@@ -202,6 +202,9 @@
+
+
+
@@ -406,6 +409,9 @@
Always
+
+ Always
+
Always
diff --git a/src/NzbDrone.Core/Indexers/Exceptions/UnsupportedFeedException.cs b/src/NzbDrone.Core/Indexers/Exceptions/UnsupportedFeedException.cs
new file mode 100644
index 000000000..b0918744d
--- /dev/null
+++ b/src/NzbDrone.Core/Indexers/Exceptions/UnsupportedFeedException.cs
@@ -0,0 +1,17 @@
+using NzbDrone.Common.Exceptions;
+
+namespace NzbDrone.Core.Indexers.Exceptions
+{
+ public class UnsupportedFeedException : NzbDroneException
+ {
+ public UnsupportedFeedException(string message, params object[] args)
+ : base(message, args)
+ {
+ }
+
+ public UnsupportedFeedException(string message)
+ : base(message)
+ {
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs
new file mode 100644
index 000000000..20533f05d
--- /dev/null
+++ b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NLog;
+using NzbDrone.Common.Http;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.Parser;
+using NzbDrone.Core.ThingiProvider;
+
+namespace NzbDrone.Core.Indexers.Torznab
+{
+ public class Torznab : HttpIndexerBase
+ {
+ public override DownloadProtocol Protocol { get { return DownloadProtocol.Torrent; } }
+ public override Int32 PageSize { get { return 100; } }
+
+ public override IIndexerRequestGenerator GetRequestGenerator()
+ {
+ return new TorznabRequestGenerator()
+ {
+ PageSize = PageSize,
+ Settings = Settings
+ };
+ }
+
+ public override IParseIndexerResponse GetParser()
+ {
+ return new TorznabRssParser();
+ }
+
+ public override IEnumerable DefaultDefinitions
+ {
+ get
+ {
+ yield return GetDefinition("HDAccess.net", GetSettings("http://hdaccess.net"));
+ }
+ }
+
+ public Torznab(IHttpClient httpClient, IConfigService configService, IParsingService parsingService, Logger logger)
+ : base(httpClient, configService, parsingService, logger)
+ {
+
+ }
+
+ private IndexerDefinition GetDefinition(String name, TorznabSettings settings)
+ {
+ return new IndexerDefinition
+ {
+ EnableRss = false,
+ EnableSearch = false,
+ Name = name,
+ Implementation = GetType().Name,
+ Settings = settings,
+ Protocol = DownloadProtocol.Usenet,
+ SupportsRss = SupportsRss,
+ SupportsSearch = SupportsSearch
+ };
+ }
+
+ private TorznabSettings GetSettings(String url, params int[] categories)
+ {
+ var settings = new TorznabSettings { Url = url };
+
+ if (categories.Any())
+ {
+ settings.Categories = categories;
+ }
+
+ return settings;
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Indexers/Torznab/TorznabException.cs b/src/NzbDrone.Core/Indexers/Torznab/TorznabException.cs
new file mode 100644
index 000000000..7f0f3205a
--- /dev/null
+++ b/src/NzbDrone.Core/Indexers/Torznab/TorznabException.cs
@@ -0,0 +1,16 @@
+using NzbDrone.Common.Exceptions;
+
+namespace NzbDrone.Core.Indexers.Torznab
+{
+ public class TorznabException : NzbDroneException
+ {
+ public TorznabException(string message, params object[] args) : base(message, args)
+ {
+ }
+
+ public TorznabException(string message)
+ : base(message)
+ {
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Indexers/Torznab/TorznabRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Torznab/TorznabRequestGenerator.cs
new file mode 100644
index 000000000..b506b26ac
--- /dev/null
+++ b/src/NzbDrone.Core/Indexers/Torznab/TorznabRequestGenerator.cs
@@ -0,0 +1,175 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Common.Http;
+using NzbDrone.Core.IndexerSearch.Definitions;
+
+namespace NzbDrone.Core.Indexers.Torznab
+{
+ public class TorznabRequestGenerator : IIndexerRequestGenerator
+ {
+ public Int32 MaxPages { get; set; }
+ public Int32 PageSize { get; set; }
+ public TorznabSettings Settings { get; set; }
+
+ public TorznabRequestGenerator()
+ {
+ MaxPages = 30;
+ PageSize = 100;
+ }
+
+ public virtual IList> GetRecentRequests()
+ {
+ var pageableRequests = new List>();
+
+ // TODO: We might consider getting multiple pages in the future, but atm we limit it to 1 page.
+ pageableRequests.AddIfNotNull(GetPagedRequests(1, Settings.Categories.Concat(Settings.AnimeCategories), "tvsearch", ""));
+
+ return pageableRequests;
+ }
+
+ public virtual IList> GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria)
+ {
+ var pageableRequests = new List>();
+
+ if (searchCriteria.Series.TvRageId > 0)
+ {
+ pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, Settings.Categories, "tvsearch",
+ String.Format("&rid={0}&season={1}&ep={2}",
+ searchCriteria.Series.TvRageId,
+ searchCriteria.SeasonNumber,
+ searchCriteria.EpisodeNumber)));
+ }
+ else
+ {
+ foreach (var queryTitle in searchCriteria.QueryTitles)
+ {
+ pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, Settings.Categories, "tvsearch",
+ String.Format("&q={0}&season={1}&ep={2}",
+ NewsnabifyTitle(queryTitle),
+ searchCriteria.SeasonNumber,
+ searchCriteria.EpisodeNumber)));
+ }
+ }
+
+ return pageableRequests;
+ }
+
+ public virtual IList> GetSearchRequests(SeasonSearchCriteria searchCriteria)
+ {
+ var pageableRequests = new List>();
+
+ if (searchCriteria.Series.TvRageId > 0)
+ {
+ pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, Settings.Categories, "tvsearch",
+ String.Format("&rid={0}&season={1}",
+ searchCriteria.Series.TvRageId,
+ searchCriteria.SeasonNumber)));
+ }
+ else
+ {
+ foreach (var queryTitle in searchCriteria.QueryTitles)
+ {
+ pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, Settings.Categories, "tvsearch",
+ String.Format("&q={0}&season={1}",
+ NewsnabifyTitle(queryTitle),
+ searchCriteria.SeasonNumber)));
+ }
+ }
+
+ return pageableRequests;
+ }
+
+ public virtual IList> GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria)
+ {
+ var pageableRequests = new List>();
+
+ if (searchCriteria.Series.TvRageId > 0)
+ {
+ pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, Settings.Categories, "tvsearch",
+ String.Format("&rid={0}&season={1:yyyy}&ep={1:MM}/{1:dd}",
+ searchCriteria.Series.TvRageId,
+ searchCriteria.AirDate)));
+ }
+ else
+ {
+ foreach (var queryTitle in searchCriteria.QueryTitles)
+ {
+ pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, Settings.Categories, "tvsearch",
+ String.Format("&q={0}&season={1:yyyy}&ep={1:MM}/{1:dd}",
+ NewsnabifyTitle(queryTitle),
+ searchCriteria.AirDate)));
+ }
+ }
+
+ return pageableRequests;
+ }
+
+ public virtual IList> GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria)
+ {
+ var pageableRequests = new List>();
+
+ foreach (var queryTitle in searchCriteria.QueryTitles)
+ {
+ pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, Settings.AnimeCategories, "search",
+ String.Format("&q={0}+{1:00}",
+ NewsnabifyTitle(queryTitle),
+ searchCriteria.AbsoluteEpisodeNumber)));
+ }
+
+ return pageableRequests;
+ }
+
+ public virtual IList> GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria)
+ {
+ var pageableRequests = new List>();
+
+ foreach (var queryTitle in searchCriteria.EpisodeQueryTitles)
+ {
+ var query = queryTitle.Replace('+', ' ');
+ query = System.Web.HttpUtility.UrlEncode(query);
+
+ pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, Settings.Categories.Concat(Settings.AnimeCategories), "search",
+ String.Format("&q={0}",
+ query)));
+ }
+
+ return pageableRequests;
+ }
+
+ private IEnumerable GetPagedRequests(Int32 maxPages, IEnumerable categories, String searchType, String parameters)
+ {
+ if (categories.Empty())
+ {
+ yield break;
+ }
+
+ var categoriesQuery = String.Join(",", categories.Distinct());
+
+ var baseUrl = String.Format("{0}/api?t={1}&cat={2}&extended=1{3}", Settings.Url.TrimEnd('/'), searchType, categoriesQuery, Settings.AdditionalParameters);
+
+ if (Settings.ApiKey.IsNotNullOrWhiteSpace())
+ {
+ baseUrl += "&apikey=" + Settings.ApiKey;
+ }
+
+ if (PageSize == 0)
+ {
+ yield return new IndexerRequest(String.Format("{0}{1}", baseUrl, parameters), HttpAccept.Rss);
+ }
+ else
+ {
+ for (var page = 0; page < maxPages; page++)
+ {
+ yield return new IndexerRequest(String.Format("{0}&offset={1}&limit={2}{3}", baseUrl, page * PageSize, PageSize, parameters), HttpAccept.Rss);
+ }
+ }
+ }
+
+ private static String NewsnabifyTitle(String title)
+ {
+ return title.Replace("+", "%20");
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs b/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs
new file mode 100644
index 000000000..0badab6f2
--- /dev/null
+++ b/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs
@@ -0,0 +1,168 @@
+using System;
+using System.Linq;
+using System.Xml.Linq;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Indexers.Exceptions;
+using NzbDrone.Core.Parser.Model;
+
+namespace NzbDrone.Core.Indexers.Torznab
+{
+ public class TorznabRssParser : TorrentRssParser
+ {
+ public const String ns = "{http://torznab.com/schemas/2015/feed}";
+
+ protected override bool PreProcess(IndexerResponse indexerResponse)
+ {
+ var xdoc = XDocument.Parse(indexerResponse.Content);
+ var error = xdoc.Descendants("error").FirstOrDefault();
+
+ if (error == null) return true;
+
+ var code = Convert.ToInt32(error.Attribute("code").Value);
+ var errorMessage = error.Attribute("description").Value;
+
+ if (code >= 100 && code <= 199) throw new ApiKeyException("Invalid API key");
+
+ if (!indexerResponse.Request.Url.ToString().Contains("apikey=") && errorMessage == "Missing parameter")
+ {
+ throw new ApiKeyException("Indexer requires an API key");
+ }
+
+ if (errorMessage == "Request limit reached")
+ {
+ throw new RequestLimitReachedException("API limit reached");
+ }
+
+ throw new TorznabException("Torznab error detected: {0}", errorMessage);
+ }
+
+ protected override ReleaseInfo ProcessItem(XElement item, ReleaseInfo releaseInfo)
+ {
+ var torrentInfo = base.ProcessItem(item, releaseInfo) as TorrentInfo;
+
+ torrentInfo.TvRageId = GetTvRageId(item);
+
+ return torrentInfo;
+ }
+
+ protected override ReleaseInfo PostProcess(XElement item, ReleaseInfo releaseInfo)
+ {
+ var enclosureType = item.Element("enclosure").Attribute("type").Value;
+ if (enclosureType != "application/x-bittorrent")
+ {
+ throw new UnsupportedFeedException("Feed contains {0} instead of application/x-bittorrent", enclosureType);
+ }
+
+ return base.PostProcess(item, releaseInfo);
+ }
+
+
+ protected override String GetInfoUrl(XElement item)
+ {
+ return item.TryGetValue("comments").TrimEnd("#comments");
+ }
+
+ protected override String GetCommentUrl(XElement item)
+ {
+ return item.TryGetValue("comments");
+ }
+
+ protected override Int64 GetSize(XElement item)
+ {
+ Int64 size;
+
+ var sizeString = TryGetTorznabAttribute(item, "size");
+ if (!sizeString.IsNullOrWhiteSpace() && Int64.TryParse(sizeString, out size))
+ {
+ return size;
+ }
+
+ size = GetEnclosureLength(item);
+
+ return size;
+ }
+
+ protected override DateTime GetPublishDate(XElement item)
+ {
+ return base.GetPublishDate(item);
+ }
+
+ protected override string GetDownloadUrl(XElement item)
+ {
+ var url = base.GetDownloadUrl(item);
+
+ if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
+ {
+ url = item.Element("enclosure").Attribute("url").Value;
+ }
+
+ return url;
+ }
+
+ protected virtual Int32 GetTvRageId(XElement item)
+ {
+ var tvRageIdString = TryGetTorznabAttribute(item, "rageid");
+ Int32 tvRageId;
+
+ if (!tvRageIdString.IsNullOrWhiteSpace() && Int32.TryParse(tvRageIdString, out tvRageId))
+ {
+ return tvRageId;
+ }
+
+ return 0;
+ }
+ protected override String GetInfoHash(XElement item)
+ {
+ return TryGetTorznabAttribute(item, "infohash");
+ }
+
+ protected override String GetMagnetUrl(XElement item)
+ {
+ return TryGetTorznabAttribute(item, "magneturl");
+ }
+
+ protected override Int32? GetSeeders(XElement item)
+ {
+ var seeders = TryGetTorznabAttribute(item, "seeders");
+
+ if (seeders.IsNotNullOrWhiteSpace())
+ {
+ return Int32.Parse(seeders);
+ }
+
+ return base.GetSeeders(item);
+ }
+
+ protected override Int32? GetPeers(XElement item)
+ {
+ var peers = TryGetTorznabAttribute(item, "peers");
+
+ if (peers.IsNotNullOrWhiteSpace())
+ {
+ return Int32.Parse(peers);
+ }
+
+ var seeders = TryGetTorznabAttribute(item, "seeders");
+ var leechers = TryGetTorznabAttribute(item, "leechers");
+
+ if (seeders.IsNotNullOrWhiteSpace() && leechers.IsNotNullOrWhiteSpace())
+ {
+ return Int32.Parse(seeders) + Int32.Parse(leechers);
+ }
+
+ return base.GetPeers(item);
+ }
+
+ protected String TryGetTorznabAttribute(XElement item, String key, String defaultValue = "")
+ {
+ var attr = item.Elements(ns + "attr").SingleOrDefault(e => e.Attribute("name").Value.Equals(key, StringComparison.CurrentCultureIgnoreCase));
+
+ if (attr != null)
+ {
+ return attr.Attribute("value").Value;
+ }
+
+ return defaultValue;
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Indexers/Torznab/TorznabSettings.cs b/src/NzbDrone.Core/Indexers/Torznab/TorznabSettings.cs
new file mode 100644
index 000000000..377fbe133
--- /dev/null
+++ b/src/NzbDrone.Core/Indexers/Torznab/TorznabSettings.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using FluentValidation;
+using FluentValidation.Results;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Annotations;
+using NzbDrone.Core.ThingiProvider;
+using NzbDrone.Core.Validation;
+
+namespace NzbDrone.Core.Indexers.Torznab
+{
+ public class TorznabSettingsValidator : AbstractValidator
+ {
+ private static readonly string[] ApiKeyWhiteList =
+ {
+ "hdaccess.net",
+ };
+
+ private static bool ShouldHaveApiKey(TorznabSettings settings)
+ {
+ if (settings.Url == null)
+ {
+ return false;
+ }
+
+ return ApiKeyWhiteList.Any(c => settings.Url.ToLowerInvariant().Contains(c));
+ }
+
+ private static readonly Regex AdditionalParametersRegex = new Regex(@"(&.+?\=.+?)+", RegexOptions.Compiled);
+
+ public TorznabSettingsValidator()
+ {
+ RuleFor(c => c.Url).ValidRootUrl();
+ RuleFor(c => c.ApiKey).NotEmpty().When(ShouldHaveApiKey);
+ RuleFor(c => c.Categories).NotEmpty().When(c => !c.AnimeCategories.Any());
+ RuleFor(c => c.AnimeCategories).NotEmpty().When(c => !c.Categories.Any());
+ RuleFor(c => c.AdditionalParameters)
+ .Matches(AdditionalParametersRegex)
+ .When(c => !c.AdditionalParameters.IsNullOrWhiteSpace());
+ }
+ }
+
+ public class TorznabSettings : IProviderConfig
+ {
+ private static readonly TorznabSettingsValidator Validator = new TorznabSettingsValidator();
+
+ public TorznabSettings()
+ {
+ Categories = new[] { 5030, 5040 };
+ AnimeCategories = Enumerable.Empty();
+ }
+
+ [FieldDefinition(0, Label = "URL")]
+ public String Url { get; set; }
+
+ [FieldDefinition(1, Label = "API Key")]
+ public String ApiKey { get; set; }
+
+ [FieldDefinition(2, Label = "Categories", HelpText = "Comma Separated list, leave blank to disable standard/daily shows", Advanced = true)]
+ public IEnumerable Categories { get; set; }
+
+ [FieldDefinition(3, Label = "Anime Categories", HelpText = "Comma Separated list, leave blank to disable anime", Advanced = true)]
+ public IEnumerable AnimeCategories { get; set; }
+
+ [FieldDefinition(4, Label = "Additional Parameters", HelpText = "Additional Torznab parameters", Advanced = true)]
+ public String AdditionalParameters { get; set; }
+
+ public ValidationResult Validate()
+ {
+ return Validator.Validate(this);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj
index 5ad216cd3..2ce666105 100644
--- a/src/NzbDrone.Core/NzbDrone.Core.csproj
+++ b/src/NzbDrone.Core/NzbDrone.Core.csproj
@@ -451,6 +451,7 @@
+
@@ -499,6 +500,11 @@
+
+
+
+
+